【HarmonyOS学习笔记】2026-07-14 | Day 3 遗留问题验证:fire-and-forget时序实锤 + Navigation路由架构与@State/@Link

发布时间:2026/7/16 8:15:59
【HarmonyOS学习笔记】2026-07-14 | Day 3 遗留问题验证:fire-and-forget时序实锤 + Navigation路由架构与@State/@Link 【HarmonyOS学习笔记】2026-07-14 | Day 3 遗留问题验证fire-and-forget时序实锤 Navigation路由架构与State/Linkdate: 2026-07-14tags: [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.onReadyState空栈占位onReady替换的先占位再替换模式、State与Link数据拥有者vs数据引用者的本质区别为什么不能随意互换。2️⃣ fire-and-forget 时序实验验证Day 3 遗留问题 #13一、问题回顾Day 3 学习中发现 RDBStoreUtil 的createSportTable、initSportTable、createPlanTable三个方法内部调了异步 APIexecute、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.IDp.SPORT_ID3.2 运行日志T0: aboutToAppear 开始 T0: 调用 createSportTable() T0: 调用 initSportTable() T0: 调用 createPlanTable() T1: 调用 queryAllPlans() T1: queryAllPlans 返回 0 条 T2: queryAllPlans() 返回结果数量0 T2: aboutToAppear 结束 ────── 异步回调 ────── T15: batchInsert success, insertNum3 T16: createPlanTable execute success T16: createSportTable execute success3.3 分析——为什么返回 0 条不能证明时序问题PLANS 表本身就没有初始数据initSportTable只往 SPORTS 表插入晨跑/瑜伽/游泳没有任何代码往 PLANS 表插初始数据。所以即使时序完全正确等异步全完成后再查结果也是 0 条。“返回 0 条” PLANS 表空的 预期结果不能作为时序问题的证据。3.4 日志能证明什么日志确实证明了异步回调在同步查询之后才到达T15ms T1ms但仅凭时序顺序不能证明这导致了实际错误。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 运行日志T0: aboutToAppear 开始 T0: 调用 createSportTable() T0: 调用 initSportTable() T1: 调用 queryAllSports() T0ms: queryAllSports 开始执行 T1ms: queryAllSports 返回 0 条: ← 直接查 SPORTS 表0条 T2: queryAllSports() 返回结果 T2: 调用 createPlanTable() T2: 调用 queryAllPlans() T3: queryAllPlans() 返回结果数量0 T13: aboutToAppear 结束 ────── 异步回调 ────── T23ms: batchInsert success, insertNum3 ← 数据在23ms后才插进去 T23ms: createSportTable execute success ← 表在23ms后才建好 T21ms: createPlanTable execute success4.3 分析这次和 queryAllPlans 完全不同查询为什么返回 0 条是时序问题吗queryAllPlans查 JOINPLANS 表本来就是空的❌ 无法判断queryAllSports直接查 SPORTS异步还没执行完表/数据还不存在✅ 实锤如果时序没问题串行执行queryAllSports 应该返回晨跑,瑜伽,游泳3 条数据。但 fire-and-forget 下建表createSportTable和插入initSportTable都还没提交给 SQLite同步查询跑在前面查到了空表。五、两轮实验对比轮次查询方式返回结果能否证明时序问题原因第一轮queryAllPlansJOIN0 条❌ 无法证明PLANS 表本身为空返回 0 条是预期结果第二轮queryAllSports直接查 SPORTS0 条✅ 实锤SPORTS 表应有 3 条初始数据但返回 0 条证明查询时数据还不存在六、实验结论6.1 fire-and-forget 时序隐患已实锤证据链createSportTable()和initSportTable()在 T0ms 被调用立即返回fire-and-forgetqueryAllSports()在 T1ms 同步查询 SPORTS 表返回 0 条createSportTable在 T23ms 才成功batchInsert在 T23ms 才插入 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 容器LoginPagemain_pages.jsonNavDestination 页被 Navigation 管理的子页面通过pushPathByName跳转MainPage、GoalPageroute_map.jsonTab 子组件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) ← 跳转 └→ MainPageNavDestination 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——创建者EntryComponentstruct LoginPage{pathStack:NavPathStacknewNavPathStack();// 普通属性创建实例build(){Navigation(this.pathStack){/* 登录UI */}// 绑定到 Navigation 容器}}为什么不用 StateNavPathStack 是 Navigation 组件内部管理的对象UI 不需要直接响应它的变化Navigation 自己监听栈变化来切换页面所以不需要 State 追踪。2.2 MainPage 的 pathStack——通过 onReady 获取Componentexportstruct MainPage{StatepathStack:NavPathStacknewNavPathStack();// 空栈占位build(){NavDestination(){Tabs(){TabContent(){Home({pathStack:this.pathStack})}TabContent(){Setting()}}}.onReady((context:NavDestinationContext){this.pathStackcontext.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 不需要 pathStackGoalPage 只做数据展示和操作不需要跳转到其他页面所以不声明 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 是引用别人的数据租客。StateLink角色数据的拥有者数据的引用者初始值必须自己给没有初始值必须由父组件传入谁管理数据自己引用的 State 那一方子组件能改吗N/A自己就是拥有者能改动双向同步回 State编译要求自给自足总能编译通过必须有人传值否则编译报错建立连接的方式——父传子// 父组件Statecount:number0;Child({count:this.count})// 传参建立连接// 子组件Linkcount:number;// 变量名必须和传参的 key 一致Link 不能随意换成 State方向编译功能Link → State✅ 编译能过❌ 功能坏了——State 自给自足拿到的是独立副本假栈和父级断开连接State → Link❌ 编译不过— Link 需要别人传值没人传就报错在 pathStack 场景中如果 Home 把 Link 换成 StateHome 就拥有了一个独立的空栈和 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️⃣ 实操验证记录#问题现象排查过程解决1Home() 编译报错 pathStack missingHome 有 Link pathStackMainPage 调 Home() 未传参对比教材源码MainPage 加 State pathStack onReadyHome({pathStack: this.pathStack})2点击目标页没反应pushPathByName 调了但 Navigation 不跳转检查 route_map 发现缺少 GoalPage 注册route_map 加 GoalPageBuilder 条目3仍然不跳转MainPage 用 State pathStack new NavPathStack()是空栈对比教材源码发现缺少 .onReadyNavDestination 加 .onReady 注入真实栈4queryAllPlans 返回 0 条看似时序问题但 PLANS 表本身为空新增 queryAllSports 排除干扰queryAllSports 也返回 0 条时序问题实锤5createSportTable 失败 (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)子页获取 pathStackNavDestination 用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 → 时序不可控实验设计排除干扰因素必须用预期应有数据但查到空的查询才能证明时序问题// 入口页创建 pathStackEntryComponentstruct LoginPage{pathStack:NavPathStacknewNavPathStack();build(){Navigation(this.pathStack){/* 首页UI */}}}// NavDestination 页onReady 获取 pathStackComponentexportstruct MainPage{StatepathStack:NavPathStacknewNavPathStack();// 空栈占位build(){NavDestination(){Home({pathStack:this.pathStack})// 传给子组件}.onReady((context:NavDestinationContext){this.pathStackcontext.pathStack;// 替换为真实栈})}}// 子组件Link 引用 pathStackComponentexportdefaultstruct 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 路由架构中入口页创建 pathStackNavDestination 页通过 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