【HarmonyOS学习笔记】2026-07-14 | Day 3 遗留问题验证:fire-and-forget时序实锤 + Navigation路由架构与@State/@Link
date: 2026-07-14
tags: [HarmonyOS, 关系型数据库, fire-and-forget, 异步时序, 实验验证, Navigation, NavPathStack, NavDestination, route_map, @State, @Link, onReady]
continues: 2026-07-13
本文解决【HarmonyOS学习笔记】2026-07-13 | 保存应用数据-关系型数据库(Day 3)中的遗留问题 #13:教材源码与推论实现的异步调用差异,fire-and-forget 模式是否存在时序隐患。同时在修复项目路由 bug 的过程中,深入理解了 Navigation 路由架构和 @State/@Link 机制。
1️⃣ 学习内容概览
今天的核心工作:通过两轮实机实验验证 fire-and-forget 的时序隐患(第一轮无法证明,第二轮实锤),以及在修复项目路由问题的过程中深入理解了 Navigation 路由架构和 @State/@Link 机制。内容包括:fire-and-forget 时序实验(两轮完整实验过程、日志分析、干扰因素排除、结论)、Navigation 路由架构(入口页/NavDestination页/Tab子组件三种角色,route_map.json与main_pages.json的职责,pathStack完整传递链路)、NavDestination.onReady(@State空栈占位+onReady替换的"先占位再替换"模式)、@State与@Link(数据拥有者vs数据引用者的本质区别,为什么不能随意互换)。
2️⃣ fire-and-forget 时序实验验证(Day 3 遗留问题 #13)
一、问题回顾
Day 3 学习中,发现 RDBStoreUtil 的createSportTable、initSportTable、createPlanTable三个方法内部调了异步 API(execute、batchInsert),但方法本身不返回 Promise——调用方无法 await,这是fire-and-forget模式。
推论:在aboutToAppear中依次调用这三个方法后立即执行同步查询,异步操作可能还没完成,查询会拿到空结果。需要实机验证。
二、实验方法
在 RDBStoreUtil 和 GoalPageBuilder 的关键位置添加Date.now()相对时间戳日志,冷启动运行,观察:
- 异步操作的完成时间是否在同步查询执行之后
- 查询结果是否为空
前置条件:必须卸载应用或清除数据后冷启动,确保数据库文件不存在。如果表已存在,CREATE TABLE IF NOT EXISTS会瞬间完成,掩盖时序问题。
三、第一轮实验——用 queryAllPlans 验证(结论:无法证明)
3.1 实验设计
在aboutToAppear中,紧接三个 fire-and-forget 调用之后执行queryAllPlans()。queryAllPlans 的 SQL 是 JOIN 查询:
SELECT...FROMsportsASsJOINplansASpWHEREs.ID=p.SPORT_ID3.2 运行日志
T+0: aboutToAppear 开始 T+0: 调用 createSportTable() T+0: 调用 initSportTable() T+0: 调用 createPlanTable() T+1: 调用 queryAllPlans() T+1: queryAllPlans 返回 0 条 T+2: queryAllPlans() 返回,结果数量=0 T+2: aboutToAppear 结束 ────── 异步回调 ────── T+15: batchInsert success, insertNum=3 T+16: createPlanTable execute success T+16: createSportTable execute success3.3 分析——为什么"返回 0 条"不能证明时序问题
PLANS 表本身就没有初始数据!initSportTable只往 SPORTS 表插入晨跑/瑜伽/游泳,没有任何代码往 PLANS 表插初始数据。所以即使时序完全正确(等异步全完成后再查),结果也是 0 条。
“返回 0 条” = PLANS 表空的 = 预期结果,不能作为时序问题的证据。
3.4 日志能证明什么
日志确实证明了"异步回调在同步查询之后才到达"(T+15ms > T+1ms),但仅凭时序顺序,不能证明"这导致了实际错误"。
3.5 用户交互验证
用户在 UI 上手动添加计划后,queryAllPlans 能正确返回结果(1条→2条→3条),且添加时能显示"晨跑"等 SPORTS 数据——说明异步操作早已完成,数据已就绪。在正常的用户操作路径下,fire-and-forget 不会导致实际错误。
3.6 对教材设计思路的理解
教材的设计思路可能是:
- 创建 SPORTS 表 → 插入 SPORTS 初始数据 → 这些都是"准备工作"
- PLANS 数据由用户手动添加(人操作,秒级),远慢于异步操作(15ms)
- 页面首次加载时不需要显示 SPORTS 数据,只需要显示空的 PLANS 列表
- 所以 fire-and-forget 在这个设计中"碰巧没问题"
但"碰巧没问题"不等于"时序没问题"。需要一个不受 PLANS 空表干扰的实验来验证。
四、第二轮实验——用 queryAllSports 验证(结论:时序问题实锤 ✅)
4.1 实验设计
新增queryAllSports()方法,直接查 SPORTS 表(SELECT NAME FROM SPORTS),不走 JOIN。
- 如果时序正确:应返回 3 条(晨跑,瑜伽,游泳)
- 如果时序有问题:返回 0 条(表还没建好/数据还没插进去)
在aboutToAppear中,紧接initSportTable()之后调用queryAllSports()。
4.2 运行日志
T+0: aboutToAppear 开始 T+0: 调用 createSportTable() T+0: 调用 initSportTable() T+1: 调用 queryAllSports() T+0ms: queryAllSports 开始执行 T+1ms: queryAllSports 返回 0 条: ← 🔴 直接查 SPORTS 表,0条! T+2: queryAllSports() 返回,结果= T+2: 调用 createPlanTable() T+2: 调用 queryAllPlans() T+3: queryAllPlans() 返回,结果数量=0 T+13: aboutToAppear 结束 ────── 异步回调 ────── T+23ms: batchInsert success, insertNum=3 ← 数据在23ms后才插进去 T+23ms: createSportTable execute success ← 表在23ms后才建好 T+21ms: createPlanTable execute success4.3 分析
这次和 queryAllPlans 完全不同:
| 查询 | 为什么返回 0 条 | 是时序问题吗 |
|---|---|---|
| queryAllPlans(查 JOIN) | PLANS 表本来就是空的 | ❌ 无法判断 |
| queryAllSports(直接查 SPORTS) | 异步还没执行完,表/数据还不存在 | ✅ 实锤! |
如果时序没问题(串行执行),queryAllSports 应该返回晨跑,瑜伽,游泳3 条数据。但 fire-and-forget 下,建表(createSportTable)和插入(initSportTable)都还没提交给 SQLite,同步查询跑在前面,查到了空表。
五、两轮实验对比
| 轮次 | 查询方式 | 返回结果 | 能否证明时序问题 | 原因 |
|---|---|---|---|---|
| 第一轮 | queryAllPlans(JOIN) | 0 条 | ❌ 无法证明 | PLANS 表本身为空,返回 0 条是预期结果 |
| 第二轮 | queryAllSports(直接查 SPORTS) | 0 条 | ✅ 实锤 | SPORTS 表应有 3 条初始数据但返回 0 条,证明查询时数据还不存在 |
六、实验结论
6.1 fire-and-forget 时序隐患已实锤
证据链:
createSportTable()和initSportTable()在 T+0ms 被调用,立即返回(fire-and-forget)queryAllSports()在 T+1ms 同步查询 SPORTS 表,返回 0 条createSportTable在 T+23ms 才成功,batchInsert在 T+23ms 才插入 3 条数据- 查询在异步操作完成前执行 → 查到空结果
逻辑必然:同步代码的执行顺序由代码书写顺序决定,异步回调的执行顺序由事件循环决定。fire-and-forget 把异步操作"射出"后不等结果,下一行同步代码立即执行——时序上永远是"查询先于异步完成"。
6.2 为什么教材的实现"碰巧没问题"
教材应用的用户路径是:登录 → 首页(此时异步操作早已完成)→ 点击目标页。目标页加载时 SPORTS 数据已就绪,且首次 queryAllPlans 返回 0 条是预期结果(PLANS 表空)。用户手动添加计划是秒级操作,远慢于异步操作(15~23ms),不存在时序冲突。
教材的设计在当前功能下没有问题,但本质是"碰巧规避了时序窗口",不是"时序逻辑正确"。如果需求变为"页面加载时就需要显示 SPORTS 列表供选择",fire-and-forget 就会导致实际错误。
6.3 fire-and-forget 的真正隐患
不是"当前代码会出错",而是**“调用方无法知道异步操作是否成功”**:
- 建表失败 → 调用方不知道 → 后续代码基于"表已存在"的假设继续执行
- 插入失败 → 调用方不知道 → 用户看到的列表是空的,以为"没有数据"
- 如果页面加载时就需要读取刚写入的数据 → 查到空结果
6.4 实验设计的关键教训
queryAllPlans 返回 0 条不能证明时序问题——PLANS 表本身为空是干扰因素。只有用"预期应有数据但查到空"的查询(queryAllSports),才能证明时序问题。实验设计必须排除干扰因素,用"预期应有数据"作为判断基准。
3️⃣ Navigation 路由架构与 pathStack 传递
在准备实验时遇到了路由问题(点击目标页没反应),排查过程中深入理解了 Navigation 路由架构。
一、三种角色
HarmonyOS 的 Navigation + NavPathStack 导航系统中,有三种不同性质的东西:
| 性质 | 是什么 | 例子 | 在哪注册 |
|---|---|---|---|
| 入口页 | 应用启动时加载的第一个页面,拥有 Navigation 容器 | LoginPage | main_pages.json |
| NavDestination 页 | 被 Navigation 管理的子页面,通过pushPathByName跳转 | MainPage、GoalPage | route_map.json |
| Tab 子组件 | MainPage 内部 Tabs 切换的组件,不是页面,不走路由 | Home、Setting | 不需要注册 |
route_map.json 只注册"需要被跳转到"的 NavDestination 页面。LoginPage 是入口页由 main_pages.json 直接加载,不需要被跳转;Home 和 Setting 是 Tab 子组件,通过 Tabs 切换显示,不走路由。
所以 route_map.json 只有 2 条记录:
{"routerMap":[{"name":"MainPage","pageSourceFile":"...MainPage.ets","buildFunction":"MainPageBuilder"},{"name":"GoalPageBuilder","pageSourceFile":"...GoalPageBuilder.ets","buildFunction":"GoalPageBuilder"}]}二、pathStack 从创建到传递的完整链路
LoginPage(源头) pathStack: NavPathStack = new NavPathStack() ← 创建(普通属性,无装饰器) Navigation(this.pathStack) ← 绑定到 Navigation 容器 pushPathByName('MainPage', null) ← 跳转 └→ MainPage(NavDestination) @State pathStack = new NavPathStack() ← 空栈占位 .onReady(context) → this.pathStack = context.pathStack ← 框架注入真实栈 Home({pathStack: this.pathStack}) ← 构造参数传递 └→ Home @Link pathStack: NavPathStack ← 双向绑定 MainPage 的 pathStack pushPathByName('GoalPageBuilder', null) ← 操作同一个真实栈2.1 LoginPage 的 pathStack——创建者
@Entry@Componentstruct LoginPage{pathStack:NavPathStack=newNavPathStack();// 普通属性,创建实例build(){Navigation(this.pathStack){/* 登录UI */}// 绑定到 Navigation 容器}}为什么不用 @State?NavPathStack 是 Navigation 组件内部管理的对象,UI 不需要直接响应它的变化(Navigation 自己监听栈变化来切换页面),所以不需要 @State 追踪。
2.2 MainPage 的 pathStack——通过 onReady 获取
@Componentexportstruct MainPage{@StatepathStack:NavPathStack=newNavPathStack();// 空栈占位build(){NavDestination(){Tabs(){TabContent(){Home({pathStack:this.pathStack})}TabContent(){Setting()}}}.onReady((context:NavDestinationContext)=>{this.pathStack=context.pathStack;// 替换为真实栈})}}为什么用 @State + onReady 而不是 @Link?因为 MainPage 是通过MainPageBuilder()创建的,这个 Builder 函数由 Navigation 框架自动调用,你没法在框架调用时手动传参。@Link 要求父组件在创建时显式传参,没人传就编译报错。
@State + onReady 的本质是"先占位再替换":
第1次渲染(onReady之前): @State pathStack = 空栈 build() 执行 → Home({pathStack: this.pathStack}) → Home 拿到空栈 onReady 触发: this.pathStack = context.pathStack ← 替换为真实栈 @State 值变了 → 框架标记需要重新渲染 第2次渲染(onReady之后): build() 重新执行 → Home({pathStack: this.pathStack}) → Home 拿到真实栈onReady 赋值后 MainPage 确实会重新渲染一次——这是 @State 的本职工作,值变了就重新渲染。Home 组件实例不会被销毁重建,ArkUI 的渲染机制只会更新 @Link 的引用。onReady 在页面首次渲染的同一帧或下一帧就触发,远快于用户操作,实际不会出问题。
2.3 Home 的 pathStack——通过 @Link 引用
@Componentexportdefaultstruct Home{@LinkpathStack:NavPathStack;// 双向绑定 MainPage 的 pathStack// 点击目标时:this.pathStack.pushPathByName('GoalPageBuilder',null);}@Link 让 Home 的this.pathStack和 MainPage 的this.pathStack指向同一个对象。Home 调用 pushPathByName 操作的就是 LoginPage 创建的那个真实导航栈,Navigation 能感知到,跳转生效。
2.4 GoalPage 不需要 pathStack
GoalPage 只做数据展示和操作,不需要跳转到其他页面,所以不声明 pathStack。
三、pushPathByName 的执行过程
this.pathStack.pushPathByName('MainPage',null);- 框架拿
'MainPage'去 route_map.json 查找 - 找到:
name: "MainPage",buildFunction: "MainPageBuilder",pageSourceFile: "...MainPage.ets" - 框架找到
MainPageBuilder这个@Builder函数,调用它 MainPageBuilder()内部创建了MainPage()组件MainPage的NavDestination()被挂载到 Navigation 容器中- Navigation 切换显示到 MainPage
栈的状态变化:
跳转前:[LoginPage] ← 栈底 跳转后:[LoginPage, MainPage] ← MainPage 在栈顶,显示 MainPage用户点击返回,Navigation 自动弹出栈顶,显示前一个页面,不需要写代码。
4️⃣ @State 与 @Link 的本质区别
核心区别一句话:@State 是"拥有数据"(房东),@Link 是"引用别人的数据"(租客)。
| @State | @Link | |
|---|---|---|
| 角色 | 数据的拥有者 | 数据的引用者 |
| 初始值 | 必须自己给 | 没有初始值,必须由父组件传入 |
| 谁管理数据 | 自己 | 引用的 @State 那一方 |
| 子组件能改吗 | N/A(自己就是拥有者) | 能,改动双向同步回 @State |
| 编译要求 | 自给自足,总能编译通过 | 必须有人传值,否则编译报错 |
建立连接的方式——父传子:
// 父组件@Statecount:number=0;Child({count:this.count})// 传参建立连接// 子组件@Linkcount:number;// 变量名必须和传参的 key 一致@Link 不能随意换成 @State:
| 方向 | 编译 | 功能 |
|---|---|---|
| @Link → @State | ✅ 编译能过 | ❌ 功能坏了——@State 自给自足,拿到的是独立副本(假栈),和父级断开连接 |
| @State → @Link | ❌ 编译不过 | — @Link 需要别人传值,没人传就报错 |
在 pathStack 场景中,如果 Home 把 @Link 换成 @State,Home 就拥有了一个独立的空栈,和 Navigation 容器没有关系,pushPathByName 调了也白调。
5️⃣ 为什么这些问题出现?(刨根问底)
🧠 我的理解
route_map 只注册 NavDestination 页面,是因为 Navigation 路由系统设计上区分了"入口"和"子页面":入口页(@Entry)由 main_pages.json 加载,是 Navigation 容器的默认内容;子页面(NavDestination)通过 pushPathByName 动态加载,需要在 route_map 中注册名字和 Builder 函数的映射关系。Tab 子组件不是页面,是页面内部的 UI 组织方式,不参与路由。
@State + onReady 是 MainPage 获取 pathStack 的唯一可行方案:因为 MainPageBuilder 由框架自动调用,你没法手动传参,@Link 行不通。"先占位再替换"虽然会触发一次重新渲染,但 onReady 在页面首次渲染的同一帧或下一帧就触发,远快于用户的手指操作,实际不会出问题。
@State 和 @Link 不是"装饰偏好",是数据通道:@State 拥有数据,@Link 引用数据,断开连接就是断开功能。Home 如果把 @Link 换成 @State,就断开了和 MainPage 的数据通道,pathStack 变成假栈,跳转不了。
fire-and-forget 的"碰巧没问题"掩盖了时序隐患:SQLite 操作极快(15~23ms),在正常的用户操作路径下异步早已完成。但如果场景变了——比如页面加载时就需要显示 SPORTS 数据——fire-and-forget 就会导致实际错误。日志证明这不是理论推论,是可复现的事实。
queryAllPlans 返回 0 条不能证明时序问题——这是一个重要的教训:PLANS 表本身为空,返回 0 条是预期结果。只有用一个"预期应该有数据但查到空"的查询(queryAllSports),才能证明时序问题。实验设计必须排除干扰因素。
6️⃣ 实操验证记录
| # | 问题 | 现象 | 排查过程 | 解决 |
|---|---|---|---|---|
| 1 | Home() 编译报错 pathStack missing | Home 有 @Link pathStack,MainPage 调 Home() 未传参 | 对比教材源码 | MainPage 加 @State pathStack + onReady,Home({pathStack: this.pathStack}) |
| 2 | 点击目标页没反应 | pushPathByName 调了但 Navigation 不跳转 | 检查 route_map 发现缺少 GoalPage 注册 | route_map 加 GoalPageBuilder 条目 |
| 3 | 仍然不跳转 | MainPage 用 @State pathStack = new NavPathStack(),是空栈 | 对比教材源码发现缺少 .onReady | NavDestination 加 .onReady 注入真实栈 |
| 4 | queryAllPlans 返回 0 条 | 看似时序问题,但 PLANS 表本身为空 | 新增 queryAllSports 排除干扰 | queryAllSports 也返回 0 条,时序问题实锤 |
| 5 | createSportTable 失败 (14800021) | SQL 语法错误 | 检查 SQL 发现缺右括号 | 补)和空格,建表成功(独立bug,与时序无关) |
7️⃣ 最终结论(最佳实践)
| 维度 | 建议 |
|---|---|
| Navigation 路由架构 | 入口页在 main_pages.json 注册,NavDestination 页在 route_map.json 注册,Tab 子组件不注册 |
| route_map | 只注册"需要被 pushPathByName 跳转到"的页面;name 必须和 pushPathByName 的参数一致 |
| pathStack 创建 | 入口页(@Entry)创建new NavPathStack()并绑定Navigation(this.pathStack) |
| 子页获取 pathStack | NavDestination 用@State pathStack = new NavPathStack()占位 +.onReady回调替换为真实栈 |
| 传递 pathStack 给子组件 | 通过构造参数Child({pathStack: this.pathStack}),子组件用@Link接收 |
| @State | 数据拥有者(房东),自带初始值,值变了自动触发 UI 重新渲染 |
| @Link | 数据引用者(租客),无初始值,必须由父组件传入,双向同步 |
| @Link → @State | 编译能过但功能坏了(断开连接,拿到独立副本) |
| @State → @Link | 编译不过(@Link 需要别人传值,没人传就报错) |
| fire-and-forget | 方法内部调异步API但不return Promise → 调用方无法await → 时序不可控 |
| 实验设计 | 排除干扰因素:必须用"预期应有数据但查到空"的查询才能证明时序问题 |
// 入口页:创建 pathStack@Entry@Componentstruct LoginPage{pathStack:NavPathStack=newNavPathStack();build(){Navigation(this.pathStack){/* 首页UI */}}}// NavDestination 页:onReady 获取 pathStack@Componentexportstruct MainPage{@StatepathStack:NavPathStack=newNavPathStack();// 空栈占位build(){NavDestination(){Home({pathStack:this.pathStack})// 传给子组件}.onReady((context:NavDestinationContext)=>{this.pathStack=context.pathStack;// 替换为真实栈})}}// 子组件:@Link 引用 pathStack@Componentexportdefaultstruct Home{@LinkpathStack:NavPathStack;// 双向绑定父组件的 pathStack// this.pathStack.pushPathByName('GoalPageBuilder', null); // 跳转}// @Builder 函数:必须和 route_map.json 中的 buildFunction 一致@BuilderexportfunctionMainPageBuilder(){MainPage()}// route_map.json:只注册 NavDestination 页面// { "name": "MainPage", "buildFunction": "MainPageBuilder" }// name = pushPathByName 的参数,buildFunction = @Builder 函数名核心原则:fire-and-forget 时序隐患已实锤——queryAllSports 应返回 3 条但返回 0 条,证明同步查询在异步操作完成前执行。教材的实现"碰巧没问题"是因为用户操作路径中异步早已完成,不是时序逻辑正确。实验设计必须排除干扰因素(queryAllPlans 返回 0 条是 PLANS 表为空的预期结果,不能证明时序问题)。Navigation 路由架构中,入口页创建 pathStack,NavDestination 页通过 @State + onReady 获取真实 pathStack,子组件通过 @Link 引用。@State 是数据拥有者,@Link 是数据引用者,两者是数据通道而非装饰偏好——@Link 不能随意换成 @State(功能断开),@State 也不能随意换成 @Link(编译不过)。
学习小结:今天解决了 Day 3 的遗留问题 #13——fire-and-forget 的时序隐患。通过两轮实机实验验证:第一轮用 queryAllPlans 无法证明(PLANS 表本身为空是干扰因素),第二轮用 queryAllSports 实锤证实(SPORTS 表应有 3 条但返回 0 条)。最重要的教训:实验设计必须排除干扰因素,用"预期应有数据但查到空"的方式才能证明时序问题。在修复项目路由 bug 的过程中,深入理解了 Navigation 路由架构——入口页、NavDestination 页、Tab 子组件三种角色各司其职,route_map 只注册需要被跳转到的 NavDestination 页面。pathStack 的传递链路是:入口页创建 → NavDestination 通过 @State + onReady 获取 → 子组件通过 @Link 引用。"先占位再替换"模式虽然会触发一次重新渲染,但实际不影响用户体验。理清了 @State 和 @Link 的本质区别——@State 是数据拥有者(房东),@Link 是数据引用者(租客),两者是数据通道而非装饰偏好,不能随意互换。还发现了 SQL 拼接缺少右括号的独立 bug。
懿路向前 · AI辅助整理
2026-07-14