今天摸鱼了吗APP开发实战:基于HarmonyOS API 24的多层Stack与定时器应用
摸鱼计时、老板警报、一键切换假工作界面——一个充满幽默感的办公场景模拟器。本文从多层Stack布局到setInterval计时器从演技评分算法到数据持久化完整记录开发全过程。一、项目缘起为什么做今天摸鱼了吗1.1 创意来源“摸鱼”——这个源自网络的热词已经成为当代职场文化不可或缺的一部分。它描述的是一种在工作时间偷偷做与工作无关的事情的行为带着自嘲和幽默的色彩。今天摸鱼了吗APP正是抓住了这个文化梗将其转化为一个有趣的互动游戏。它不是鼓励摸鱼而是用一种戏谑的方式呈现职场中的小趣味。1.2 产品设计功能体验目标 摸鱼计时启动APP即开始计时看到摸鱼时长不断增加有种罪恶的快感 老板警报随机弹出警报或手动演习营造紧张感 演技评分每次警报后给出评分和评语像游戏一样有反馈 假装工作界面一键切换到仿Excel界面增加安全感1.3 技术选型维度选择理由UI架构多层Stack需要叠加普通UI、弹窗、假界面、结果浮层计时器setInterval摸鱼计时每秒更新延迟setTimeout模拟警报持续时间、结果自动关闭数据持久化Preferences存储演技历史记录版本API 24HarmonyOS NEXT二、UI架构多层Stack的实战应用2.1 为什么需要多层Stack这个APP的UI层级非常复杂——同时存在5层┌─────────────────────────────────────────────┐ │ 第5层假装工作界面 (buildFakeWorkView) │ ← 紧急时覆盖一切 ├─────────────────────────────────────────────┤ │ 第4层演技评分结果弹窗 (buildResultOverlay)│ ← 警报解除后显示4秒 ├─────────────────────────────────────────────┤ │ 第3层老板警报弹窗 (buildBossAlert) │ ← 随机触发 ├─────────────────────────────────────────────┤ │ 第2层底部导航栏 (buildBottomNav) │ ← 常驻底部 ├─────────────────────────────────────────────┤ │ 第1层主内容区 (buildFishView/historyView) │ ← 常规UI └─────────────────────────────────────────────┘在传统UI框架中这种多层叠加需要用Dialog或Modal来实现。但在ArkUI中Stack组件天然支持子组件的层叠排列build(){Stack(){Column(){// 第1-2层主内容 导航buildFishView()/buildHistoryView()buildBottomNav()}buildBossAlert()// 第3层警报弹窗条件渲染buildResultOverlay()// 第4层评分结果条件渲染buildFakeWorkView()// 第5层假工作界面条件渲染}}Stack的特点是子组件按声明顺序从下到上层叠后声明的在上层。我们只需要用if条件控制每一层的显隐ArkUI会自动管理它们的渲染。2.2 各层的显隐条件层级显隐条件覆盖范围主内容始终显示全屏底部导航始终显示底部56px警报弹窗showBossAlert true半透明遮罩 居中卡片评分结果showResult true半透明遮罩 居中卡片假工作界面isPanic true全屏覆盖2.3 全屏覆盖层的特殊性buildBossAlert()和buildResultOverlay()是全屏遮罩层它们的布局模式相同BuilderbuildBossAlert(){Column(){Column(){// 弹窗内容}.width(85%).padding(28).backgroundColor(#FFF).borderRadius(20);}.width(100%).height(100%).backgroundColor(#80000000).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center);}关键点外层Column全屏尺寸 半透明黑色背景#80000000使用justifyContent和alignItems实现居中内层Column85%宽度白色背景圆角2.4 假工作界面的位置buildFakeWorkView()使用Stack作为根容器来实现底部状态栏和提示文字的定位BuilderbuildFakeWorkView(){Stack(){Column(){// Excel工具栏 数据表格}.width(100%).height(100%);// 底部状态栏Row(){...}.width(100%).padding(6).backgroundColor(#333).alignSelf(ItemAlign.Bottom);// 提示文字Text(⏳ 老板正在巡视保持淡定...).width(100%).textAlign(TextAlign.Center).alignSelf(ItemAlign.Bottom).margin({bottom:36});}.width(100%).height(100%).backgroundColor(#1E1E1E);}这里的Stack与最外层的Stack形成了Stack嵌套Stack的层级结构。内层Stack负责Excel界面的内部布局外层Stack负责整个APP的层级管理。三、定时器管理setInterval与setTimeout3.1 摸鱼计时器摸鱼计时是APP运行的核心机制——它从aboutToAppear开始每秒更新一次privatetimerId:number-1;aboutToAppear():void{this.loadHistory();this.startFishTimer();}startFishTimer():void{this.timerIdsetInterval((){this.fishTime;},1000);}关键设计fishTime是 State 变量每秒变化触发UI更新显示timerId是number类型ArkTS中setInterval返回number理论上组件销毁时应clearInterval但单页应用无需担心3.2 警报持续时间控制当用户点击假工作按钮后警报不会立即解除而是模拟2-4秒的危险期panic():void{this.showBossAlertfalse;this.panicStartDate.now();this.isPanictrue;constduration2000Math.floor(Math.random()*2000);setTimeout((){this.endPanic();},duration);}设计意图2-4秒的随机时长模拟真实场景——老板不可能看一眼就走随机性让每次体验不同足够长到让用户感受到紧张又不会长到不耐烦3.3 评分结果自动关闭评分结果显示4秒后自动消失this.showResulttrue;setTimeout((){this.showResultfalse;},4000);3.4 定时器最佳实践问题在ArkTS中setInterval和setTimeout的返回值类型是什么// JavaScript环境返回 number浏览器或 NodeJS.TimeoutNode// ArkTS返回 numberprivatetimerId:number-1;清理定时器clearInterval(this.timerId);注意事项组件销毁时清理定时器使用aboutToDisappear生命周期避免在定时器回调中执行耗时操作定时器回调中访问this需要使用箭头函数保持绑定四、演技评分算法4.1 评分公式演技评分是游戏的核心反馈机制。评分基于两个因素反应时间从警报出现到用户点击假工作的时间单位ms基础分 100 扣分 反应时间(ms) / 50 原始分 max(10, 100 - 扣分) 最终分 max(10, min(100, 原始分 随机波动 ±10))逻辑反应越快分数越高。每慢50ms扣1分。100ms反应 98分500ms反应 90分1000ms反应 80分。随机波动±10分让评分有变化不会每次都一样。4.2 评级系统根据分数给出6个等级分数评级表情评语≥90 S金色奥斯卡影帝老板完全没察觉75-89 A绿色演技精湛毫无破绽60-74 B蓝色反应不错像个认真工作的好员工。40-59 C橙色中规中矩勉强过关。20-39 D红色太假了你紧张什么20 F紫色演技堪忧建议回炉重造。评级代码if(finalScore90){this.gradeS;this.gradeEmoji;}elseif(finalScore75){this.gradeA;this.gradeEmoji;}// ...4.3 反应时间的测量反应时间通过Date.now()的前后差值计算panic():void{// 记录警报出现时间实际上是用户点击按钮的时间this.panicStartDate.now();// ...}endPanic():void{this.reactionMsDate.now()-this.panicStart;// 计算评分...}注意这里的反应时间实际包括警报持续时间2-4秒加上用户点击到警报解除的时间。因为panicStart是在用户点击假工作时记录而endPanic在2-4秒后触发。所以实际的反应时间值包含了等待时间但这正好符合游戏设计——警报解除越快评分越高。4.4 演技历史记录每次评分后记录存入actingHistory数组并通过 Preferences 持久化interfaceActingRecord{id:number// 自增IDtime:string// 发生时间score:number// 评分comment:string// 评语reactionMs:number// 反应时间(ms)}五、UI实现详解5.1 摸鱼主界面摸鱼主界面是用户打开APP后的默认视图布局如下Column ├── 标题行 今天摸鱼了吗 ├── 摸鱼计时88:88:88 (48fp, 等宽字体, 青绿色) ├── 下班倒计时还剩 X 小时 X 分钟 (黄色) ├── 鱼缸区 (layoutWeight:1) │ ├── (每10秒切换) │ └── 摸鱼等级 摸鱼大师 ├── 演习按钮 摸鱼演习 (深蓝底) └── 紧急按钮⚠️ 老板来了(红色, 带阴影)鱼缸动画fishTime / 10 % 5每10秒切换一次表情从 →→→→ 循环。虽然是简单的数组索引切换但给计时器增加了视觉趣味。5.2 底部导航两个标签 摸鱼 / 演技史选中标签高亮为青绿色#4ECDC4与APP的暗色调主题#1a1a2e背景形成鲜明对比。5.3 演技历史视图历史记录列表展示所有评分记录List └── ForEach: actingHistory └── ListItem └── Row ├── 评级表情 (28fp, 40px宽) ├── Column (layoutWeight:1) │ ├── Row: 分数 反应时间 │ └── Text: 评语 (单行省略) └── Text: 时间每条记录的颜色编码与评级匹配绿色代表高分红色代表低分。5.4 假装工作界面Excel模拟这是最有趣的UI部分——一个仿 Microsoft Excel 的界面标题栏深绿色背景 2024年度Q4销售数据报表.xlsx - Excel工具栏深灰背景文件 | 开始 | 插入 | 页面布局 | 公式 | 数据 | 审阅 | 视图数据表格一行表头 六行数据月份 销售额 利润 增长率 一月 135万 25万 10% 二月 150万 30万 12% ...共6行随机数据底部状态栏固定在底部就绪 平均值45.2万 计数6提示文字固定在底部状态栏上方⏳ 老板正在巡视保持淡定...设计细节表格行交替颜色#2A2A2A/#222增长率使用绿色#4CAF50模拟Excel的正数显示数据随机生成每次进入假界面都不同六、踩坑合集坑1.position({ absolute: true }) 在ArkUI中不可用症状.position({ absolute: true, top: -2, right: 4 })报错。原因absolute: true是CSS语法ArkUI不支持。ArkUI的.position()只接受{ x: number, y: number }或{ top, left, right, bottom }。修复方案方案一使用Stack作为父容器子组件通过.alignSelf()定位Stack(){Column(){/* 主内容 */}Text(状态栏).alignSelf(ItemAlign.Bottom);// ✅ 固定在底部}方案二使用.offset()进行偏移Text(徽标).offset({x:4,y:-2});// ✅ 相对当前位置偏移方案三使用.margin()推动位置。最佳实践在ArkUI中需要绝对定位时优先考虑Stack.alignSelf()的组合而不是依赖.position()。坑2switch语句缺少default分支症状getGradeColor方法中 switch 没有 defaultArkTS 编译报错。修复添加default: return #888。教训ArkTS的 switch 语句比 TypeScript 更严格——即使代码逻辑上已经覆盖了所有可能的 case编译器仍然要求有 default。坑3setInterval的类型症状将setInterval返回值赋给number类型变量时不确定是否正确。说明在ArkTS中setInterval和setTimeout都返回number类型。这与浏览器环境一致浏览器中setInterval也返回number。privatetimerId:number-1;// ✅ 正确坑4Stack中子组件的z-order症状在 Stack 中子组件的层叠顺序不符合预期。规则在 Stack 中子组件按声明顺序从下到上层叠后面声明的在上面。Stack(){Column()/* 第1层最底层 */Text()/* 第2层 */Row()/* 第3层最顶层 */}如果需要控制特定组件的层级调整声明顺序即可。坑5全屏遮罩层中内容不居中症状弹窗内容没有在遮罩层中居中显示。修复Column(){// 遮罩层Column(){// 弹窗内容// ...}.width(85%);// 限制宽度}.width(100%).height(100%).justifyContent(FlexAlign.Center)// 垂直居中.alignItems(HorizontalAlign.Center);// 水平居中外层 Column 使用justifyContentalignItems实现居中内层 Column 是实际的弹窗内容。七、项目结构与代码统计7.1 文件结构Index.ets (~470行) ├── 类型定义 (~10行) │ └── interface ActingRecord │ ├── 成员变量 (~25行) │ ├── State变量view/isPanic/fishTime/score等15个 │ └── private变量timerId/pref/nextId/bossPhrases/gradeComments │ ├── 游戏逻辑 (~100行) │ ├── loadHistory / saveHistory │ ├── startFishTimer / randomBossAlert │ ├── panic / endPanic │ ├── drill / getFishTimeStr / getWorkRemainStr │ └── getGradeColor / getFishLevel │ ├── build() 导航 (~50行) │ ├── build() 多层Stack │ └── buildBottomNav │ ├── Builder视图 (~280行) │ ├── buildFishView (摸鱼主界面) │ ├── buildBossAlert (警报弹窗) │ ├── buildFakeWorkView (Excel假界面) │ ├── buildResultOverlay (评分结果) │ └── buildHistoryView (演技历史)7.2 代码量分布模块行数占比类型定义~102%变量声明~255%游戏逻辑~10021%UI辅助~5011%UI视图~28060%八、总结与展望8.1 项目复盘维度数据开发周期约半天代码量470行单文件UI层级5层Stack计时器1个setInterval 3个setTimeout评级档位6级S/A/B/C/D/F评语库7条老板警报语8条8.2 从6个APP中学到的ArkUI开发模式经过6个APP的开发实践可以总结出一些通用的ArkUI开发模式模式1单文件单组件6个APP全部使用单文件单组件结构。对于功能复杂度在一个屏幕能展示完级别的APP单文件开发效率最高。模式2Builder分层每个视图对应一个Builder方法通过build()中的条件渲染切换。这种模式将大型UI拆分为小型可管理的片段。模式3State 手动刷新ArkUI的 State 检测引用变化对象属性修改需要手动创建新引用。refreshState()或reRender()这种统一刷新方法是必备工具。模式4底部三/二标签导航游戏/工具类APP使用2-3个底部标签的导航模式用户认知成本低。模式5Stack多层叠加对于需要弹窗、遮罩、覆盖层的APP使用Stack作为根容器。8.3 可扩展方向1. 真实剪贴板接入ohos.pasteboard实现真正的复制到剪贴板功能让一键分享名副其实。2. 音效系统接入ohos.multimedia.audio为老板来了警报添加音效为评分结果添加音效。3. 工作时间统计接入ohos.data.preferences记录每日摸鱼总时长生成周报/月报。4. 多场景切换不止Excel还可以假装写代码IDE界面、假装开会Zoom界面、假装写文档Word界面。5. 排行榜通过分布式数据库实现好友之间的演技评分PK。附录完整API清单kit.ArkDataAPI用途preferences.getPreferences(ctx, name)获取偏好数据库Preferences.get(key, default)读取演技历史Preferences.put(key, value)写入演技历史Preferences.flush()刷入磁盘ArkUI组件组件用途Stack多层UI根容器Column/Row布局容器Text文本显示Button按钮List/ListItem演技历史列表ForEach循环渲染全局JavaScript APIAPI用途setInterval()摸鱼计时器setTimeout()延迟执行clearInterval()清理计时器Date.now()反应时间测量Math.random()随机数/波动Math.floor()向下取整Math.max()/Math.min()值范围限制String.padStart()时间格式化