首次启动引导功能

概述

首次启动引导功能确保新用户在首次打开 RunAdvisor 时,自动跳转到资料设置页面完成个人信息填写。该功能基于 Preferences 中的 is_profile_set 标志位判断是否首次启动,配合 800ms 延迟跳转实现流畅的用户体验。

首次启动引导

功能设计

核心需求

  1. 首次启动时自动引导用户进入资料设置页面
  2. 用户完成资料填写后,标记为已设置,后续启动不再触发引导
  3. 用户重置资料后,重新触发首次引导流程
  4. 引导完成后返回首页,首页数据显示基于用户个人资料

判断条件

首次启动的判断依据为 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);
  }
}

初始化流程:

  1. 从 PreferencesService 读取用户资料
  2. 判断 isProfileSet() 确定 isFirstLaunch 状态
  3. 加载步数、热量、天气等数据
  4. 若为首次启动,800ms 后自动跳转至 ProfileSetup

自动跳转机制

延迟跳转

if (this.isFirstLaunch) {
  setTimeout(() => {
    this.navStack.pushPath({ name: 'ProfileSetup' });
  }, 800);
}

800ms 延迟的原因

  1. 页面渲染等待: 首页 Tabs 组件需要完成初始布局,避免跳转时的视觉冲突
  2. 用户感知: 让用户短暂看到首页内容,建立"应用已成功启动"的认知
  3. 动画流畅: 避免页面尚未显示就立即跳转导致的闪烁
  4. 数据预加载: 在延迟期间完成天气和步数的初始加载请求

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();

关键操作说明:

  1. prefs.saveProfile(p): 持久化用户资料,同时将 is_profile_set 设为 true
  2. this.profile = p: 更新当前页面的 profile 状态,触发 UI 刷新
  3. this.isFirstLaunch = false: 标记首次引导已完成
  4. this.advices = []: 清空跑步建议缓存,下次访问建议页面时基于新 profile 重新计算
  5. this.navStack.clear(): 清除导航栈,用户回到首页且无法通过返回键回到设置页面

设置完成后的首页

重置资料与重新引导

触发方式

用户在个人中心页面点击"重置资料"按钮:

Button('重置资料')
  .backgroundColor(Constants.COLOR_RED)
  .onClick(() => {
    const prefs: PreferencesService = PreferencesService.getInstance();
    prefs.clearProfile();
    this.profile = new UserProfile();
    this.isFirstLaunch = true;
  })

重置后的行为

  1. Preferences 中的 user_profileis_profile_set 被删除
  2. 当前 profile 恢复为默认值(体重70kg、身高170cm、年龄30等)
  3. isFirstLaunch 设为 true
  4. 首页 aboutToAppear 已执行过,不会再次触发 800ms 跳转
  5. 用户需手动进入个人中心点击"编辑资料"重新设置

设计考量

重置后不自动跳转的原因:

  • 重置是用户的主动操作,与首次启动的被动引导场景不同
  • 避免打断用户当前的操作流程
  • 用户可在需要时自行进入编辑页面

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

已知限制

  1. 重置资料后不会自动弹出引导页面,用户需手动进入编辑
  2. 800ms 延迟为硬编码值,无法根据设备性能自适应
  3. 首次引导仅有一个资料设置步骤,没有多步骤引导页(如功能介绍滑动页)
  4. isFirstLaunch 为组件内部状态,应用冷重启后需重新从 Preferences 读取

首次启动的用户体验设计

设计原则

首次启动引导遵循以下设计原则:

  1. 非阻断性: 引导不应完全阻断用户查看应用,800ms 延迟让用户先看到首页
  2. 必要性: 仅引导填写必要的个人信息,不包含功能介绍等冗余步骤
  3. 可逆性: 用户可通过"重置资料"重新触发设置流程
  4. 一次性: 引导仅在首次启动时触发,不重复打扰用户

与竞品对比

应用 引导方式 步骤数 是否可跳过
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() 清空导航栈,效果是:

  1. ProfileSetup 页面从栈中移除,用户无法通过返回键回到设置页
  2. 导航栈回到根页面(Tabs),首页自动显示
  3. 首页 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() 的默认值

这种向后兼容的处理确保了应用升级后不会因数据格式变更而崩溃。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐