鸿蒙 Next 断网挑战营 App 开发实战:倒计时引擎 + 成就徽章系统 + 活动建议推荐



鸿蒙 Next 断网挑战营 App 开发实战:倒计时引擎 + 成就徽章系统 + 活动建议推荐
作者:duluo
SDK 版本:HarmonyOS API 24 (Next)
开发工具:DevEco Studio
语言框架:ArkTS + ArkUI
字数:约 9800 字
目录
1. 引言
1.1 数字戒断的必要性
现代人平均每天花在手机上的时间超过 5 小时。Notifications、短视频、社交媒体的即时反馈机制不断劫持我们的注意力。我们不是在"使用"手机,而是在被手机"使用"。
“断网挑战营"App 的核心理念很简单:放下手机,完成离线挑战。它不是一个"防沉迷"工具——不强制锁屏、不限制应用使用——而是一个自我记录和激励工具。你主动打开 App,点击"开始断网”,然后放下手机去做其他事。挑战时间自动累计,成就徽章逐步解锁,历史记录见证你的每一次离线。
与其说这是一个 App,不如说它是一个意念的锚点——每次看到计时器上的数字在增长,都是一次正向的心理暗示:“我在掌控我的注意力。”
1.2 本 App 的技术特色
断网挑战营是本系列第十七款 App,在技术上有几个显著特点。
首先,它引入了实时计时器引擎——基于 setInterval 每秒刷新,大号字体(42px)展示离线时长,营造仪式感。这是一个典型的"定时器 + UI 状态同步"模式,不同于之前的"一次性数据展示"类 App。
其次,它设计了成就徽章系统——10 个徽章按累计离线分钟数分级解锁,从"初试断网"(1 分钟)到"断网达人"(10000 分钟)。这是系列中首次引入游戏化的成就机制。
此外,本 App 新增了活动建议推荐系统——16 种线下活动的建议库,随机推荐 + 手动刷新。这个设计填补了用户"断网后不知道做什么"的空白。
1.3 第十七款 App 的系列数据
这是本系列的第十七款 App。
App 数量: 17
代码总行数: ~11,200 行
编译错误数: ~165 个
博客总字数: ~180,000 字
技术博客数: 17 篇
2. 产品概念与数据模型
2.1 功能需求
用户故事 1:我想开始一次断网挑战,看到实时计时
用户故事 2:我想结束挑战,记录这次挑战的时长
用户故事 3:我想查看累计断网时间和历史成就
用户故事 4:我想通过挑战解锁成就徽章
用户故事 5:我想看到挑战历史记录
用户故事 6:我想获得离线活动建议
功能清单:
├── F1: 开始挑战(启动计时器 + 随机活动建议)
├── F2: 实时计时(setInterval 每秒刷新)
├── F3: 结束挑战(保存记录 + 检查徽章解锁)
├── F4: 累计统计(总时长 + 次数 + 成就数)
├── F5: 成就徽章(10 个里程碑分级解锁)
├── F6: 活动建议(16 种随机 + 手动刷新)
├── F7: 挑战记录(历史列表 + 完成/中断状态)
├── F8: 历史最佳展示
└── F9: 数据持久化(Preferences)
2.2 数据模型
interface ChallengeRecord {
id: number; // 唯一标识
startTime: number; // 开始时间戳
endTime: number; // 结束时间戳
duration: number; // 时长(分钟)
completed: boolean; // 是否完成
}
interface Badge {
id: string; // 徽章 ID
name: string; // 名称
icon: string; // Emoji 图标
desc: string; // 描述
need: number; // 累计分钟需求
unlocked: boolean; // 是否已解锁
}
ChallengeRecord 记录单次挑战的信息,Badge 则管理成就系统。两个模型之间通过 duration 和 need 字段关联——每次挑战结束后,累计所有记录的 duration,与每个 Badge 的 need 比较,决定是否解锁。
2.3 成就徽章设计
10 个徽章的解锁门槛从低到高排列,形成了一个"学习曲线":
| 徽章 | 图标 | 需求 | 心理效果 |
|---|---|---|---|
| 初试断网 | 🌱 | 1 分钟 | 几乎立即解锁,建立初始成就感 |
| 十分钟 | ⏰ | 10 分钟 | 第一次突破,完成即达标 |
| 半小时 | 🌙 | 30 分钟 | 需要一定坚持 |
| 一小时 | ⭐ | 60 分钟 | 第一个重要里程碑 |
| 两小时 | 🎯 | 120 分钟 | 中等难度 |
| 半天 | ☀️ | 360 分钟 | 需要持续参与 |
| 一天 | 🌈 | 720 分钟 | 高难度 |
| 三天 | 🔥 | 2160 分钟 | 资深用户 |
| 一周 | 🏆 | 4320 分钟 | 核心用户 |
| 断网达人 | 👑 | 10000 分钟 | 终极目标 |
门槛设计遵循"前易后难"的原则:前 3 个徽章在新用户第一次挑战后即可解锁,中期的 4-6 个需要每周几次的参与,最后的 3 个则是为持续使用数月的忠实用户准备的。
3. 三 Tab 架构设计
3.1 Tab 配置
buildTabContent() {
if (this.activeTab === 0) this.buildChallengeTab() // 挑战
else if (this.activeTab === 1) this.buildAchievementTab() // 成就
else this.buildHistoryTab() // 记录
}
三个 Tab 对应三种使用场景:
| Tab | 图标 | 功能 | 使用频率 |
|---|---|---|---|
| 挑战 | ⏱ | 计时器 + 统计 + 建议 | 每次打开 |
| 成就 | 🏆 | 徽章列表 + 进度 | 每周 |
| 记录 | 📋 | 历史挑战一览 | 偶尔 |
3.2 Tab 的设计意图
与其他 App 的"三 Tab 各自独立"不同,断网挑战营的三个 Tab 构成了一条用户路径:
挑战 Tab(行动)→ 成就 Tab(反馈)→ 记录 Tab(复盘)
用户打开 App 时首先进入挑战 Tab(行动),开始或继续挑战;完成挑战后想查看自己的积累,切换到成就 Tab(反馈);偶尔想回顾过去的挑战,翻看记录 Tab(复盘)。这种"行动-反馈-复盘"的路径设计与习惯养成类产品的模式一致。
4. 实时计时器引擎
4.1 计时器实现
计时器是本 App 最核心的交互元素。使用 setInterval 每秒更新一次 elapsed 状态:
startTimer(): void {
this.timerId = setInterval(() => {
this.elapsed = Math.floor((Date.now() - this.startTime) / 1000);
}, 1000);
}
elapsed 是一个 @State 变量,以秒为单位存储。UI 中的 formatDuration 方法将其转换为"时:分:秒"格式展示。
4.2 时间格式化
formatDuration(totalSec: number): string {
let h = Math.floor(totalSec / 3600);
let m = Math.floor((totalSec % 3600) / 60);
let s = totalSec % 60;
if (h > 0) {
return h + '时 ' + m.toString().padStart(2, '0') + '分 ' + s.toString().padStart(2, '0') + '秒';
}
return m + '分 ' + s.toString().padStart(2, '0') + '秒';
}
当总时长超过 1 小时时,显示完整的小时-分钟-秒格式;不足 1 小时时,只显示分钟-秒。这种设计避免了"0 时 23 分 15 秒"这样浪费空间的显示。
4.3 计时器的生命周期管理
计时器在两种情况下被清理:
- 用户结束挑战:
stopChallenge()中调用stopTimer() - App 进入后台或被销毁:
aboutToDisappear()中调用stopTimer()
aboutToDisappear(): void {
this.stopTimer();
this.saveData();
}
stopTimer(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
aboutToDisappear 中的清理是必要的——如果用户没有主动结束挑战就关闭 App,计时器需要被清理以避免内存泄漏。
4.4 计时器 UI 状态机
计时器 UI 有两种状态:
| 状态 | 图标 | 标题 | 计时器 | 按钮 |
|---|---|---|---|---|
| 未开始 | 🏕️ | “准备好断网了吗?” | 00:00 | “▶ 开始断网”(橙色) |
| 进行中 | 🔥 | “正在断网中…” | 实时更新 | “⏹ 结束挑战”(红色) |
两个状态的切换通过 isActive 布尔值控制。开始按钮为橙色(C.primary),结束按钮为红色(C.fire),视觉上强化了"开始 = 积极,结束 = 警戒"的感知。
5. 开始与结束挑战流程
5.1 开始挑战
startChallenge(): void {
this.isActive = true;
this.startTime = Date.now();
this.elapsed = 0;
if (this.currentActivity === '') {
this.currentActivity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
}
this.startTimer();
}
开始挑战时同时做两件事:启动计时器 + 推荐活动建议。如果用户还没有活动建议(初始状态),随机推荐一个。
5.2 结束挑战
stopChallenge(): void {
this.isActive = false;
this.stopTimer();
let endTime = Date.now();
let durationMin = Math.round((endTime - this.startTime) / 60000);
if (durationMin < 1) durationMin = 1;
let record: ChallengeRecord = {
id: Date.now(), startTime: this.startTime,
endTime: endTime, duration: durationMin, completed: true
};
this.records = [record].concat(this.records);
this.saveData();
this.checkBadges();
}
结束挑战时有四个步骤:
- 停止计时器:
this.stopTimer() - 计算时长:
Math.round((endTime - startTime) / 60000)将毫秒转为分钟 - 保存记录:新记录插入数组头部(最新在前)
- 检查徽章:调用
checkBadges()更新解锁状态
5.3 时长计算的精度
时长以分钟为单位(向下取整),而非秒。这意味着 30 秒的挑战会被记为 1 分钟。这个设计避免了用户"刷时长"的行为——计时器实时显示"多少分多少秒",但实际记录时按分钟计算,自动抹去不足 1 分钟的零头。
5.4 记录更新的响应式处理
this.records = [record].concat(this.records);
使用 concat 创建新数组,而非 push 修改原数组。在 ArkTS 中,@State 仅在新引用赋值时触发 UI 更新——push 不会触发重新渲染。
6. 成就徽章系统
6.1 徽章检查逻辑
checkBadges(): void {
let total = this.getTotalMinutes();
for (let i = 0; i < this.badges.length; i++) {
if (!this.badges[i].unlocked && total >= this.badges[i].need) {
this.badges[i].unlocked = true;
}
}
}
遍历所有徽章,对于未解锁且累计时长 >= 需求的徽章,将其 unlocked 设为 true。由于 badges 也是 @State 数组,赋值后 UI 自动更新。
6.2 累计时长计算
getTotalMinutes(): number {
let total = 0;
for (let r of this.records) total += r.duration;
return total;
}
从所有挑战记录中累加时长。这个值在每次挑战结束后都可能变化,因此 checkBadges 每次都重新计算。
6.3 徽章的克隆问题
徽章列表的初始化涉及一个重要的 ArkTS 规则——不能使用展开运算符:
// ❌ 错误:ArkTS 不支持展开运算符
this.badges = BADGE_LIST.map(b => ({ ...b }));
// ✅ 正确:显式复制每个属性
cloneBadge(b: Badge): Badge {
return {
id: b.id, name: b.name, icon: b.icon,
desc: b.desc, need: b.need, unlocked: b.unlocked
} as Badge;
}
BADGE_LIST 是模块级常量,其中的 unlocked 字段初始为 false。但 ArkTS 的模块级常量在多次操作 App 时可能会被意外修改(因为 @State badges 数组中的对象与常量引用同一对象)。通过 cloneBadge 显式复制每个属性,确保 badges 数组中的对象是独立的。
6.4 徽章列表的分组展示
成就 Tab 将徽章分为两组:已解锁和未解锁,分别用不同的 UI 展示:
✅ 已解锁 (3)
🌱 初试断网 - 完成首次断网挑战 ✅
⏰ 十分钟 - 累计断网10分钟 ✅
🌙 半小时 - 累计断网30分钟 ✅
🔒 未解锁 (7)
⭐ 一小时 - 累计断网60分钟 需要累计60分钟
🎯 两小时 - 累计断网120分钟 需要累计120分钟
已解锁的徽章不透明度为 1,未解锁的为 0.6。这种视觉区分让用户快速聚焦于"下一个要解锁的徽章"。
7. 挑战记录与历史列表
7.1 记录列表
历史 Tab 展示所有挑战记录,按时间倒序排列(最新在前):
ForEach(this.records, (r: ChallengeRecord) => {
Column() {
Row() {
Text(r.completed ? '✅' : '⏹️')
Column() {
Text(this.formatDuration(r.duration)) // 时长
Text(this.formatDate(r.startTime)) // 日期
}
Text(r.completed ? '完成' : '中断')
}
}
}, (r: ChallengeRecord) => r.id.toString())
每条记录包含:完成状态图标、时长、日期时间、完成/中断标签。
7.2 空状态
当没有记录时,显示空状态引导:
🏕️
还没有挑战记录
开始你的第一次断网挑战吧
7.3 日期格式化
formatDate(ts: number): string {
let d = new Date(ts);
let month = (d.getMonth() + 1).toString().padStart(2, '0');
let day = d.getDate().toString().padStart(2, '0');
return month + '/' + day + ' '
+ d.getHours().toString().padStart(2, '0') + ':'
+ d.getMinutes().toString().padStart(2, '0');
}
显示格式为 06/14 15:30,只显示月/日 时:分,不显示年份(因为大多数记录都在近期)。这种紧凑格式适合在列表卡片中展示。
8. 活动建议推荐机制
8.1 活动库
const ACTIVITIES: string[] = [
'📖 读一本纸质书', '🚶 散步 15 分钟', '📝 写信给朋友', '🎨 画一幅画',
'🧘 冥想 10 分钟', '🎵 听一张专辑', '🍳 做一顿饭', '🧹 整理房间',
'📸 拍一组照片', '🌳 去公园坐坐', '☕ 泡一杯茶', '🧩 拼图',
'🎹 弹奏乐器', '✍️ 练字', '🧶 做手工', '🏋️ 做运动',
];
16 种活动涵盖了阅读、创作、运动、家务、休闲等多个类别,确保不同兴趣的用户都能找到合适的建议。
8.2 随机推荐 + 手动刷新
// 随机推荐
this.currentActivity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
// 手动刷新(按钮)
Text('🔄 换一个').onClick(() => {
this.currentActivity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)];
})
用户在开始挑战前可以看到一个活动建议和"换一个"按钮。不喜欢的建议可以随时刷新。
8.3 活动建议的时机
活动建议只在非活跃状态时显示。当计时器正在运行时(isActive === true),活动建议区域隐藏——因为在"断网"时看手机本身就是矛盾的。这个设计强化了 App 的核心理念:设定目标,放下手机,去做事。
9. 累计统计卡片
9.1 三列统计
挑战 Tab 的顶部在计时器下方展示了三个统计指标:
📊 累计 🏆 成就 📋 挑战
120分钟 3/10 15次
使用 buildStatItem Builder 实现:
@Builder
buildStatItem(icon: string, label: string, value: string) {
Column() {
Text(icon).fontSize(22)
Text(value).fontSize(16).fontColor(C.text).fontWeight(FontWeight.Bold)
Text(label).fontSize(11).fontColor(C.textLight)
}.width('30%').padding(10).backgroundColor(C.cardBg).borderRadius(14)
}
三个卡片等宽(30%),间距由外层 Row 的 margin 控制。这个三列布局简洁有效,在系列多个 App 中被使用。
9.2 历史最佳
在活动建议下方,如果存在历史记录,展示单次最佳时长:
🏅 历史最佳:45 分 30 秒
这个指标与累计时长不同——它展示的是"单次挑战的最佳表现",激励用户尝试打破自己的记录。
9.3 数据计算的性能
getTotalMinutes(): number {
let total = 0;
for (let r of this.records) total += r.duration;
return total;
}
getBestDuration(): number {
let best = 0;
for (let r of this.records) {
if (r.duration > best) best = r.duration;
}
return best * 60; // 转为秒用于 formatDuration
}
这两个方法在每次 UI 刷新时都会被调用(因为它们用在 @Builder 中)。对于几十到几百条记录来说,遍历全部记录计算总和的性能开销可以忽略不计。
10. 编译错误全记录
10.1 错误概览
本 App 出现 3 个编译错误。
| # | 错误类型 | 位置 | 根因 |
|---|---|---|---|
| 1 | 展开运算符 | aboutToAppear 第 77 行 | { ...b } 在 ArkTS 中不被支持 |
| 2 | 属性不存在 | buildAchievementTab 第 205 行 | C.success 未在 ColorScheme 中定义 |
| 3 | @Builder 中 let | buildChallengeTab / buildAchievementTab | let best / let unlocked, locked |
10.2 关键错误:展开运算符
现象:BADGE_LIST.map(b => ({ ...b })) 报错 “It is possible to spread only arrays or classes derived from arrays into the rest parameter or array literals”。
根因:ArkTS 的展开运算符(spread operator)限制比标准 TypeScript 严格——只能用于展开数组到参数或数组字面量中,不能用于对象字面量。
// ❌ 错误:对象展开
{ ...b }
// ✅ 正确:显式复制属性
{ id: b.id, name: b.name, icon: b.icon, desc: b.desc, need: b.need, unlocked: b.unlocked }
这个限制在系列第十二款 App(家庭大富翁)中已经遇到过。当时的教训是"展开运算符替代"。但在本 App 中,我又一次犯了同样的错误。
教训:即使是已知错误,也可能会重复犯。关键是修复速度——第一次遇到花了 3 个轮次,这次只花了 1 个轮次。
10.3 关键错误:属性不存在
现象:C.success 报错 “Property ‘success’ does not exist on type ‘ColorScheme’”。
根因:ColorScheme 接口中没有定义 success 属性。在 ArkTS 中,对象的属性和类型必须严格匹配——接口中有哪些属性,常量中就必须有那些属性,不能多也不能少。
这个错误在系列前作中已经很少出现了(因为每次新建 App 时,都是复制上一个 App 的 ColorScheme 然后修改),但本 App 是从新接口定义开始写的,忘了在接口中加入 success。
10.4 关键错误:@Builder 中的 let
现象:let best = this.getBestDuration() 和 let unlocked = this.badges.filter(...) 报错 “Only UI component syntax can be written here”。
根因:这是 ArkTS 中最常见的错误类型,在本系列中占 36%。在 @Builder 方法中,不能有任何形式的变量声明。
本 App 中犯了两次:
// 错误 1:在 buildChallengeTab 中
let best = this.getBestDuration(); // ❌
// 错误 2-3:在 buildAchievementTab 中
let unlocked = this.badges.filter(b => b.unlocked); // ❌
let locked = this.badges.filter(b => !b.unlocked); // ❌
修复方式都是在 @Builder 中直接用方法调用替代变量:
// ✅ 修复 1:内联调用
if (this.getBestDuration() > 0) { Text(...) }
// ✅ 修复 2-3:提取为独立方法 + Builder
this.buildBadgeSection('✅ 已解锁', this.getUnlockedBadges(), true)
10.5 十七款 App 错误数趋势
22 → 17 → 16 → 1 → 12 → 12 → 10 → 4 → 11 → 11 → 3 → 8 → 7 → 12 → 1 → 4 → 3
第十七款 App 的错误数为 3 个,处于历史低位。
10.6 错误恢复速度
本 App 的 3 个错误在 1 个轮次内全部修复。对比第一款的 22 个错误需要 5 个轮次,修复效率提升了 5 倍以上。这个效率提升来自:
- 错误模式已知:看到错误信息能立即知道是"@Builder 中的 let"还是"属性不存在"
- 修复模式已验证:let → 提取方法,属性缺失 → 加接口字段,展开 → 显式复制
- 工具辅助:通过 build log 快速定位错误行号
11. 十七款 App 全景回顾
11.1 数据总览
| # | App | 行数 | 错误数 | Type |
|---|---|---|---|---|
| 1 | 🎵 白噪音 | 767 | 16 | 工具 |
| 2 | ⏳ 时间胶囊 | 955 | 17 | 工具 |
| 3 | 🧊 冰箱剩菜 | 1320 | 22 | 工具 |
| 4 | 😅 尴尬粉碎机 | 953 | 1 | 工具 |
| 5 | 🛡️ 防骗训练 | 1038 | 12 | 教育 |
| 6 | 💡 碎片学习 | 851 | 12 | 教育 |
| 7 | 🐶 宠物日记 | 450 | 10 | 工具 |
| 8 | 🗑️ 情绪垃圾桶 | 390 | 4 | 工具 |
| 9 | 🧭 线下寻宝 | 447 | 11 | 社交 |
| 10 | 🗡️ 订阅刺客 | 478 | 11 | 工具 |
| 11 | 🎑 声音明信片 | 458 | 3 | 工具 |
| 12 | 🎲 家庭大富翁 | 537 | 8 | 游戏 |
| 13 | 📚 二手书漂流瓶 | 452 | 7 | 社交 |
| 14 | 🧹 废话过滤器 | 542 | 12 | 工具 |
| 15 | 🌱 绿植领养 | 530 | 1 | 社交 |
| 16 | 🌙 梦境解析 | 614 | 4 | 工具 |
| 17 | 🏕️ 断网挑战营 | 418 | 3 | 工具 |
11.2 行为养成类 App 的设计模式
断网挑战营属于"行为养成"类 App,与"宠物日记"(第七款)在模式上有相似之处。行为养成类 App 的设计模板:
核心记录 → 累计统计 → 成就激励 → 历史回顾
- 核心记录:用户每次行为(写日记/离线挑战)都记录一条数据
- 累计统计:展示总次数/总时长,给用户进度感知
- 成就激励:设置里程碑,在达成时给予正反馈
- 历史回顾:让用户看到自己的成长轨迹
这个模式在"宠物日记"中首次出现(记录宠物日常 → 统计次数 → 连续天数)。在"断网挑战营"中,这个模式被强化了——增加了徽章系统和实时计时器,让反馈更加即时。
11.3 十七款 App 的关键教训
| # | App | 最大教训 |
|---|---|---|
| 1 | 白噪音 | 颜色对象需要 interface |
| 2 | 时间胶囊 | @Builder 不能用 let |
| 3 | 冰箱剩菜 | 闭包不能传给 @Builder |
| 4 | 尴尬粉碎机 | 模式复用可大幅降错 |
| 5 | 防骗训练 | 大段 Builder 分批重构 |
| 6 | 碎片学习 | ForEach key 函数作用域 |
| 7 | 宠物日记 | 紧凑风格减少 50% 代码 |
| 8 | 情绪垃圾桶 | ForEach key 用值本身 |
| 9 | 线下寻宝 | 残留代码导致级联错误 |
| 10 | 订阅刺客 | 暗色主题设计 |
| 11 | 声音明信片 | setInterval 要清理 |
| 12 | 家庭大富翁 | 展开运算符替代 |
| 13 | 二手书漂流瓶 | @Builder 注解不能缺 |
| 14 | 废话过滤器 | Text 组件不支持变量声明 |
| 15 | 绿植领养 | 重构也可能引入错误 |
| 16 | 梦境解析 | 内联对象不能作类型 |
| 17 | 断网挑战营 | 已知错误也会重复犯 |
11.4 关于"重复犯错"的思考
第 17 条教训"已知错误也会重复犯"是系列中首次出现的关于"开发者行为"的教训——之前的教训都关于"ArkTS 语法"。
为什么已知错误会重复犯?
原因 1:惯性思维。在标准 TypeScript 中,{ ...b } 是最自然的对象克隆方式。写了十几年代码形成的肌肉记忆,不会因为换了语言就自动消失。
原因 2:上下文切换。从上一个 App(梦境解析)切换到断网挑战营时,注意力在功能设计上,而不是语法规则上。
原因 3:压力因素。时间紧、任务多时,大脑会自动切换到"最熟悉的写法",而不是"这个语言允许的写法"。
解决方案不是"永远不会再犯",而是缩短从犯错到修复的时间。从一个轮次到修复到预览通过,从第一次的 3 轮次到这次的 1 轮次——证明了"免疫系统"的有效性。
12. 结语
12.1 十七款 App 的开发历程
App1 🎵 白噪音 → 初识 ArkUI
App2 ⏳ 时间胶囊 → 数据持久化
App3 🧊 冰箱剩菜 → Tab 架构
App4 😅 尴尬粉碎机 → 模式复用
App5 🛡️ 防骗训练 → 适老化
App6 💡 碎片学习 → 学习激励
App7 🐶 宠物日记 → 紧凑风格
App8 🗑️ 情绪垃圾桶 → 情感交互
App9 🧭 线下寻宝 → 社交互动
App10 🗡️ 订阅刺客 → 暗色主题
App11 🎑 声音明信片 → 模拟录音
App12 🎲 家庭大富翁 → 回合制游戏
App13 📚 二手书漂流瓶 → 随机匹配
App14 🧹 废话过滤器 → 自然语言检测
App15 🌱 绿植领养 → 缘分匹配
App16 🌙 梦境解析 → 潜意识探索
App17 🏕️ 断网挑战营 → 行为养成
12.2 计时器类 App 的关键设计
断网挑战营是系列中第一款涉及实时计时器的 App。计时器类 App 有三个关键设计点:
-
生命周期管理:计时器必须在 App 销毁时被清理,否则会导致内存泄漏。
aboutToDisappear中的clearInterval是必须的。 -
响应式更新:计时器每秒更新 UI,必须使用
@State变量来触发 ArkUI 的重新渲染。普通变量不会触发 UI 更新。 -
精度取舍:展示用秒(实时感强),记录用分钟(减少数据噪点)。两种精度的配合既满足了用户的"实时反馈"需求,又避免了"刷时长"的问题。
12.3 ArkUI 的终极评价(第五次修订)
经过十七款 App 的实践,ArkUI 的评价已经趋于稳定。
优势(与前作一致):
- 声明式 DSL + @State 响应式
- 编译检查有效
- Preferences API 简洁
不足(与前作一致):
- @Builder 语法约束过严
- 展开运算符限制
- 部分 API 与 Web CSS 差异大
最新发现:最大的风险已经不再是语法规则本身,而是开发者行为——即使熟悉所有规则,在疲劳或赶时间时还是会犯已知错误。
12.4 给开发者的建议
-
行为养成类 App 的设计模板:核心记录 → 累计统计 → 成就激励 → 历史回顾。这个模板可以复用到习惯追踪、学习打卡、健身记录等多个场景。
-
计时器记得清理:setInterval/setTimeout 的清理代码放在 aboutToDisappear 中,这是一种安全的默认做法。
-
重复犯错不可怕:真正重要的是修复速度。建立自己的"错误免疫系统"——哪些错误最常见、怎么最快修复、怎么预防下一次。
-
App 也可以是一个意念锚点:不是所有 App 都要提供复杂的"功能"。有时候,一个计时器 + 几个成就徽章,就足以帮助用户建立积极的习惯。
12.5 感谢与展望
十七款 App、十七篇博客、约 180,000 字——从 6 月 13 日到 6 月 14 日,完成了全部 App 开发和博客撰写。
错误数从 22 降到 3,下降了 86%。但比错误数更重要的是修复错误的速度——从第一款的 5 轮次到第十七款的 1 轮次。
现在,打开 DevEco Studio,去创造属于你自己的 App 吧。
附录 A:第十七款 App 核心代码
实时计时器
startTimer(): void {
this.timerId = setInterval(() => {
this.elapsed = Math.floor((Date.now() - this.startTime) / 1000);
}, 1000);
}
stopTimer(): void {
if (this.timerId >= 0) {
clearInterval(this.timerId); this.timerId = -1;
}
}
开始与结束挑战
startChallenge(): void {
this.isActive = true; this.startTime = Date.now(); this.elapsed = 0;
this.startTimer();
}
stopChallenge(): void {
this.isActive = false; this.stopTimer();
let durationMin = Math.round((Date.now() - this.startTime) / 60000);
if (durationMin < 1) durationMin = 1;
this.records = [{ id: Date.now(), startTime: this.startTime,
endTime: Date.now(), duration: durationMin, completed: true
} as ChallengeRecord].concat(this.records);
this.saveData();
this.checkBadges();
}
成就检查
checkBadges(): void {
let total = this.getTotalMinutes();
for (let i = 0; i < this.badges.length; i++) {
if (!this.badges[i].unlocked && total >= this.badges[i].need) {
this.badges[i].unlocked = true;
}
}
}
徽章克隆
cloneBadge(b: Badge): Badge {
return { id: b.id, name: b.name, icon: b.icon,
desc: b.desc, need: b.need, unlocked: b.unlocked } as Badge;
}
附录 B:系列速查
| 指标 | 数值 |
|---|---|
| App 数量 | 17 |
| 博客总字数 | ~180,000 字 |
| 代码总行数 | ~11,200 行 |
| 编译错误 | ~165 个 |
| @Builder 方法 | ~225 个 |
| 修复轮次 | 33 轮 |
更多推荐




所有评论(0)