基于鸿蒙os开发跑步管理系统(十二)-首次启动引导功能
首次启动引导功能
概述
首次启动引导功能确保新用户在首次打开 RunAdvisor 时,自动跳转到资料设置页面完成个人信息填写。该功能基于 Preferences 中的 is_profile_set 标志位判断是否首次启动,配合 800ms 延迟跳转实现流畅的用户体验。

功能设计
核心需求
- 首次启动时自动引导用户进入资料设置页面
- 用户完成资料填写后,标记为已设置,后续启动不再触发引导
- 用户重置资料后,重新触发首次引导流程
- 引导完成后返回首页,首页数据显示基于用户个人资料
判断条件
首次启动的判断依据为 PreferencesService.isProfileSet() 返回 false:
- 应用首次安装后,Preferences 中不存在
is_profile_set键 - 用户执行"重置资料"操作后,
is_profile_set被删除 - 两种情况均判定为首次启动
状态管理
@State isFirstLaunch
@State isFirstLaunch: boolean = false;
该状态变量控制以下行为:
- 首页
aboutToAppear中判断是否触发自动跳转 - ProfileSetup 页面标题显示"欢迎使用 RunAdvisor"或"编辑个人资料"
- ProfileSetup 页面是否显示引导副标题
- 重置资料时重新设为
true
状态初始化
aboutToAppear(): void {
const prefs: PreferencesService = PreferencesService.getInstance();
this.profile = prefs.getProfile();
this.isFirstLaunch = !prefs.isProfileSet();
this.stepCount = prefs.getStepCount();
this.todayCalories = prefs.getTodayCalories();
this.loadWeather();
this.loadSteps();
if (this.isFirstLaunch) {
setTimeout(() => {
this.navStack.pushPath({ name: 'ProfileSetup' });
}, 800);
}
}
初始化流程:
- 从 PreferencesService 读取用户资料
- 判断
isProfileSet()确定isFirstLaunch状态 - 加载步数、热量、天气等数据
- 若为首次启动,800ms 后自动跳转至 ProfileSetup
自动跳转机制
延迟跳转
if (this.isFirstLaunch) {
setTimeout(() => {
this.navStack.pushPath({ name: 'ProfileSetup' });
}, 800);
}
800ms 延迟的原因
- 页面渲染等待: 首页 Tabs 组件需要完成初始布局,避免跳转时的视觉冲突
- 用户感知: 让用户短暂看到首页内容,建立"应用已成功启动"的认知
- 动画流畅: 避免页面尚未显示就立即跳转导致的闪烁
- 数据预加载: 在延迟期间完成天气和步数的初始加载请求
Navigation 栈操作
使用 navStack.pushPath 实现跳转,ProfileSetup 页面以 NavDestination 形式入栈:
Navigation(this.navStack) {
Tabs({ barPosition: BarPosition.End, index: this.currentTab }) {
// ... 五个 Tab 页面
}
}
.navDestination(this.NavDestBuilder)
.mode(NavigationMode.Stack)
NavDestBuilder 中处理 ProfileSetup 路由:
@Builder
NavDestBuilder(name: string, param: Object) {
if (name === 'ProfileSetup') {
this.ProfileSetupPage()
} else if (name === 'MapRoute') {
this.MapRoutePageNav(param as string)
}
}
用户体验流程
流程图
应用启动
│
├── EntryAbility.onCreate()
│ ├── PreferencesService.init(context)
│ └── LocationEnhanceService.startGyroscope()
│
├── Index.aboutToAppear()
│ ├── 读取 profile, isFirstLaunch = !isProfileSet()
│ ├── 加载步数、热量、天气
│ │
│ ├── [isFirstLaunch = true]
│ │ └── 800ms 后 → navStack.pushPath('ProfileSetup')
│ │
│ └── [isFirstLaunch = false]
│ └── 正常显示首页
│
├── [首次启动路径]
│ └── ProfileSetup 页面
│ ├── 标题: "欢迎使用 RunAdvisor"
│ ├── 副标题: "请填写基本信息,获取个性化跑步建议"
│ ├── 输入: 体重/身高/年龄/性别/步数目标/热量目标/周跑目标
│ └── 点击"保存"
│ ├── UserProfile.create(...)
│ ├── PreferencesService.saveProfile(p)
│ ├── isFirstLaunch = false
│ ├── advices = []
│ └── navStack.clear() → 返回首页
│
└── [正常使用路径]
└── 首页显示个性化数据
用户在首次启动时看到的引导页面,标题为"欢迎使用 RunAdvisor",下方有引导说明文字。
ProfileSetup 页面详解
首次启动模式 vs 编辑模式
ProfileSetup 通过 isFirstLaunch 状态区分两种模式:
| 方面 | 首次启动模式 | 编辑模式 |
|---|---|---|
| 页面标题 | “欢迎使用 RunAdvisor” | “编辑个人资料” |
| 导航栏标题 | “欢迎设置” | “编辑资料” |
| 引导副标题 | 显示 | 不显示 |
| 数据回显 | 使用默认值 | 使用当前 profile 值 |
| 保存后行为 | navStack.clear() 返回首页 | navStack.clear() 返回首页 |
NavDestination() {
// ...
}
.title(this.isFirstLaunch ? '欢迎设置' : '编辑资料')
输入验证
保存时对核心字段进行非空校验:
private saveProfileSetup(): void {
const weight: number = parseInt(this.setupWeight);
const height: number = parseInt(this.setupHeight);
const age: number = parseInt(this.setupAge);
const stepGoal: number = parseInt(this.setupStepGoal);
const calorieGoal: number = parseInt(this.setupCalorieGoal);
const runGoal: number = parseInt(this.setupRunGoal);
if (isNaN(weight) || isNaN(height) || isNaN(age)) {
return;
}
// ...
}
验证规则:
- 体重、身高、年龄为必填项,无法解析时直接返回不保存
- 步数目标、热量目标、周跑目标为可选项,无法解析时使用默认值
保存后的状态更新
const p: UserProfile = UserProfile.create(
weight, height, age, this.setupGender,
isNaN(stepGoal) ? Constants.DEFAULT_DAILY_GOAL : stepGoal,
isNaN(calorieGoal) ? Constants.DEFAULT_CALORIE_GOAL : calorieGoal,
isNaN(runGoal) ? Constants.DEFAULT_RUN_GOAL : runGoal
);
const prefs: PreferencesService = PreferencesService.getInstance();
prefs.saveProfile(p);
this.profile = p;
this.isFirstLaunch = false;
this.advices = [];
this.navStack.clear();
关键操作说明:
prefs.saveProfile(p): 持久化用户资料,同时将is_profile_set设为truethis.profile = p: 更新当前页面的 profile 状态,触发 UI 刷新this.isFirstLaunch = false: 标记首次引导已完成this.advices = []: 清空跑步建议缓存,下次访问建议页面时基于新 profile 重新计算this.navStack.clear(): 清除导航栈,用户回到首页且无法通过返回键回到设置页面

重置资料与重新引导
触发方式
用户在个人中心页面点击"重置资料"按钮:
Button('重置资料')
.backgroundColor(Constants.COLOR_RED)
.onClick(() => {
const prefs: PreferencesService = PreferencesService.getInstance();
prefs.clearProfile();
this.profile = new UserProfile();
this.isFirstLaunch = true;
})
重置后的行为
- Preferences 中的
user_profile和is_profile_set被删除 - 当前
profile恢复为默认值(体重70kg、身高170cm、年龄30等) isFirstLaunch设为true- 首页
aboutToAppear已执行过,不会再次触发 800ms 跳转 - 用户需手动进入个人中心点击"编辑资料"重新设置
设计考量
重置后不自动跳转的原因:
- 重置是用户的主动操作,与首次启动的被动引导场景不同
- 避免打断用户当前的操作流程
- 用户可在需要时自行进入编辑页面
PreferencesService 关键方法
isProfileSet()
isProfileSet(): boolean {
try {
const store: preferences.Preferences = this.ensureStore();
return store.getSync(Constants.PROFILE_SET_KEY, false) as boolean;
} catch (e) {
return false;
}
}
saveProfile()
saveProfile(profile: UserProfile): void {
const store: preferences.Preferences = this.ensureStore();
const profileJson: string = JSON.stringify(profile);
store.putSync(Constants.PROFILE_KEY, profileJson);
store.putSync(Constants.PROFILE_SET_KEY, true);
store.flushSync();
}
clearProfile()
clearProfile(): void {
const store: preferences.Preferences = this.ensureStore();
store.deleteSync(Constants.PROFILE_KEY);
store.deleteSync(Constants.PROFILE_SET_KEY);
store.flushSync();
}
数据流
首次启动:
Preferences (空) → isProfileSet() = false → isFirstLaunch = true → 800ms → ProfileSetup
保存资料:
ProfileSetup.save() → Preferences.saveProfile() → is_profile_set = true → navStack.clear() → 首页
正常启动:
Preferences (有数据) → isProfileSet() = true → isFirstLaunch = false → 正常首页
重置资料:
ProfilePage.reset() → Preferences.clearProfile() → is_profile_set = deleted → isFirstLaunch = true
已知限制
- 重置资料后不会自动弹出引导页面,用户需手动进入编辑
- 800ms 延迟为硬编码值,无法根据设备性能自适应
- 首次引导仅有一个资料设置步骤,没有多步骤引导页(如功能介绍滑动页)
isFirstLaunch为组件内部状态,应用冷重启后需重新从 Preferences 读取
首次启动的用户体验设计
设计原则
首次启动引导遵循以下设计原则:
- 非阻断性: 引导不应完全阻断用户查看应用,800ms 延迟让用户先看到首页
- 必要性: 仅引导填写必要的个人信息,不包含功能介绍等冗余步骤
- 可逆性: 用户可通过"重置资料"重新触发设置流程
- 一次性: 引导仅在首次启动时触发,不重复打扰用户
与竞品对比
| 应用 | 引导方式 | 步骤数 | 是否可跳过 |
|---|---|---|---|
| RunAdvisor | 自动跳转资料设置 | 1 步 | 否(资料必填) |
| Keep | 多步骤滑动页 + 资料设置 | 4-5 步 | 部分可跳过 |
| 悦跑圈 | 功能介绍 + 资料设置 | 3 步 | 可跳过 |
| Nike Run Club | 仅权限请求 | 0 步 | N/A |
RunAdvisor 选择单步骤引导的原因:
- 应用功能相对简单,无需多步骤功能介绍
- 个人资料是所有个性化功能(跑步建议、BMI、热量目标)的基础
- 用户填完资料后即可完整使用所有功能
800ms 延迟的调优考量
800ms 的延迟值经过实际测试调优:
| 延迟值 | 表现 |
|---|---|
| 0ms | 首页未渲染即跳转,出现白屏闪烁 |
| 200ms | 首页框架渲染但内容未加载,视觉不连贯 |
| 500ms | 首页基本可见但 Tabs 动画未完成 |
| 800ms | 首页完全渲染,用户建立"应用已启动"认知后平滑跳转 |
| 1500ms | 等待过长,用户可能误以为应用卡顿 |
800ms 在中端设备上表现最佳,但在低端设备上可能仍不够。理想的方案是根据页面渲染完成回调触发跳转,但 ArkUI 的 Tabs 组件没有提供"首次渲染完成"的回调。
NavPathStack 导航机制
导航栈结构
RunAdvisor 使用 Navigation + NavPathStack 实现页面导航:
@State navStack: NavPathStack = new NavPathStack();
Navigation(this.navStack) {
Tabs({ barPosition: BarPosition.End, index: this.currentTab }) {
TabContent() { this.HomePage() }
TabContent() { this.DietPage() }
TabContent() { this.AdvicePage() }
TabContent() { this.RoutePage() }
TabContent() { this.ProfilePage() }
}
}
.navDestination(this.NavDestBuilder)
.mode(NavigationMode.Stack)
NavPathStack 维护一个页面栈,pushPath 入栈新页面,clear 清空所有页面。NavigationMode.Stack 提供标准的栈式导航行为,包括自动生成的返回按钮和滑动手势。
ProfileSetup 的 NavDestination
ProfileSetup 页面通过 NavDestination 注册:
@Builder
NavDestBuilder(name: string, param: Object) {
if (name === 'ProfileSetup') {
this.ProfileSetupPage()
} else if (name === 'MapRoute') {
this.MapRoutePageNav(param as string)
}
}
NavDestination 是轻量级的页面容器,在 Navigation 栈中显示,自带导航栏和返回按钮。ProfileSetup 的导航栏标题根据 isFirstLaunch 动态切换。
navStack.clear() 的作用
this.navStack.clear();
保存资料后调用 navStack.clear() 清空导航栈,效果是:
- ProfileSetup 页面从栈中移除,用户无法通过返回键回到设置页
- 导航栈回到根页面(Tabs),首页自动显示
- 首页 UI 因为 @State profile 的变更而刷新
这一设计确保了引导流程的不可逆性:用户完成设置后不应回到设置页,除非主动通过"编辑资料"按钮进入。
首页与引导的状态同步
状态变量依赖关系
首页 Index 组件中与引导流程相关的 @State 变量:
@State profile: UserProfile = new UserProfile();
@State isFirstLaunch: boolean = false;
@State stepCount: number = 0;
@State todayCalories: number = 0;
@State weather: WeatherInfo = WeatherInfo.createDefault();
@State advices: RunAdvice[] = [];
@State currentTab: number = 0;
这些变量之间的依赖关系:
isFirstLaunch
├── 控制自动跳转 (aboutToAppear)
├── 控制 ProfileSetup 标题
└── 受 clearProfile() 影响
profile
├── 影响 BMI 计算 (ProfilePage)
├── 影响跑步建议 (AdvicePage)
├── 影响热量目标 (DietPage)
└── 受 saveProfile() 更新
advices
├── 受 profile 变更影响 (saveProfile 后清空)
└── 受天气变更影响 (重新计算建议)
保存后的状态刷新
保存资料后,以下状态同步更新:
private saveProfileSetup(): void {
const p: UserProfile = UserProfile.create(weight, height, age, this.setupGender, stepGoal, calorieGoal, runGoal);
const prefs: PreferencesService = PreferencesService.getInstance();
prefs.saveProfile(p); // 1. 持久化
this.profile = p; // 2. 更新内存状态 → 触发 UI 刷新
this.isFirstLaunch = false; // 3. 标记引导完成
this.advices = []; // 4. 清空建议缓存
this.navStack.clear(); // 5. 返回首页
}
步骤 2 的 this.profile = p 触发 ArkUI 的状态追踪机制,所有依赖 profile 的 UI 组件自动重新渲染。这是 ArkUI 声明式 UI 的核心机制:@State 变量的赋值会标记组件为"需要更新",在下一帧渲染时重新执行 build() 方法。
首页数据加载时序
aboutToAppear()
├── prefs.getProfile() → this.profile = p
├── isProfileSet() → this.isFirstLaunch = true/false
├── prefs.getStepCount() → this.stepCount
├── prefs.getTodayCalories() → this.todayCalories
├── loadWeather() → this.weather (异步)
├── loadSteps() → this.stepCount (异步)
└── if (isFirstLaunch) → setTimeout(800ms) → pushPath('ProfileSetup')
同步操作(getProfile、getStepCount、getTodayCalories)立即完成,异步操作(loadWeather、loadSteps)在后台执行。isFirstLaunch 的判断在所有数据加载之前完成,确保 800ms 定时器可以及时启动。
ProfileSetup 页面的 UI 实现
表单布局
ProfileSetup 页面使用 Scroll + Column 布局,确保内容超出屏幕时可以滚动:
NavDestination
└── Scroll
└── Column
├── 标题区 (isFirstLaunch ? "欢迎使用 RunAdvisor" : "编辑个人资料")
├── 副标题区 (仅首次启动显示)
├── 体重输入 (TextInput + "kg" 后缀)
├── 身高输入 (TextInput + "cm" 后缀)
├── 年龄输入 (TextInput + "岁" 后缀)
├── 性别选择 (Row: 男/女 切换按钮)
├── 步数目标输入 (TextInput)
├── 热量目标输入 (TextInput + "kcal" 后缀)
├── 周跑目标输入 (TextInput + "次/周" 后缀)
└── 保存按钮 (Button)
每个输入项使用 Row 布局,左侧为标签文字,右侧为 TextInput 和单位后缀:
Row() {
Text('体重(kg)')
.width(100)
.fontSize(14)
TextInput({ text: this.setupWeight })
.type(InputType.Number)
.width(100)
.onChange((value: string) => { this.setupWeight = value; })
}
.margin({ top: 12 })
性别选择的实现
性别选择使用两个 Row 按钮实现点击切换:
Row() {
Text('男')
.fontColor(this.setupGender === 'male' ? '#FFFFFF' : '#333333')
.fontSize(14)
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
}
.backgroundColor(this.setupGender === 'male' ? '#4A90D9' : '#F5F5F5')
.borderRadius(8)
.onClick(() => { this.setupGender = 'male'; })
Row() {
Text('女')
.fontColor(this.setupGender === 'female' ? '#FFFFFF' : '#333333')
.fontSize(14)
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
}
.backgroundColor(this.setupGender === 'female' ? '#4A90D9' : '#F5F5F5')
.borderRadius(8)
.onClick(() => { this.setupGender = 'female'; })
选中状态为蓝色背景 (#4A90D9) 白色文字,未选中为灰色背景 (#F5F5F5) 深灰文字。@State setupGender 的变更自动触发两个按钮的样式更新。
保存按钮
保存按钮使用醒目的蓝色,宽度占满:
Button('保存')
.width('100%')
.height(44)
.backgroundColor('#4A90D9')
.fontColor('#FFFFFF')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.margin({ top: 24 })
.onClick(() => { this.saveProfileSetup(); })
44px 高度符合移动端触控目标的最小推荐尺寸(44-48px)。宽度 100% 使按钮易于点击,减少误触。
数据持久化细节
Preferences 键名定义
所有键名定义在 Constants 类中,集中管理避免拼写错误:
class Constants {
static readonly PROFILE_KEY: string = 'user_profile';
static readonly PROFILE_SET_KEY: string = 'is_profile_set';
static readonly STEP_COUNT_KEY: string = 'step_count';
static readonly TODAY_CALORIES_KEY: string = 'today_calories';
static readonly WEATHER_CACHE_KEY: string = 'weather_cache';
static readonly WEATHER_CACHE_TIME_KEY: string = 'weather_cache_time';
static readonly DIET_RECORDS_KEY: string = 'diet_records';
}
键名使用下划线命名法,与 HarmonyOS Preferences 的命名惯例一致。每个键有明确的语义,避免复用同一键存储不同类型的数据(WeatherService 的缓存 Bug 就是因为键名复用导致的)。
flushSync 的重要性
store.putSync(Constants.PROFILE_KEY, profileJson);
store.putSync(Constants.PROFILE_SET_KEY, true);
store.flushSync();
flushSync() 将内存中的数据立即写入磁盘。如果不调用 flushSync,数据仅存在于内存中,应用异常退出时可能丢失。对于用户资料这种关键数据,同步 flush 确保了持久化的可靠性。flushSync 的性能开销约 1-5ms,对用户体验无影响。
Profile 序列化格式
UserProfile 序列化后的 JSON 格式:
{
"weight": 70,
"height": 170,
"age": 30,
"gender": "male",
"dailyStepGoal": 10000,
"dailyCalorieGoal": 2200,
"weeklyRunGoal": 5
}
JSON 格式可读性好,便于调试时通过 Preferences 查看工具检查数据。缺点是占用空间略大于二进制格式,但对于单条用户资料数据,空间差异可忽略。
边界情况处理
重复点击保存
用户快速连续点击保存按钮可能导致重复执行 saveProfileSetup()。当前版本未做防抖处理,但由于 saveProfileSetup() 是幂等操作(每次保存覆盖上一次数据),重复调用不会导致数据错误。后续版本可通过设置 isSaving 标志位防止重复提交。
中途退出设置
用户在 ProfileSetup 页面点击返回按钮(NavDestination 自带的返回箭头)退出设置,此时 isFirstLaunch 仍为 true,但 aboutToAppear 不会再次执行(因为首页已经创建)。用户回到首页后看到的是基于默认 Profile 的数据,但没有再次触发引导跳转。
这意味着用户可能"逃过"首次引导,以默认 Profile 使用应用。当前版本的处理方式是:首页仍然可以正常使用,只是所有数据基于默认值。用户可以随时通过个人中心的"编辑资料"修改信息。
数据迁移场景
应用升级时如果 UserProfile 模型增加了新字段,旧版本的 JSON 反序列化会缺失新字段。当前的反序列化实现使用逐字段赋值,缺失字段将保持 UserProfile 的默认值:
const p: UserProfile = new UserProfile();
p.weight = obj.weight as number;
// 新字段如果不存在于旧 JSON 中,保持 new UserProfile() 的默认值
这种向后兼容的处理确保了应用升级后不会因数据格式变更而崩溃。
更多推荐




所有评论(0)