HarmonyOS应用<奇妙科学乐园>开发第98篇:家长控制页开发——密码设置/时长限制/护眼模式

📖 引言
"奇妙科学乐园"面向6-12岁儿童群体,家长控制是儿童类应用的核心安全防线。本文将完整拆解 ParentControl.ets 页面的实现细节,包括密码验证与设置、每日使用时长限制、护眼模式切换、就寝模式管理、5次错误锁定机制以及今日使用统计。这是一个典型的"安全+交互"复合型页面——既要保证家长设置的绝对安全性,又要为儿童提供清晰直观的使用时长可视化反馈。
通过本文,你将掌握HarmonyOS应用中如何利用 @ohos.data.preferences 实现独立的家长控制数据持久化、如何通过 Toggle 组件实现护眼模式切换、如何用 ForEach + Flex 构建时长选择器,以及如何设计安全的密码验证流程。
🎯 学习目标
完成本文后,你将能够:
- ✅ 实现独立的Preferences数据存储,将家长控制数据与用户数据隔离
- ✅ 设计安全的密码验证流程,包含首次设置、二次确认和5次错误锁定
- ✅ 使用Toggle组件实现护眼模式和就寝模式的开关切换
- ✅ 通过Flex+ForEach构建胶囊标签式时长选择器
- ✅ 利用Progress组件实现使用时长可视化进度条
💡 需求分析
功能模块设计
| 模块 | 功能描述 | 技术要点 |
|---|---|---|
| 密码验证 | 首次设置4位以上密码,二次确认,后续输入验证 | Preferences持久化、TextInput密码模式、Stack弹窗覆盖 |
| 5次错误锁定 | 连续5次密码错误后提示等待,防止暴力破解 | wrongCount计数器、递增等待时间提示 |
| 时长限制 | 6档可选时长(30分钟~不限制),实时保存 | Flex布局、ForEach渲染、选中态高亮 |
| 护眼模式 | Toggle开关切换,降低蓝光保护视力 | Toggle组件、boolean状态管理、即时持久化 |
| 就寝模式 | 设置就寝/起床时间,到点自动锁定 | 时间格式化、条件渲染、Toggle联动 |
| 使用统计 | Progress进度条展示今日已用时长/限制 | Progress组件、动态计算total值 |
| 锁定态控制 | 未验证前所有设置项不可操作 | isUnlocked状态控制enabled/opacity |
状态流转设计
页面进入
│
├─ 首次进入(无密码)
│ └─ 显示"设置密码"弹窗 → 输入+确认 → 设置成功 → 解锁
│
├─ 非首次进入(有密码)
│ ├─ 显示"验证密码"弹窗
│ │ ├─ 密码正确 → 解锁 → 显示设置界面
│ │ └─ 密码错误 → wrongCount++ → 提示剩余次数/锁定
│ └─ 取消 → RouterUtil.back()
│
└─ 设置界面
├─ 修改时长限制 → 即时保存+Toast
├─ 切换护眼模式 → 即时保存+Toast
├─ 切换就寝模式 → 即时保存+Toast
└─ 查看使用统计 → 只读展示
🛠️ 核心实现
步骤1: 数据模型与常量设计
功能说明
家长控制页面需要独立的数据存储空间,与用户偏好数据(收藏、历史、答题记录)隔离。我们使用独立的 parent_control_prefs Preferences实例,通过 AppConstants 中预定义的Key来管理数据读写。
完整代码
// pages/ParentControl.ets
import { promptAction } from '@kit.ArkUI';
import preferences from '@ohos.data.preferences';
import common from '@ohos.app.ability.common';
import { Logger } from '../utils/Logger';
import { RouterUtil } from '../utils/RouterUtil';
import { AppConstants, ThemeColors } from '../constants/AppConstants';
import { AppBar } from '../components/base/AppBar';
const TAG = 'ParentControl';
// 独立的Preferences名称,与用户数据存储隔离
const PREFS_NAME = 'parent_control_prefs';
// 家长设置数据接口定义
interface ParentSettings {
password: string; // 家长密码
dailyLimit: number; // 每日使用时长限制(分钟)
eyeProtection: boolean; // 护眼模式开关
bedtimeMode: boolean; // 就寝模式开关
bedtimeStart: string; // 就寝时间(HH:mm格式)
bedtimeEnd: string; // 起床时间(HH:mm格式)
usageToday: number; // 今日已用时长(分钟)
lastUsageDate: string; // 上次使用日期(用于跨日重置)
}
// 时长选项数据结构
interface LimitOption {
label: string; // 显示文案
value: number; // 时长值(分钟),0表示不限制
}
代码解析
1. 独立存储空间设计
// ✅ 正确:家长控制使用独立的Preferences实例
const PREFS_NAME = 'parent_control_prefs';
// ❌ 错误:将家长密码与用户收藏数据混在同一Preferences中
// 若用户清除缓存,可能误删家长密码;且密码数据应与业务数据隔离
原理/说明:
- 家长控制数据与用户偏好数据使用不同的Preferences名称(
parent_control_prefsvsscience_app_prefs) - 这种隔离确保了家长设置不会被用户的常规操作(如清除缓存)意外影响
AppConstants中定义了8个家长控制专用的Key常量(KEY_PARENT_PWD、KEY_TIME_LIMIT、KEY_EYE_PROTECTION等)
2. 时长选项数据结构
private limitOptions: LimitOption[] = [
{ label: '30分钟', value: 30 },
{ label: '1小时', value: 60 },
{ label: '1.5小时', value: 90 },
{ label: '2小时', value: 120 },
{ label: '3小时', value: 180 },
{ label: '不限制', value: 0 }
] as Array<LimitOption>;
原理/说明:
- 使用
interface LimitOption定义选项结构,确保类型安全 as Array<LimitOption>显式类型标注,满足ArkTS严格模式要求value: 0表示不限制,这是业务层的约定
步骤2: 页面状态管理与初始化
功能说明
页面使用 @State 装饰器管理13个响应式状态变量,涵盖密码输入、验证状态、设置项数值等所有UI需要响应的数据。aboutToAppear 生命周期中加载已保存的设置。
完整代码
@Entry
@Component
struct ParentControl {
// ===== 设置项状态 =====
@State dailyLimit: number = 60; // 每日时长限制(分钟)
@State eyeProtection: boolean = false; // 护眼模式开关
@State bedtimeMode: boolean = false; // 就寝模式开关
@State bedtimeHour: number = 21; // 就寝时间-小时
@State bedtimeMinute: number = 0; // 就寝时间-分钟
@State wakeUpHour: number = 7; // 起床时间-小时
@State wakeUpMinute: number = 0; // 起床时间-分钟
// ===== 密码验证状态 =====
@State showPasswordDialog: boolean = true; // 是否显示密码弹窗
@State isUnlocked: boolean = false; // 是否已解锁(验证通过)
@State isFirstSetup: boolean = false; // 是否首次设置
@State passwordInput: string = ''; // 密码输入
@State confirmPassword: string = ''; // 确认密码输入
@State passwordError: string = ''; // 密码错误提示
@State wrongCount: number = 0; // 错误次数计数器
// Preferences实例引用
private prefs: preferences.Preferences | null = null;
aboutToAppear() {
this.loadSettings();
}
代码解析
1. 状态变量分类管理
状态变量按职责分为三类:
┌─────────────────────────────────────────────────────┐
│ 设置项状态(6个) │
│ dailyLimit / eyeProtection / bedtimeMode │
│ bedtimeHour / bedtimeMinute / wakeUpHour / ... │
├─────────────────────────────────────────────────────┤
│ 密码验证状态(6个) │
│ showPasswordDialog / isUnlocked / isFirstSetup │
│ passwordInput / confirmPassword / passwordError │
├─────────────────────────────────────────────────────┤
│ 安全控制(1个) │
│ wrongCount │
└─────────────────────────────────────────────────────┘
2. 初始化加载流程
private async loadSettings(): Promise<void> {
try {
const ctx = getContext() as common.UIAbilityContext;
this.prefs = await preferences.getPreferences(ctx, PREFS_NAME);
// 判断是否首次设置:密码为空则表示首次
const pwd = await this.prefs.get(AppConstants.KEY_PARENT_PWD, '') as string;
this.isFirstSetup = pwd.length === 0;
// 加载已有设置,使用默认值兜底
this.dailyLimit = await this.prefs.get(AppConstants.KEY_TIME_LIMIT, 60) as number;
this.eyeProtection = await this.prefs.get(AppConstants.KEY_EYE_PROTECTION, false) as boolean;
this.bedtimeMode = await this.prefs.get('bedtime_mode', false) as boolean;
// 解析时间字符串为小时和分钟
const startStr = await this.prefs.get(AppConstants.KEY_BEDTIME_START, '21:00') as string;
const endStr = await this.prefs.get(AppConstants.KEY_BEDTIME_END, '07:00') as string;
const startParts = startStr.split(':');
const endParts = endStr.split(':');
this.bedtimeHour = parseInt(startParts[0]);
this.bedtimeMinute = parseInt(startParts[1]);
this.wakeUpHour = parseInt(endParts[0]);
this.wakeUpMinute = parseInt(endParts[1]);
// 非首次进入时显示密码验证弹窗
this.showPasswordDialog = !this.isFirstSetup;
Logger.info(TAG, `家长控制设置加载完成, 首次设置=${this.isFirstSetup}`);
} catch (e) {
Logger.error(TAG, '加载设置失败', e as Error);
// 异常时降级为首次设置模式
this.isFirstSetup = true;
this.showPasswordDialog = false;
}
}
原理/说明:
- 使用
getContext() as common.UIAbilityContext获取UIAbility上下文 preferences.getPreferences()是异步方法,需要await等待- 每个字段都提供默认值(如
60、false、'21:00'),确保Preferences为空时页面正常显示 - 异常捕获后降级为首次设置模式,避免用户无法进入页面
步骤3: 密码设置与验证机制
功能说明
密码模块是家长控制页面的安全核心,包含首次设置流程(输入+确认)和后续验证流程(输入+比对),以及5次错误锁定机制。
完整代码
// 首次设置密码
setupPassword() {
if (this.passwordInput.length < 4) {
this.passwordError = '密码至少4位';
return;
}
if (this.passwordInput !== this.confirmPassword) {
this.passwordError = '两次输入的密码不一致';
return;
}
this.doSetPassword(this.passwordInput);
}
// 执行密码设置(异步写入Preferences)
private async doSetPassword(pwd: string): Promise<void> {
try {
if (!this.prefs) return;
await this.prefs.put(AppConstants.KEY_PARENT_PWD, pwd);
await this.prefs.flush();
// 设置成功后切换状态
this.isFirstSetup = false;
this.isUnlocked = true;
this.showPasswordDialog = false;
this.passwordInput = '';
this.confirmPassword = '';
this.passwordError = '';
Logger.info(TAG, '家长密码设置成功');
promptAction.showToast({
message: '密码设置成功',
duration: 1500
});
} catch (e) {
Logger.error(TAG, '设置密码失败', e as Error);
this.passwordError = '设置失败,请重试';
}
}
// 验证密码入口
verifyPassword() {
if (this.passwordInput.length === 0) {
this.passwordError = '请输入密码';
return;
}
this.doVerifyPassword(this.passwordInput);
}
// 执行密码验证(含5次错误锁定)
private async doVerifyPassword(input: string): Promise<void> {
try {
if (!this.prefs) {
this.passwordError = '系统未就绪,请稍后再试';
return;
}
const savedPwd = await this.prefs.get(AppConstants.KEY_PARENT_PWD, '') as string;
if (input === savedPwd) {
// 验证通过
this.isUnlocked = true;
this.showPasswordDialog = false;
this.wrongCount = 0; // 重置错误计数
this.passwordInput = '';
this.passwordError = '';
Logger.info(TAG, '家长验证通过');
} else {
// 验证失败,累加错误次数
this.wrongCount++;
if (this.wrongCount >= 5) {
// 5次错误后锁定提示
this.passwordError = `错误次数过多,请${this.wrongCount * 10}秒后重试`;
this.passwordInput = '';
} else {
this.passwordError = `密码错误,还剩${5 - this.wrongCount}次机会`;
this.passwordInput = '';
}
Logger.warn(TAG, `密码错误,已错误${this.wrongCount}次`);
}
} catch (e) {
Logger.error(TAG, '验证密码失败', e as Error);
this.passwordError = '验证失败,请重试';
}
}
代码解析
1. 首次设置的前置校验
// ✅ 正确:逐项校验,给出明确的中文错误提示
setupPassword() {
if (this.passwordInput.length < 4) {
this.passwordError = '密码至少4位';
return;
}
if (this.passwordInput !== this.confirmPassword) {
this.passwordError = '两次输入的密码不一致';
return;
}
this.doSetPassword(this.passwordInput);
}
// ❌ 错误:缺少前置校验,直接写入
setupPassword() {
this.doSetPassword(this.passwordInput); // 可能为空或两次不一致
}
2. 5次错误锁定机制
错误次数递增逻辑:
第1次错误 → "密码错误,还剩4次机会"
第2次错误 → "密码错误,还剩3次机会"
第3次错误 → "密码错误,还剩2次机会"
第4次错误 → "密码错误,还剩1次机会"
第5次错误 → "错误次数过多,请50秒后重试"
关键点:
- wrongCount 在每次验证通过时重置为0
- 锁定时间 = wrongCount * 10(递增等待)
- 实际项目中应结合时间戳实现真正的倒计时锁定
3. 密码存储的安全性考量
// 当前实现:明文存储密码
await this.prefs.put(AppConstants.KEY_PARENT_PWD, pwd);
// 改进方向:使用加密哈希
// HarmonyOS SDK 提供 @ohos.security.crypto 模块
// 推荐使用 SHA-256 + 盐值哈希存储,而非明文
// 但对于儿童应用的本地密码,明文存储在Preferences中
// (Preferences本身属于应用沙箱,外部无法直接读取)
// 是可接受的折中方案
步骤4: 时长限制选择器
功能说明
时长限制选择器使用 Flex + ForEach 实现6个胶囊标签,选中态为主题色填充+白色文字,未选中为灰色背景+深色文字。点击即时保存并显示Toast反馈。
完整代码
// 时长选项数据
private limitOptions: LimitOption[] = [
{ label: '30分钟', value: 30 },
{ label: '1小时', value: 60 },
{ label: '1.5小时', value: 90 },
{ label: '2小时', value: 120 },
{ label: '3小时', value: 180 },
{ label: '不限制', value: 0 }
] as Array<LimitOption>;
// 时长变更处理
onLimitChange(value: number) {
this.dailyLimit = value;
this.saveSettings().catch(() => {});
promptAction.showToast({
message: value === 0 ? '已关闭时长限制' : `每日限用${value}分钟`,
duration: 1500
});
}
UI构建代码
// 时长选择器UI
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(this.limitOptions, (option: LimitOption) => {
Text(option.label)
.fontSize(13)
.fontColor(this.dailyLimit === option.value
? ThemeColors.TEXT_WHITE
: ThemeColors.TEXT_PRIMARY)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(this.dailyLimit === option.value
? ThemeColors.PRIMARY
: '#f5f5f5')
.borderRadius(20)
.margin({ right: 8, bottom: 8 })
// 未解锁时禁用交互,并降低透明度
.enabled(this.isUnlocked)
.opacity(this.isUnlocked ? 1 : 0.5)
.onClick(() => {
if (this.isUnlocked) {
this.onLimitChange(option.value);
}
});
}, (option: LimitOption) => option.value.toString());
}
.width('100%');
代码解析
1. Flex自动换行布局
// ✅ 正确:Flex + FlexWrap.Wrap 实现自动换行
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start })
// ❌ 错误:使用Row无法自动换行,超出屏幕被截断
Row() {
ForEach(this.limitOptions, ...)
}
原理/说明:
FlexWrap.Wrap允许子元素在容器宽度不足时自动换行FlexAlign.Start保证左对齐排列margin({ right: 8, bottom: 8 })控制标签间距
2. 选中态与禁用态的双重控制
// 选中态:通过三元表达式切换颜色
.fontColor(this.dailyLimit === option.value
? ThemeColors.TEXT_WHITE // 选中:白色文字
: ThemeColors.TEXT_PRIMARY) // 未选中:深色文字
.backgroundColor(this.dailyLimit === option.value
? ThemeColors.PRIMARY // 选中:主题色背景
: '#f5f5f5') // 未选中:浅灰背景
// 禁用态:未解锁时降低透明度+禁用点击
.enabled(this.isUnlocked)
.opacity(this.isUnlocked ? 1 : 0.5)
步骤5: 护眼模式与就寝模式
功能说明
护眼模式和就寝模式使用 Toggle 组件实现开关切换。Toggle的 isOn 属性绑定状态变量,onChange 回调中执行状态切换和持久化保存。就寝模式开启后,下方动态显示时间设置区域。
完整代码
// 护眼模式切换
toggleEyeProtection() {
this.eyeProtection = !this.eyeProtection;
this.saveSettings().catch(() => {});
promptAction.showToast({
message: this.eyeProtection ? '护眼模式已开启' : '护眼模式已关闭',
duration: 1500
});
}
// 就寝模式切换
toggleBedtimeMode() {
this.bedtimeMode = !this.bedtimeMode;
this.saveSettings().catch(() => {});
promptAction.showToast({
message: this.bedtimeMode ? '就寝模式已开启' : '就寝模式已关闭',
duration: 1500
});
}
护眼模式UI构建
Column() {
Row() {
Row() {
Image($r('app.media.icon_eye_care'))
.width(22)
.height(22)
.objectFit(ImageFit.Contain)
.margin({ right: 10 });
Column() {
Text('护眼模式')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(ThemeColors.TEXT_PRIMARY)
.width('100%')
.margin({ bottom: 2 });
Text('降低蓝光,保护视力')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY)
.width('100%');
}
.layoutWeight(1);
}
.layoutWeight(1);
// Toggle开关组件
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
.enabled(this.isUnlocked)
.onChange((isOn: boolean) => {
if (this.isUnlocked) {
this.toggleEyeProtection();
}
});
}
.width('100%');
}
就寝模式条件渲染
// 就寝模式开启时,动态显示时间设置区域
if (this.bedtimeMode) {
Row() {
Column() {
Text('就寝时间')
.fontSize(13)
.fontColor(ThemeColors.TEXT_TERTIARY)
.margin({ bottom: 4 });
Text(this.formatTime(this.bedtimeHour, this.bedtimeMinute))
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor(ThemeColors.TEXT_PRIMARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center);
Text('→')
.fontSize(18)
.fontColor('#cccccc');
Column() {
Text('起床时间')
.fontSize(13)
.fontColor(ThemeColors.TEXT_TERTIARY)
.margin({ bottom: 4 });
Text(this.formatTime(this.wakeUpHour, this.wakeUpMinute))
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor(ThemeColors.TEXT_PRIMARY);
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center);
}
.width('100%')
.padding(14)
.backgroundColor('#f9f9f9')
.borderRadius(12);
}
代码解析
1. Toggle组件的enabled与onChange配合
// ✅ 正确:enabled控制禁用态 + onChange中二次检查
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
.enabled(this.isUnlocked) // UI层禁用
.onChange((isOn: boolean) => {
if (this.isUnlocked) { // 逻辑层二次检查
this.toggleEyeProtection();
}
});
// ❌ 错误:仅用enabled,未在onChange中检查
// 某些设备上Toggle可能绕过enabled触发onChange
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
.enabled(this.isUnlocked)
.onChange((isOn: boolean) => {
this.toggleEyeProtection(); // 未检查isUnlocked
});
2. 条件渲染控制区域显隐
// ✅ 正确:使用if条件渲染,组件销毁/创建
if (this.bedtimeMode) {
Row() { /* 就寝时间设置 */ }
}
// ❌ 错误:使用visibility或opacity隐藏
// 组件仍然存在,占用布局空间
Row() {
/* 就寝时间设置 */
}
.visibility(this.bedtimeMode ? Visibility.Visible : Visibility.Hidden)
步骤6: 密码弹窗与Stack覆盖层
功能说明
密码验证/设置弹窗使用 Stack 组件实现全屏覆盖。当 showPasswordDialog 或 isFirstSetup 为 true 时,在设置界面之上渲染一个半透明遮罩层,中间放置密码输入表单。
完整代码
build() {
Stack() {
// 底层:设置内容(始终存在)
Column() {
AppBar({
barTitle: '家长控制',
showBack: true,
onBack: () => this.goBack()
});
Scroll() {
Column() {
// 时长限制卡片
// 护眼模式卡片
// 就寝模式卡片
// 使用统计卡片
}
.width('100%')
.padding({ left: 16, right: 16, top: 14 });
}
.scrollBar(BarState.Off)
.backgroundColor(ThemeColors.BG_SECONDARY);
}
.width('100%')
.height('100%');
// 顶层:密码弹窗(条件渲染)
if (this.showPasswordDialog || this.isFirstSetup) {
Column() {
Column() {
// 标题
Text(this.isFirstSetup ? '🔐 设置家长密码' : '🔐 家长验证')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(ThemeColors.TEXT_PRIMARY)
.margin({ bottom: 8 });
// 说明文案
Text(this.isFirstSetup
? '请设置4位以上密码,用于验证家长身份'
: '请输入家长密码以继续操作')
.fontSize(13)
.fontColor(ThemeColors.TEXT_SECONDARY)
.margin({ bottom: 20 });
// 密码输入框
TextInput({ placeholder: '请输入密码', text: this.passwordInput })
.type(InputType.Password)
.width('100%')
.height(44)
.backgroundColor('#f5f5f5')
.borderRadius(8)
.padding({ left: 14, right: 14 })
.margin({ bottom: 12 })
.onChange((value: string) => {
this.passwordInput = value;
this.passwordError = '';
});
// 首次设置时显示确认密码输入框
if (this.isFirstSetup) {
TextInput({ placeholder: '请再次输入密码', text: this.confirmPassword })
.type(InputType.Password)
.width('100%')
.height(44)
.backgroundColor('#f5f5f5')
.borderRadius(8)
.padding({ left: 14, right: 14 })
.margin({ bottom: 12 })
.onChange((value: string) => {
this.confirmPassword = value;
this.passwordError = '';
});
}
// 错误提示(条件渲染)
if (this.passwordError) {
Text(this.passwordError)
.fontSize(12)
.fontColor(ThemeColors.DANGER)
.width('100%')
.margin({ bottom: 12 });
}
// 操作按钮
Row() {
Button('取消')
.layoutWeight(1)
.height(44)
.fontSize(15)
.type(ButtonType.Capsule)
.backgroundColor('#f5f5f5')
.fontColor(ThemeColors.TEXT_SECONDARY)
.margin({ right: 8 })
.onClick(() => {
RouterUtil.back('ParentControl');
});
Button(this.isFirstSetup ? '设置密码' : '验证')
.layoutWeight(1)
.height(44)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.type(ButtonType.Capsule)
.backgroundColor(ThemeColors.PRIMARY)
.onClick(() => {
if (this.isFirstSetup) {
this.setupPassword();
} else {
this.verifyPassword();
}
});
}
.width('100%');
}
.width('100%')
.padding(24)
.backgroundColor(ThemeColors.BG_PRIMARY)
.borderRadius(20)
.margin({ left: 24, right: 24 });
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor(ThemeColors.OVERLAY); // 半透明遮罩 rgba(0,0,0,0.5)
}
}
.width('100%')
.height('100%');
}
代码解析
1. Stack覆盖层设计模式
Stack结构示意:
┌─────────────────────────────────┐
│ Column(设置内容,z-index: 0) │
│ ┌───────────────────────────┐ │
│ │ AppBar │ │
│ │ Scroll { 时长/护眼/就寝 } │ │
│ └───────────────────────────┘ │
│ │
│ ┌───────────────────────────┐ │ ← 条件渲染
│ │ Column(弹窗,z-index: 1) │ │
│ │ .backgroundColor(OVERLAY) │ │ ← rgba(0,0,0,0.5)遮罩
│ │ ┌─────────────────────┐ │ │
│ │ │ 密码输入表单 │ │ │
│ │ │ .bgColor(#ffffff) │ │ │
│ │ │ .borderRadius(20) │ │ │
│ │ └─────────────────────┘ │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
2. 弹窗内取消按钮的行为
// ✅ 正确:取消时返回上一页
.onClick(() => {
RouterUtil.back('ParentControl');
});
// ❌ 错误:仅关闭弹窗,用户仍停留在家长控制页面
.onClick(() => {
this.showPasswordDialog = false; // 未验证即可看到设置
});
原理/说明:
- 未验证密码的用户不应看到任何设置内容
- 取消操作必须返回上一页,而非仅关闭弹窗
- 这保证了家长控制的安全性——只有通过验证的家长才能查看和修改设置
步骤7: 使用统计与Progress进度条
功能说明
使用统计区域使用 Progress 组件可视化展示今日已用时长与限制时长的比例。当限制为0(不限制)时,进度条的total值设为100,避免除零错误。
完整代码
// 使用统计卡片
Column() {
Row() {
Image($r('app.media.icon_stats'))
.width(22)
.height(22)
.objectFit(ImageFit.Contain)
.margin({ right: 10 });
Column() {
Text('今日使用统计')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(ThemeColors.TEXT_PRIMARY)
.width('100%')
.margin({ bottom: 2 });
Text('今日已使用 45 分钟')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY)
.width('100%');
}
.layoutWeight(1);
}
.width('100%')
.margin({ bottom: 14 });
Column() {
// 进度条:value为已用时长,total为限制时长
Progress({ value: 45, total: this.dailyLimit > 0 ? this.dailyLimit : 100 })
.width('100%')
.color(ThemeColors.PRIMARY)
.backgroundColor(ThemeColors.BG_TERTIARY)
.margin({ bottom: 8 });
Row() {
Text('已用 45 分钟')
.fontSize(12)
.fontColor(ThemeColors.TEXT_SECONDARY);
Text(this.dailyLimit > 0
? `/ ${this.dailyLimit} 分钟`
: '/ 不限时')
.fontSize(12)
.fontColor(ThemeColors.TEXT_TERTIARY);
}
.width('100%');
}
.width('100%');
}
代码解析
1. Progress组件的防除零处理
// ✅ 正确:dailyLimit为0时用100作为total
Progress({ value: 45, total: this.dailyLimit > 0 ? this.dailyLimit : 100 })
// ❌ 错误:dailyLimit为0时total为0,进度条计算异常
Progress({ value: 45, total: this.dailyLimit })
2. 文案与进度条联动
// 限制60分钟时:显示 "/ 60 分钟"
// 不限制时:显示 "/ 不限时"
Text(this.dailyLimit > 0
? `/ ${this.dailyLimit} 分钟`
: '/ 不限时')
⚠️ 常见问题与解决方案
问题1: Preferences初始化失败导致页面白屏
现象:
进入家长控制页面后白屏,控制台报错"getPreferences failed"。
原因:getContext() 在Previewer环境中返回的对象类型不是 UIAbilityContext,导致 preferences.getPreferences() 调用失败。
错误代码:
// ❌ 错误:未处理Preferences初始化异常
private async loadSettings(): Promise<void> {
const ctx = getContext() as common.UIAbilityContext;
this.prefs = await preferences.getPreferences(ctx, PREFS_NAME);
// 如果这里抛出异常,后续代码都不执行
}
正确代码:
// ✅ 正确:try-catch包裹 + 降级处理
private async loadSettings(): Promise<void> {
try {
const ctx = getContext() as common.UIAbilityContext;
this.prefs = await preferences.getPreferences(ctx, PREFS_NAME);
// ... 正常加载逻辑
} catch (e) {
Logger.error(TAG, '加载设置失败', e as Error);
// 降级为首次设置模式,确保页面可用
this.isFirstSetup = true;
this.showPasswordDialog = false;
}
}
规则/建议:
- 所有IO操作(Preferences、文件、网络)必须try-catch
- 异常降级方案要保证页面可用,不能白屏
问题2: Toggle在未解锁时仍能触发onChange
现象:
虽然Toggle的 enabled(false) 使其变灰,但某些设备版本上快速点击仍会触发 onChange 回调,导致护眼模式被意外切换。
原因:enabled 只是UI层面的禁用,部分设备存在事件穿透的可能。
错误代码:
// ❌ 错误:仅依赖enabled,未在onChange中二次检查
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
.enabled(this.isUnlocked)
.onChange((isOn: boolean) => {
this.toggleEyeProtection(); // 可能在未解锁时执行
});
正确代码:
// ✅ 正确:UI禁用 + 逻辑层二次检查
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
.enabled(this.isUnlocked)
.onChange((isOn: boolean) => {
if (this.isUnlocked) { // 二次检查
this.toggleEyeProtection();
}
});
规则/建议:
- 关键操作必须有UI禁用 + 逻辑检查的双重保护
- 这是"防御性编程"的典型实践
问题3: 5次错误锁定没有真正的倒计时
现象:
密码错误5次后提示"请50秒后重试",但用户关闭页面再进入,错误计数已重置,可以继续尝试。
原因:wrongCount 是组件内部状态,页面销毁后即丢失,没有持久化到Preferences。
错误代码:
// ❌ 错误:wrongCount仅存在内存中
@State wrongCount: number = 0;
// 页面销毁后重置为0
正确代码:
// ✅ 正确:将锁定状态持久化
// 1. 在AppConstants中添加锁定相关Key
KEY_LOCKED_UNTIL: 'parent_locked_until'
// 2. 验证前检查是否在锁定期内
private async checkLockStatus(): Promise<boolean> {
const lockedUntil = await this.prefs?.get(
AppConstants.KEY_LOCKED_UNTIL, 0
) as number;
const now = Date.now();
if (now < lockedUntil) {
const remainSec = Math.ceil((lockedUntil - now) / 1000);
this.passwordError = `已锁定,请${remainSec}秒后重试`;
return true;
}
return false;
}
// 3. 5次错误后写入锁定时间戳
if (this.wrongCount >= 5) {
const lockUntil = Date.now() + this.wrongCount * 10 * 1000;
await this.prefs?.put('parent_locked_until', lockUntil);
await this.prefs?.flush();
}
规则/建议:
- 安全机制不能仅依赖内存状态,必须持久化
- 锁定时间使用时间戳而非固定秒数,确保跨页面生效
问题4: 密码弹窗的TextInput输入时Error提示不消失
现象:
密码错误提示"密码错误,还剩3次机会"显示后,用户开始输入新密码,但错误提示文字始终不消失。
原因:
密码输入框的 onChange 中虽然清空了 passwordError,但如果是首次设置的"确认密码"输入框触发,passwordInput 的onChange不会被调用。
错误代码:
// ❌ 错误:只在passwordInput的onChange中清空错误
TextInput({ placeholder: '请输入密码', text: this.passwordInput })
.onChange((value: string) => {
this.passwordInput = value;
this.passwordError = ''; // 只在这里清空
});
正确代码:
// ✅ 正确:两个输入框的onChange都清空错误
TextInput({ placeholder: '请输入密码', text: this.passwordInput })
.onChange((value: string) => {
this.passwordInput = value;
this.passwordError = ''; // 清空错误
});
if (this.isFirstSetup) {
TextInput({ placeholder: '请再次输入密码', text: this.confirmPassword })
.onChange((value: string) => {
this.confirmPassword = value;
this.passwordError = ''; // 同样清空错误
});
}
规则/建议:
- 表单中所有输入控件的onChange都应清空表单级错误提示
- 确保用户在任何输入框开始输入时,都能看到错误提示消失的即时反馈
问题5: saveSettings的catch为空导致静默失败
现象:
用户切换护眼模式后,退出页面再进入,发现设置没有保存,但没有任何错误提示。
原因:saveSettings().catch(() => {}) 中的空catch吞掉了异常。
错误代码:
// ❌ 错误:空catch,异常被静默吞掉
onLimitChange(value: number) {
this.dailyLimit = value;
this.saveSettings().catch(() => {}); // 异常被吞掉
}
正确代码:
// ✅ 正确:catch中记录日志
onLimitChange(value: number) {
this.dailyLimit = value;
this.saveSettings().catch((err: Error) => {
Logger.error(TAG, '保存时长限制失败', err);
promptAction.showToast({
message: '保存失败,请重试',
duration: 1500
});
});
}
规则/建议:
- 禁止空catch,至少要记录日志
- 影响用户数据的操作失败时应给出友好提示
- 但在当前项目中,
saveSettings内部已有完整的try-catch和日志记录,外层空catch是为防止Promise未处理的rejection警告,属于可接受的折中
📝 本章小结
核心知识点
本文详细讲解了家长控制页面的完整实现,主要包括:
1. 独立数据存储设计
- 家长控制使用独立的
parent_control_prefsPreferences实例 - 与用户数据(收藏、历史、答题)完全隔离
- 8个专用Key常量集中管理在
AppConstants中
2. 安全的密码验证流程
- 首次设置:输入+确认+最小长度校验
- 后续验证:输入+比对+错误计数+锁定提示
- Stack覆盖层确保未验证用户无法看到设置内容
3. 设置项的即时保存与反馈
- Toggle开关 + enabled双重保护
- Flex+ForEach构建胶囊选择器
- Progress进度条可视化使用时长
- 每次操作即时保存 + Toast反馈
最佳实践总结
✅ Preferences独立存储
const PREFS_NAME = 'parent_control_prefs';
// 与用户数据隔离,安全性更高
✅ Toggle双重保护
Toggle({ type: ToggleType.Switch, isOn: this.eyeProtection })
.enabled(this.isUnlocked)
.onChange((isOn: boolean) => {
if (this.isUnlocked) {
this.toggleEyeProtection();
}
});
✅ Progress防除零
Progress({ value: 45, total: this.dailyLimit > 0 ? this.dailyLimit : 100 })
下一步预告
在下一篇文章中,我们将:
- 🎨 详细讲解全量Icon系统从emoji到SVG的替换工程
- 📚 分析45处emoji替换的具体方案和28个SVG图标的设计思路
- 🏷️ 解决fillColor动态着色和资源冲突等实战问题
🔗 相关链接
- 项目源码: Atomgit仓库
💡 提示: 建议结合项目源码中 entry/src/main/ets/pages/ParentControl.ets 文件阅读,对照本文的代码解析理解每个功能模块的实现细节。
更多推荐

所有评论(0)