HarmonyOS 防沉迷控制实战:基于状态管理V2开发打地鼠小游戏
摘要 :本文以"打地鼠大作战"小游戏为载体,深入讲解 HarmonyOS 防沉迷控制的完整实现方案。采用 ArkTS 状态管理 V2(
@ComponentV2、@Local、@Param、@Event)进行开发,涵盖使用时长控制、强制休息倒计时、数据持久化等核心功能。文中总结了大量实战踩坑经验,适合 HarmonyOS 中高级开发者参考学习。
效果

一、前言
随着移动应用对用户体验的重视,防沉迷控制已成为应用开发中不可或缺的功能模块。HarmonyOS 提供了完善的防沉迷控制架构指南,但实际开发中如何将官方方案与现代状态管理 V2 结合,仍然是一个值得探讨的话题。
本文将通过一个完整的实战案例------打地鼠小游戏 + 防沉迷控制,带你从零掌握以下核心技能:
- 使用
@ComponentV2全家桶构建现代化组件 - 实现游戏时长控制与强制休息机制
- 使用
TextTimer组件实现精确倒计时 - 使用
Preferences实现设置数据持久化 - 使用
Navigation+NavPathStack实现页面路由
二、效果展示与功能概述
2.1 功能清单
| 功能模块 | 说明 |
|---|---|
| 打地鼠游戏 | 3×3 格子,随机出现🐹地鼠,点击消灭得分,支持连击奖励 |
| 使用时间控制 | 可设置 1~60 分钟不等的游戏时长 |
| 强制休息机制 | 使用时间到达后自动锁定,显示休息倒计时 |
| 数据持久化 | 使用 Preferences 保存设置和最高分 |
| 进度环动画 | 休息期间显示圆形进度环动画 |
| 解锁后重设 | 休息结束后可重新设置时间或直接继续游戏 |
2.2 整体架构
#mermaid-svg-IztlMROn7UWlcuYz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IztlMROn7UWlcuYz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IztlMROn7UWlcuYz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IztlMROn7UWlcuYz .error-icon{fill:#552222;}#mermaid-svg-IztlMROn7UWlcuYz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IztlMROn7UWlcuYz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IztlMROn7UWlcuYz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IztlMROn7UWlcuYz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IztlMROn7UWlcuYz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IztlMROn7UWlcuYz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IztlMROn7UWlcuYz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IztlMROn7UWlcuYz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IztlMROn7UWlcuYz .marker.cross{stroke:#333333;}#mermaid-svg-IztlMROn7UWlcuYz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IztlMROn7UWlcuYz p{margin:0;}#mermaid-svg-IztlMROn7UWlcuYz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IztlMROn7UWlcuYz .cluster-label text{fill:#333;}#mermaid-svg-IztlMROn7UWlcuYz .cluster-label span{color:#333;}#mermaid-svg-IztlMROn7UWlcuYz .cluster-label span p{background-color:transparent;}#mermaid-svg-IztlMROn7UWlcuYz .label text,#mermaid-svg-IztlMROn7UWlcuYz span{fill:#333;color:#333;}#mermaid-svg-IztlMROn7UWlcuYz .node rect,#mermaid-svg-IztlMROn7UWlcuYz .node circle,#mermaid-svg-IztlMROn7UWlcuYz .node ellipse,#mermaid-svg-IztlMROn7UWlcuYz .node polygon,#mermaid-svg-IztlMROn7UWlcuYz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IztlMROn7UWlcuYz .rough-node .label text,#mermaid-svg-IztlMROn7UWlcuYz .node .label text,#mermaid-svg-IztlMROn7UWlcuYz .image-shape .label,#mermaid-svg-IztlMROn7UWlcuYz .icon-shape .label{text-anchor:middle;}#mermaid-svg-IztlMROn7UWlcuYz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IztlMROn7UWlcuYz .rough-node .label,#mermaid-svg-IztlMROn7UWlcuYz .node .label,#mermaid-svg-IztlMROn7UWlcuYz .image-shape .label,#mermaid-svg-IztlMROn7UWlcuYz .icon-shape .label{text-align:center;}#mermaid-svg-IztlMROn7UWlcuYz .node.clickable{cursor:pointer;}#mermaid-svg-IztlMROn7UWlcuYz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IztlMROn7UWlcuYz .arrowheadPath{fill:#333333;}#mermaid-svg-IztlMROn7UWlcuYz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IztlMROn7UWlcuYz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IztlMROn7UWlcuYz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IztlMROn7UWlcuYz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IztlMROn7UWlcuYz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IztlMROn7UWlcuYz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IztlMROn7UWlcuYz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IztlMROn7UWlcuYz .cluster text{fill:#333;}#mermaid-svg-IztlMROn7UWlcuYz .cluster span{color:#333;}#mermaid-svg-IztlMROn7UWlcuYz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IztlMROn7UWlcuYz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IztlMROn7UWlcuYz rect.text{fill:none;stroke-width:0;}#mermaid-svg-IztlMROn7UWlcuYz .icon-shape,#mermaid-svg-IztlMROn7UWlcuYz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IztlMROn7UWlcuYz .icon-shape p,#mermaid-svg-IztlMROn7UWlcuYz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IztlMROn7UWlcuYz .icon-shape .label rect,#mermaid-svg-IztlMROn7UWlcuYz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IztlMROn7UWlcuYz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IztlMROn7UWlcuYz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IztlMROn7UWlcuYz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} EntryAbility
GamePage 游戏主页面
GameBoard 游戏棋盘组件-自包含
GameLockOverlay 锁定遮罩组件
Navigation 路由管理
TimeSettingPage 时间设置页
PreferenceManager 数据持久化
三、项目结构与文件规划
entry/src/main/
├── ets/
│ ├── entryability/
│ │ └── EntryAbility.ets # 入口 Ability,全屏+安全区域配置
│ ├── pages/
│ │ ├── GamePage.ets # 游戏主页面(@Entry)
│ │ └── TimeSettingPage.ets # 时间设置页面(NavDestination)
│ ├── components/
│ │ ├── GameBoard.ets # 打地鼠游戏棋盘组件(自包含)
│ │ └── GameLockOverlay.ets # 防沉迷锁定遮罩组件
│ └── utils/
│ ├── TimeUtils.ets # 时间工具类
│ └── PreferenceManager.ets # 偏好管理器
├── resources/
│ └── base/
│ ├── profile/
│ │ ├── main_pages.json # 页面注册
│ │ └── route_map.json # 路由映射配置
│ └── media/
│ └── ic_public_bottom_arrow.svg
└── module.json5 # 模块配置(含 routerMap)
四、状态管理 V2 核心概念
在正式编码之前,我们需要了解状态管理 V2 相比 V1 的核心变化:
| V1 装饰器 | V2 装饰器 | 用途 |
|---|---|---|
@Component |
@ComponentV2 |
声明自定义组件 |
@State |
@Local |
组件内部状态 |
@Prop |
@Param |
父→子单向数据传递(只读) |
@Link |
@Event |
子→父事件回调 |
@Provide/@Consume |
@Param/@Event |
跨层级数据传递 |
@Observed/@ObjectLink |
@ObservedV2/@Trace |
嵌套对象观测 |
| 无 | @Monitor |
监听特定状态变化 |
| 无 | @Computed |
计算属性(缓存) |
关键点 :V2 中
@Param是只读 的,子组件不能直接修改;@Event用于子组件向父组件发送回调。@StorageProp/@StorageLink不支持@ComponentV2,需使用AppStorage.get()手动读取。
五、工具层实现
5.1 时间工具类 TimeUtils
typescript
// utils/TimeUtils.ets
export class TimeUtils {
/**
* 获取当前时间从午夜开始经过的秒数
* 用于计算使用时长和休息时长的时间差
*/
static getCurrentSeconds(): number {
const now: Date = new Date();
return now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
}
/** 将秒数格式化为 mm:ss */
static formatSeconds(totalSeconds: number): string {
const minutes: number = Math.floor(totalSeconds / 60);
const seconds: number = Math.floor(totalSeconds % 60);
const minStr: string = minutes < 10 ? '0' + minutes.toString() : minutes.toString();
const secStr: string = seconds < 10 ? '0' + seconds.toString() : seconds.toString();
return `${minStr}:${secStr}`;
}
}
5.2 偏好管理器 PreferenceManager
typescript
// utils/PreferenceManager.ets
import { preferences } from '@kit.ArkData';
export class PreferenceKeys {
static readonly USE_TIME: string = 'use_time';
static readonly REST_TIME: string = 'rest_time';
static readonly FUTURE_REST: string = 'future_rest';
static readonly HIGH_SCORE: string = 'high_score';
}
export class PreferenceManager {
private static readonly STORE_NAME: string = 'anti_addiction_store';
private static getStore(context: Context): preferences.Preferences {
return preferences.getPreferencesSync(context, {
name: PreferenceManager.STORE_NAME
});
}
static saveNumber(context: Context, key: string, value: number): void {
const store = PreferenceManager.getStore(context);
store.putSync(key, value);
store.flush();
}
static getNumber(context: Context, key: string, defaultValue: number): number {
const store = PreferenceManager.getStore(context);
const val: preferences.ValueType = store.getSync(key, defaultValue);
if (typeof val === 'number') {
return val;
}
return defaultValue;
}
}
六、游戏组件实现
6.1 打地鼠游戏棋盘 GameBoard(自包含设计)
这是本案例的核心娱乐组件。重要设计决策 :游戏的启动/停止完全由组件内部控制,不依赖 @Monitor 跨组件触发,确保可靠性。
6.1.1 状态设计:@Local 基本类型数组
实战经验 :
@ObservedV2+@Trace对嵌套对象属性的修改在ForEach中可能不触发重渲染。对于游戏格子这类高频更新的场景,使用@Local基本类型数组 + 整体替换数组更可靠。
typescript
@ComponentV2
export struct GameBoard {
// 通过 @Event 向父组件通知
@Event onScoreChange: (score: number) => void = (score: number) => {};
@Event onGameEnd: (finalScore: number) => void = (finalScore: number) => {};
@Event onRunningChange: (running: boolean) => void = (running: boolean) => {};
// 格子状态数组: 0=空闲, 1=地鼠出现, 2=被击中
// 使用基本类型数组,每次修改替换整个数组确保 @Local 响应式更新
@Local cellStates: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0];
@Local score: number = 0;
@Local timeLeft: number = 60;
@Local combo: number = 0;
@Local isRunning: boolean = false;
@Local moleTimerId: number = -1;
@Local countdownTimerId: number = -1;
@Local lastFinalScore: number = 0;
}
6.1.2 核心游戏逻辑
typescript
// 开始新游戏 - 由内部按钮直接调用,无需 @Monitor
private startGame(): void {
this.clearAllTimers();
this.score = 0;
this.timeLeft = 60;
this.combo = 0;
this.cellStates = [0, 0, 0, 0, 0, 0, 0, 0, 0]; // 整体替换触发更新
this.isRunning = true;
this.onRunningChange(true);
// 启动地鼠生成器
this.moleTimerId = setInterval(() => {
this.spawnMole();
}, 800);
// 启动倒计时
this.countdownTimerId = setInterval(() => {
this.timeLeft--;
if (this.timeLeft <= 0) this.endGame();
}, 1000);
}
// 随机生成地鼠 - 替换整个数组触发 @Local 更新
private spawnMole(): void {
const inactiveIndices: number[] = [];
for (let i = 0; i < this.cellStates.length; i++) {
if (this.cellStates[i] === 0) inactiveIndices.push(i);
}
if (inactiveIndices.length === 0) return;
const randomIdx = inactiveIndices[
Math.floor(Math.random() * inactiveIndices.length)
];
const newStates: number[] = this.cellStates.slice();
newStates[randomIdx] = 1;
this.cellStates = newStates; // 整体替换,触发响应式
// 地鼠 1.2 秒后自动消失
setTimeout(() => {
if (this.cellStates[randomIdx] === 1) {
const clearStates = this.cellStates.slice();
clearStates[randomIdx] = 0;
this.cellStates = clearStates;
this.combo = 0;
}
}, 1200);
}
// 击中地鼠
private hitMole(index: number): void {
if (!this.isRunning || this.cellStates[index] !== 1) return;
const hitStates = this.cellStates.slice();
hitStates[index] = 2; // 标记为击中
this.cellStates = hitStates;
this.combo++;
const bonus = Math.min(this.combo - 1, 5) * 5;
this.score += 10 + bonus;
this.onScoreChange(this.score);
setTimeout(() => {
const clearStates = this.cellStates.slice();
clearStates[index] = 0;
this.cellStates = clearStates;
}, 200);
}
6.1.3 UI 渲染(含地鼠 Emoji)
typescript
build() {
Column() {
// 顶部信息栏:得分 + 连击 + 倒计时
Row() {
Column() {
Text('得分').fontSize(12).fontColor('#99FFFFFF')
Text(this.score.toString()).fontSize(28).fontWeight(FontWeight.Bold)
}
Blank()
if (this.combo >= 2) {
Text(`${this.combo}连击!`).fontColor('#FFD700')
}
Blank()
Text(this.formatTime(this.timeLeft))
.fontColor(this.timeLeft <= 10 ? '#FF4444' : Color.White)
}
// 3×3 游戏格子
Grid() {
ForEach(this.cellStates, (state: number, index: number) => {
GridItem() {
Stack() {
Column() // 格子背景
.aspectRatio(1).borderRadius(16)
.backgroundColor(
state === 1 ? '#FF6B35' : state === 2 ? '#4CAF50' : '#2A2D3E'
)
// 地鼠 / 击中 / 地洞
if (state === 1) {
Column() {
Text('🐹').fontSize(40) // 地鼠出现!
Text('🟫').fontSize(16).margin({ top: -8 })
}
} else if (state === 2) {
Text('💥').fontSize(36) // 击中特效
} else {
Text('⚫').fontSize(20) // 空闲地洞
}
}
.alignContent(Alignment.Center) // Stack 用 alignContent
.onClick(() => this.hitMole(index))
.animation({ duration: 150, curve: Curve.EaseOut })
}
// key 包含状态值,状态变化时重新渲染
}, (state: number, index: number) => index.toString() + '_' + state.toString())
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr')
// 开始游戏按钮(内部触发,不依赖 @Monitor)
if (!this.isRunning) {
Button(this.lastFinalScore > 0 ? '再来一局' : '开始游戏')
.onClick(() => this.startGame())
}
}
}
Stack 注意 :
Stack组件不支持justifyContent,必须使用alignContent(Alignment.Center)来居中对齐子元素。
6.2 防沉迷锁定遮罩 GameLockOverlay
当使用时长到达后,全屏锁定遮罩覆盖游戏界面,核心功能是倒计时 和进度环动画。
typescript
@ComponentV2
export struct GameLockOverlay {
@Param restSeconds: number = 0;
@Event onUnlock: () => void = () => {};
@Event onGoToSettings: () => void = () => {};
@Local isUnlocked: boolean = false;
@Local progressAngle: number = 0;
build() {
Stack() {
Column().backgroundColor('#E6000000').backdropBlur(20)
Column() {
// 圆形进度环
Stack() {
Circle().stroke('#33FFFFFF').strokeWidth(6)
Circle()
.stroke('#4FC3F7').strokeWidth(6)
.strokeDashArray([this.progressAngle, 440 - this.progressAngle])
.rotate({ angle: -90 })
Text(this.isUnlocked ? '🔓' : '🔒').fontSize(48)
}
// TextTimer 倒计时
TextTimer({
isCountDown: true,
count: this.restSeconds * 1000,
controller: lockTimerController
})
.format('mm:ss').fontSize(48).fontColor('#4FC3F7')
.onTimer((utc: number, elapsedTime: number) => {
const remaining = this.restSeconds - elapsedTime;
this.progressAngle = 440 * (remaining / this.restSeconds);
if (elapsedTime >= this.restSeconds) {
this.isUnlocked = true;
lockTimerController.pause();
this.onUnlock();
}
})
.onAppear(() => lockTimerController.start())
if (this.isUnlocked) {
Button('继续游戏').onClick(() => this.onGoToSettings())
Button('重新设置').onClick(() => this.onGoToSettings())
}
}
}
}
}
七、时间设置页面 TimeSettingPage
设置页面使用 NavDestination 作为容器。由于 @StorageProp 不支持 @ComponentV2,通过 AppStorage.get() 获取 NavPathStack。
7.1 路由与 NavPathStack 获取
typescript
@Builder
export function timeSettingPageBuilder() {
TimeSettingPage();
}
@ComponentV2
export struct TimeSettingPage {
@Local navPathStack: NavPathStack = new NavPathStack();
// ...
build() {
NavDestination() {
// 页面内容
}
.hideTitleBar(true)
.onWillAppear(() => {
// 从 AppStorage 获取父页面的 NavPathStack
const stack: NavPathStack | undefined = AppStorage.get<NavPathStack>('gameNavPathStack');
if (stack !== undefined) {
this.navPathStack = stack;
}
})
.onBackPressed(() => {
this.saveAndGoBack();
return true;
})
}
}
踩坑提醒 :
onWillAppear的回调签名是Callback<void, void>,不接受NavDestinationContext参数。必须通过AppStorage间接传递NavPathStack。
7.2 卡片式设置界面
typescript
// 使用时长设置卡片
Column() {
Row() {
Column() { Text('🎮').fontSize(32) }
.width(56).height(56)
.backgroundColor('#E8F5E9').borderRadius(16)
Column() {
Text('使用时长').fontSize(16).fontWeight(FontWeight.Medium)
Text('每次游戏的持续时间').fontSize(12).fontColor('#999999')
}
}
if (this.showUsePicker) {
TextPicker({ range: USE_TIME_OPTIONS, selected: 1 })
.canLoop(false)
.onChange((value: string | string[]) => {
this.useTimeSeconds = MINUTES_TO_SECONDS[value as string];
})
}
}
.backgroundColor(Color.White).borderRadius(20)
lineSpacing 注意 :
lineSpacing()方法要求LengthMetrics类型参数,需导入import { LengthMetrics } from '@kit.ArkUI'并使用LengthMetrics.px(4)。
八、游戏主页面 GamePage(防沉迷控制核心)
GamePage 组合了游戏组件和防沉迷控制逻辑。游戏启动由 GameBoard 内部控制,GamePage 只负责防沉迷锁定。
8.1 防沉迷状态机
#mermaid-svg-JbNgSk4BaQQF01mt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JbNgSk4BaQQF01mt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JbNgSk4BaQQF01mt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JbNgSk4BaQQF01mt .error-icon{fill:#552222;}#mermaid-svg-JbNgSk4BaQQF01mt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JbNgSk4BaQQF01mt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JbNgSk4BaQQF01mt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JbNgSk4BaQQF01mt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JbNgSk4BaQQF01mt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JbNgSk4BaQQF01mt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JbNgSk4BaQQF01mt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JbNgSk4BaQQF01mt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JbNgSk4BaQQF01mt .marker.cross{stroke:#333333;}#mermaid-svg-JbNgSk4BaQQF01mt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JbNgSk4BaQQF01mt p{margin:0;}#mermaid-svg-JbNgSk4BaQQF01mt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JbNgSk4BaQQF01mt .cluster-label text{fill:#333;}#mermaid-svg-JbNgSk4BaQQF01mt .cluster-label span{color:#333;}#mermaid-svg-JbNgSk4BaQQF01mt .cluster-label span p{background-color:transparent;}#mermaid-svg-JbNgSk4BaQQF01mt .label text,#mermaid-svg-JbNgSk4BaQQF01mt span{fill:#333;color:#333;}#mermaid-svg-JbNgSk4BaQQF01mt .node rect,#mermaid-svg-JbNgSk4BaQQF01mt .node circle,#mermaid-svg-JbNgSk4BaQQF01mt .node ellipse,#mermaid-svg-JbNgSk4BaQQF01mt .node polygon,#mermaid-svg-JbNgSk4BaQQF01mt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JbNgSk4BaQQF01mt .rough-node .label text,#mermaid-svg-JbNgSk4BaQQF01mt .node .label text,#mermaid-svg-JbNgSk4BaQQF01mt .image-shape .label,#mermaid-svg-JbNgSk4BaQQF01mt .icon-shape .label{text-anchor:middle;}#mermaid-svg-JbNgSk4BaQQF01mt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JbNgSk4BaQQF01mt .rough-node .label,#mermaid-svg-JbNgSk4BaQQF01mt .node .label,#mermaid-svg-JbNgSk4BaQQF01mt .image-shape .label,#mermaid-svg-JbNgSk4BaQQF01mt .icon-shape .label{text-align:center;}#mermaid-svg-JbNgSk4BaQQF01mt .node.clickable{cursor:pointer;}#mermaid-svg-JbNgSk4BaQQF01mt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JbNgSk4BaQQF01mt .arrowheadPath{fill:#333333;}#mermaid-svg-JbNgSk4BaQQF01mt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JbNgSk4BaQQF01mt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JbNgSk4BaQQF01mt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JbNgSk4BaQQF01mt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JbNgSk4BaQQF01mt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JbNgSk4BaQQF01mt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JbNgSk4BaQQF01mt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JbNgSk4BaQQF01mt .cluster text{fill:#333;}#mermaid-svg-JbNgSk4BaQQF01mt .cluster span{color:#333;}#mermaid-svg-JbNgSk4BaQQF01mt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JbNgSk4BaQQF01mt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JbNgSk4BaQQF01mt rect.text{fill:none;stroke-width:0;}#mermaid-svg-JbNgSk4BaQQF01mt .icon-shape,#mermaid-svg-JbNgSk4BaQQF01mt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JbNgSk4BaQQF01mt .icon-shape p,#mermaid-svg-JbNgSk4BaQQF01mt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JbNgSk4BaQQF01mt .icon-shape .label rect,#mermaid-svg-JbNgSk4BaQQF01mt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JbNgSk4BaQQF01mt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JbNgSk4BaQQF01mt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JbNgSk4BaQQF01mt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 使用时长到达
显示锁定遮罩
倒计时结束
点击设置
保存并返回
正常使用
条件渲染移除GameBoard
休息倒计时
解锁状态
TimeSettingPage
8.2 核心状态与控制逻辑
typescript
@Entry
@ComponentV2
struct GamePage {
@Local navPathStack: NavPathStack = new NavPathStack();
@Local isShowLock: boolean = false;
@Local restSeconds: number = 0;
@Local useTimeSeconds: number = 300;
@Local restTimeSeconds: number = 600;
@Local futureRestTime: number = 0;
@Local restTimerId: number = -1;
@Local highScore: number = 0;
@Local gameHistory: number[] = [];
@Local topRectHeight: number = 0; // 状态栏高度
aboutToAppear(): void {
// 从 AppStorage 读取状态栏高度(@StorageProp 不支持 @ComponentV2)
const storedHeight: number | undefined = AppStorage.get<number>('topRectHeight');
if (storedHeight !== undefined) this.topRectHeight = storedHeight;
// 将 NavPathStack 存入 AppStorage 供子页面使用
AppStorage.setOrCreate<NavPathStack>('gameNavPathStack', this.navPathStack);
// 加载偏好设置并检查锁定状态
this.useTimeSeconds = PreferenceManager.getNumber(
this.context, PreferenceKeys.USE_TIME, 300);
this.futureRestTime = PreferenceManager.getNumber(
this.context, PreferenceKeys.FUTURE_REST, 0);
this.checkLockStatus();
}
private checkLockStatus(): void {
const now = TimeUtils.getCurrentSeconds();
if (this.futureRestTime === 0) { this.isShowLock = false; return; }
const timeUntilRest = this.futureRestTime - now;
if (timeUntilRest > this.restTimeSeconds) {
this.isShowLock = false;
this.startRestCountdown();
} else if (timeUntilRest > 0) {
this.restSeconds = timeUntilRest;
this.isShowLock = true;
} else {
this.isShowLock = false;
}
}
private startRestCountdown(): void {
this.clearRestTimer();
const now = TimeUtils.getCurrentSeconds();
const delay = (this.futureRestTime - this.restTimeSeconds - now) * 1000;
if (delay > 0) {
this.restTimerId = setTimeout(() => {
this.restSeconds = this.restTimeSeconds;
this.isShowLock = true; // 锁定!条件渲染自动移除 GameBoard
}, delay);
}
}
}
8.3 页面布局与组件组合
typescript
build() {
Navigation(this.navPathStack) {
Stack() {
Column() {
// 标题栏(预留状态栏高度)
Row() { /* 标题 + 设置按钮 */ }
.padding({ top: 8 + this.uiContext.px2vp(this.topRectHeight) })
// 游戏棋盘(自包含开始按钮)
// 锁定时条件渲染移除,GameBoard 自动销毁
if (!this.isShowLock) {
GameBoard({
onScoreChange: (score: number) => { this.currentScore = score; },
onGameEnd: (finalScore: number) => { /* 记录成绩 */ },
onRunningChange: (running: boolean) => {
if (running) this.startRestCountdown();
}
})
}
// 防沉迷状态提示 + 最近成绩
}
.linearGradient({
direction: GradientDirection.Bottom,
colors: [['#1A1A2E', 0.0], ['#16213E', 0.5], ['#0F3460', 1.0]]
})
.onVisibleAreaChange([0.0, 1.0], (isVisible: boolean, ratio: number) => {
if (isVisible && ratio >= 1.0) this.checkLockStatus();
if (!isVisible && ratio <= 0.0) this.clearRestTimer();
})
// 防沉迷锁定遮罩
if (this.isShowLock) {
GameLockOverlay({
restSeconds: this.restSeconds,
onUnlock: () => { this.isUnlocked = true; },
onGoToSettings: () => {
this.isShowLock = false;
this.navPathStack.pushPathByName('TimeSettingPage', '');
}
})
.transition(TransitionEffect.asymmetric(
TransitionEffect.opacity(0)
.combine(TransitionEffect.scale({ x: 1.1, y: 1.1 }))
.animation({ duration: 400, curve: Curve.EaseOut }),
TransitionEffect.opacity(0)
.animation({ duration: 300, curve: Curve.EaseIn })
))
}
}
}
.hideTitleBar(true).hideBackButton(true).hideToolBar(true)
}
TransitionEffect 注意 :
TransitionEffect.asymmetric()不支持未类型化的对象字面量链式调用.animation()。必须使用TransitionEffect.opacity(0).combine(...)链式 API。
九、配置文件说明
9.1 module.json5
json5
{
"module": {
"pages": "$profile:main_pages",
"routerMap": "$profile:route_map",
"deviceTypes": ["phone", "tablet", "2in1"]
}
}
9.2 route_map.json
json
{
"routerMap": [{
"name": "TimeSettingPage",
"pageSourceFile": "src/main/ets/pages/TimeSettingPage.ets",
"buildFunction": "timeSettingPageBuilder"
}]
}
9.3 EntryAbility 全屏配置(关键:先设置 AppStorage,再 loadContent)
typescript
onWindowStageCreate(windowStage: window.WindowStage): void {
const windowClass = windowStage.getMainWindowSync();
windowClass.setWindowLayoutFullScreen(true);
// 先获取安全区域并存入 AppStorage
let avoidArea = windowClass.getWindowAvoidArea(
window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
AppStorage.setOrCreate('bottomRectHeight', avoidArea.bottomRect.height);
avoidArea = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
AppStorage.setOrCreate('topRectHeight', avoidArea.topRect.height);
// 监听安全区域动态变化
windowClass.on('avoidAreaChange', (data) => { /* 更新 AppStorage */ });
// 最后加载页面(确保 AppStorage 值在页面 aboutToAppear 前已就绪)
windowStage.loadContent('pages/GamePage', (err) => { /* ... */ });
}
关键顺序 :
AppStorage.setOrCreate必须在loadContent之前 调用,否则页面aboutToAppear读取时值为默认值 0。
十、防沉迷控制流程图
#mermaid-svg-i9i9j6QGYzKIi3b0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-i9i9j6QGYzKIi3b0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-i9i9j6QGYzKIi3b0 .error-icon{fill:#552222;}#mermaid-svg-i9i9j6QGYzKIi3b0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-i9i9j6QGYzKIi3b0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-i9i9j6QGYzKIi3b0 .marker.cross{stroke:#333333;}#mermaid-svg-i9i9j6QGYzKIi3b0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-i9i9j6QGYzKIi3b0 p{margin:0;}#mermaid-svg-i9i9j6QGYzKIi3b0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-i9i9j6QGYzKIi3b0 .cluster-label text{fill:#333;}#mermaid-svg-i9i9j6QGYzKIi3b0 .cluster-label span{color:#333;}#mermaid-svg-i9i9j6QGYzKIi3b0 .cluster-label span p{background-color:transparent;}#mermaid-svg-i9i9j6QGYzKIi3b0 .label text,#mermaid-svg-i9i9j6QGYzKIi3b0 span{fill:#333;color:#333;}#mermaid-svg-i9i9j6QGYzKIi3b0 .node rect,#mermaid-svg-i9i9j6QGYzKIi3b0 .node circle,#mermaid-svg-i9i9j6QGYzKIi3b0 .node ellipse,#mermaid-svg-i9i9j6QGYzKIi3b0 .node polygon,#mermaid-svg-i9i9j6QGYzKIi3b0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-i9i9j6QGYzKIi3b0 .rough-node .label text,#mermaid-svg-i9i9j6QGYzKIi3b0 .node .label text,#mermaid-svg-i9i9j6QGYzKIi3b0 .image-shape .label,#mermaid-svg-i9i9j6QGYzKIi3b0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-i9i9j6QGYzKIi3b0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-i9i9j6QGYzKIi3b0 .rough-node .label,#mermaid-svg-i9i9j6QGYzKIi3b0 .node .label,#mermaid-svg-i9i9j6QGYzKIi3b0 .image-shape .label,#mermaid-svg-i9i9j6QGYzKIi3b0 .icon-shape .label{text-align:center;}#mermaid-svg-i9i9j6QGYzKIi3b0 .node.clickable{cursor:pointer;}#mermaid-svg-i9i9j6QGYzKIi3b0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-i9i9j6QGYzKIi3b0 .arrowheadPath{fill:#333333;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-i9i9j6QGYzKIi3b0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-i9i9j6QGYzKIi3b0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-i9i9j6QGYzKIi3b0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-i9i9j6QGYzKIi3b0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-i9i9j6QGYzKIi3b0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-i9i9j6QGYzKIi3b0 .cluster text{fill:#333;}#mermaid-svg-i9i9j6QGYzKIi3b0 .cluster span{color:#333;}#mermaid-svg-i9i9j6QGYzKIi3b0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-i9i9j6QGYzKIi3b0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-i9i9j6QGYzKIi3b0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-i9i9j6QGYzKIi3b0 .icon-shape,#mermaid-svg-i9i9j6QGYzKIi3b0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-i9i9j6QGYzKIi3b0 .icon-shape p,#mermaid-svg-i9i9j6QGYzKIi3b0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-i9i9j6QGYzKIi3b0 .icon-shape .label rect,#mermaid-svg-i9i9j6QGYzKIi3b0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-i9i9j6QGYzKIi3b0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-i9i9j6QGYzKIi3b0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-i9i9j6QGYzKIi3b0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未到休息时间
正在休息中
休息已结束
定时器触发
倒计时结束
应用启动
设置 AppStorage 安全区域
加载 GamePage
读取 Preferences 设置
检查当前时间
GameBoard 可玩
显示锁定遮罩
解锁状态
用户点击开始游戏
游戏运行中
防沉迷定时器倒计时
条件渲染移除 GameBoard
TextTimer 休息倒计时
跳转 TimeSettingPage
保存新设置
十一、开发注意事项与踩坑指南
11.1 ArkTS + V2 踩坑清单
| 问题 | 原因 | 解决方案 |
|---|---|---|
@Monitor 监听 @Param 不触发 |
V2 中 @Monitor 对 @Param 的监听可能不可靠 |
游戏启动由组件内部按钮直接调用,不依赖跨组件 @Monitor |
@ObservedV2 + ForEach 不更新 |
嵌套对象属性修改不触发 ForEach 重渲染 |
使用 @Local 基本类型数组 + 整体替换 |
@StorageProp 编译报错 |
仅支持 @Component,不支持 @ComponentV2 |
在 aboutToAppear 中使用 AppStorage.get() 手动读取 |
onWillAppear 类型不匹配 |
回调签名是 Callback<void, void> |
不传参数,通过 AppStorage 间接获取 NavPathStack |
TransitionEffect 对象字面量报错 |
不支持未类型化字面量链式 .animation() |
使用 TransitionEffect.opacity(0).combine(...) |
lineSpacing(4) 编译报错 |
需要 LengthMetrics 类型 |
使用 LengthMetrics.px(4) |
Stack 的 justifyContent 不存在 |
Stack 不支持该属性 | 改用 alignContent(Alignment.Center) |
| AppStorage 值读取为 0 | loadContent 在 AppStorage 设置前调用 |
EntryAbility 中先设 AppStorage 再 loadContent |
11.2 ForEach key 生成器最佳实践
typescript
// 正确:key 包含状态值,状态变化时触发重新渲染
ForEach(this.cellStates, (state: number, index: number) => {
// ...
}, (state: number, index: number) => index.toString() + '_' + state.toString())
// 错误:key 仅含索引,状态变化时不会重新渲染
ForEach(this.cellStates, (state: number, index: number) => {
// ...
}, (state: number, index: number) => index.toString())
11.3 定时器管理
typescript
aboutToDisappear(): void {
this.clearAllTimers(); // 组件销毁时必须清理
}
private clearAllTimers(): void {
if (this.moleTimerId !== -1) {
clearInterval(this.moleTimerId);
this.moleTimerId = -1;
}
if (this.countdownTimerId !== -1) {
clearInterval(this.countdownTimerId);
this.countdownTimerId = -1;
}
}
11.4 TextTimer 使用要点
count参数单位是毫秒 ,elapsedTime单位是秒- 使用
TextTimerController控制启动/暂停 - 条件渲染隐藏时需手动
pause()
十二、总结
本文实现了一个完整的防沉迷控制案例,核心技术要点如下:
- 自包含组件设计 :GameBoard 内部管理游戏启动/停止,避免
@Monitor跨组件触发的可靠性问题 - @Local 数组响应式 :使用基本类型
number[]+ 整体替换确保ForEach可靠更新 - 条件渲染控制生命周期 :通过
if (!isShowLock)销毁/重建 GameBoard,实现防沉迷强制休息 - AppStorage 跨页面通信 :替代不支持
@ComponentV2的@StorageProp/@StorageLink - 安全区域适配:EntryAbility 先设 AppStorage 再 loadContent,确保状态栏高度正确
希望本文的实战经验和踩坑总结能为 HarmonyOS 开发者提供有价值的参考。
参考资料:
- HarmonyOS 防沉迷控制架构指南
- ArkTS 状态管理 V2 官方文档
- Preferences 数据持久化
GameBoard 内部管理游戏启动/停止,避免@Monitor跨组件触发的可靠性问题
- @Local 数组响应式 :使用基本类型
number[]+ 整体替换确保ForEach可靠更新 - 条件渲染控制生命周期 :通过
if (!isShowLock)销毁/重建 GameBoard,实现防沉迷强制休息 - AppStorage 跨页面通信 :替代不支持
@ComponentV2的@StorageProp/@StorageLink - 安全区域适配:EntryAbility 先设 AppStorage 再 loadContent,确保状态栏高度正确
希望本文的实战经验和踩坑总结能为 HarmonyOS 开发者提供有价值的参考。
参考资料: