TroilOryan

harmony-app-template

[MANDATORY] 基于 HarmonyAppTemplate 基建模板快速搭建与扩展鸿蒙 App(HarmonyOS / ArkTS / ArkUI)。 触发词:鸿蒙/鸿蒙App/HarmonyOS/ArkTS/ArkUI/ETS/Stage 模型; 新建项目/脚手架/模板工程/初始化/开工; 新建页面/新增子页/加个Tab/HdsTabs/HdsNavigation/路由跳转/push/路由栈; 设置页/设置项/持久化开关/preferences/存本地/存盘; 弹窗/底部弹窗/bindSheet/确认弹窗/AppSheet/宽屏dialog; 主题/深浅色/暗色模式/自定义背景/毛玻璃/GlassCard; 安全区/状态栏/全屏/挖孔/SafeArea/标题栏; 图标/SFIcon/加个图标、运行日志/AppLogger/更新日志; hvigor 报错/safe-delete/编译不过/改了没生效

TroilOryan 0 Updated 2w ago

Resources

1
GitHub

Install

npx skillscat add troiloryan/icey-harmony-skill/harmony-app-template

Install via the SkillsCat registry.

SKILL.md

鸿蒙 App 模板基建 v1.4.1

📦 本 Skill 自带模板工程副本(assets/template/,79 文件 / 1.2MB)——
整个 Skill 目录拷到任何机器都能直接起新鸿蒙 App,不依赖外部工程。

一份生产项目实战沉淀的 HarmonyOS / ArkTS 应用脚手架方案:
新建/扩展鸿蒙 App 时,不要从空工程起步,而是复用「已验证基建」——
HDS 导航/标题栏、路由、弹窗、设置持久化、主题、安全区、日志七件套一次到位,
避免在每个新项目里重复踩同一批坑。

本 Skill 是什么:一套「读模板 → 按工作流剪裁 → 按铁律改代码」的操作规程。
不是什么:不是 ArkUI 语法教程 —— 语法查官方文档,这里只放模板特有的决策与坑。


⚡ 快速开始(30 秒选模式)

用户要什么?
├─ 🆕 从零起一个新鸿蒙 App
│    └─→ W1 新建项目(复制内置模板 → **先修附录 C 的 D1~D9** → 改 bundleName → 对齐本机 SDK → 按清单裁剪)
│
├─ ➕ 在已有工程(通常已是本模板)里加东西
│    ├─ 加一个底部 Tab            → W2
│    ├─ 加一个子页 + 路由跳转      → W3   ★最常用
│    ├─ 加一个持久化设置项         → W4   ★最常用
│    ├─ 加一个弹窗(底部/确认/选项)→ W5
│    ├─ 加一个图标                → W6
│    └─ 接自定义背景 / 毛玻璃      → W7
│
└─ 🔧 出问题了
     ├─ hvigor / 编译报错          → W8 + §4 坑点速查表
     ├─ 界面表现不对(留白/错位/颜色)→ §2 铁律 #11~#16 + §4
     └─ 「改了没生效」              → 先看 LSN-002(改错了地方 / 缓存)

💡 提醒:如果你要的功能模板里没有(比如视频播放、网络层),本 Skill 负责保证
它长在正确的骨架上(scaffold/sheet/setting/router 四件套),不含业务实现。


0. 第一步:定位模板源(必做)

改动必须以真实模板为准,禁止凭记忆写代码。本 Skill 自带模板副本(assets/template/),
换机器开箱即用,按三级顺序定位:

① 内置副本(首选,可移植)   <skill>/assets/template/
   └─ 79 个文件 / 1.2MB,含全部 34 个 .ets 与工程配置
   └─ 新建项目(W1)直接用它;改它之前先想清楚:改的是「模板资产」还是「用户工程」

② 环境变量(团队/老机器自定义源)   $HARMONY_TEMPLATE_HOME

③ 用户当前工程(扩展已有 App 时)
   └─ 判定是否为模板衍生:同时存在 layout/Index.ets + model/AppRouter.ets +
      components/SubPageScaffold/ = 是,按本 Skill 规程改
   └─ 不是模板衍生 → 先按 W1 用 ① 起工程,或只借鉴写法(此时铁律仍适用,路径全部换成用户工程)

W1 与 W2~W8 的区别:W1 从 ① 复制;W2~W8 改的是 ③ 用户当前工程。
不要直接改 ① 内置副本 —— 那是模板资产,改了会污染以后所有新项目。

定位成功后必须先读这 5 个文件再动手(它们是所有决策的依据):

顺序 文件 你从中获得什么
1 entry/src/main/ets/layout/Index.ets 主布局结构、路由分发点、Tab 列表
2 entry/src/main/ets/entryAbility/EntryAbility.ets 初始化顺序、全屏/SafeArea、前后台信号
3 entry/src/main/ets/model/SettingsManager.ets 持久化写法的唯一范本
4 entry/src/main/ets/model/ThemeManager.ets 色彩 @StorageLink 版本号机制
5 README.md(模板根) 完整铁律原文(122 条经验,本 Skill 取其纲要)

⚠️ 路径铁律:模板路径可能随机器/磁盘变化。所有输出的路径必须是本次定位到的真实路径,
禁止沿用上一次会话记忆里的盘符。Skill 自身目录也要先定位再引用(常见位置:
~/.workbuddy/skills/harmony-app-template/),不要假设它在某个固定盘符下。详见 LSN-005。

内置副本内容清单与排除项见 assets/TEMPLATE_MANIFEST.md。


1. 模板提供了什么(能力清单)

层 能力 关键文件(均在 entry/src/main/ets/)
导航 HdsNavigation + HdsTabs 悬浮底栏、Tab 瞬时切换、全 Tab 预加载 layout/Index.ets
标题栏 滚动渐变模糊(GRADIENT_BLUR + ADAPTIVE 材质),主页/子页/详情页三套配置 model/TitleBarStyles.ets
子页 一行代码建子页(HdsNavDestination + 标题栏 + 背景 + 宽屏 embedded)+ 标题栏内扩展区(titleBottomBuilder,放搜索框/页签) components/SubPageScaffold/
路由 全局单例 NavPathStack + 重复页拦截 + 携带参数 model/AppRouter.ets
安全区 全屏后手动避让,含挖孔合并、旋转/avoidAreaChange 动态刷新 model/SafeArea.ets
持久化 preferences 增量写 + 300ms 防抖 + saveNow 双保险 + 材质方法组(materialAvailable/materialFor/materialSystem/materialTinted/systemMaterialStyleFor/systemMaterialColor) model/SettingsManager.ets
主题 跟随系统/强制深浅/自定义背景 + 版本号驱动全局重建 + cardDivider(卡内淡分割线)+ vipGold model/ThemeManager.ets
弹窗 AppSheet(手机底部/宽屏居中 dialog 自适应)+ 同组互斥 + 退后台自动关 components/AppSheet/、services/SheetManager.ets
弹窗内容 SheetContainer(限高+Scroll)、ConfirmSheet(danger)、OptionSheet(选项) `components/SheetContainer
设置 UI SettingItem(开关/值/下拉菜单/导航四种形态)/ SettingsGroup(主页导航卡,边距靠父容器)/ SettingsGroupCard(子页分组卡,边距自带,标题在卡外) / MaterialLayer(光感承载层)/ SFIcon(SVG 图标)/ AppSwitch components/*
图标资源 42 个 SFIcon SVG(sf_xxx 常量表在 sficon_names.ets);页面级功能图标另用系统 SymbolGlyph(LSN-020/035 分工) entry/src/main/resources/base/media/*.svg
日志 内存环形缓冲 500 条 + 调试模式落盘 Download + 导出 picker services/AppLogger.ets
其它 全局 Context、系统栏 Helper、更新日志、文件授权、官方符号图标体系、沉浸光感材质(services/SystemMaterial + components/MaterialCard:卡片 / hero 沾色 / 弹窗 / chips 四形态 + 降级毛玻璃 + 诊断行)、自定义壁纸(ThemeManager.setCustomBg + 相册选择器 + 三链路状态重放) services/*、components/MaterialCard/

完整文件职责表见 references/file-map.md。


2. 核心铁律(违反必出 Bug)

全部来自真机验证。每条都是「不这么做 → 就会出现某个具体现象」。

启动与恢复

# 禁止行为 后果 正确做法
00 拿内置副本当工程直接改 / 新建项目后不修缺陷 改坏模板资产(污染后续所有新项目);或首次构建必然失败(D1/D2/D5/D6/D7/D8) 新建项目一律 cp -r 出去再改;复制后第一件事是按附录 C 把 D1~D9 全扫一遍(详见 LSN-013)
01 loadContent 里 await startupReady 数据恢复耗时可观 → 白屏久驻 UI 优先,永不阻塞:立即 loadContent,恢复后台跑完写 AppStorage 响应式刷新
02 多个 init() 不各自包 try-catch SettingsManager 挂了 → 所有设置静默丢失 每个 init 独立 try{}catch{},一个失败不中断后续
03 初始化顺序随意 依赖设置的模块读到默认值 SettingsManager.init 必须最先
04 只靠 300ms 防抖落盘 防抖未到期被杀进程 → 设置丢失 onBackground / onDestroy 必须 saveNow()

布局(最高频出错区)

# 禁止行为 后果 正确做法
05 页面根 Column 用 height('100%') 在 Hds TabContent 内溢出约 50vp 用 .layoutWeight(1)
06 layoutWeight(1) + justifyContent(SpaceBetween) 联用 子项高度异常、内容离屏 二者禁止同用
07 顶部留白硬编码 112 换机型/折叠屏错位 Row() {}.height(SafeArea.top + 72)(状态栏 + MINI 标题栏 + 间距)。
⚠️ 若该页用了 titleBottomBuilder(标题栏内搜索框区),还要再加那个区的高度(如 +56),否则首项被标题栏盖住
08 内容顶部加固定空白 Column 做避让 内容被滚动模糊带压住 List 视口顶到屏幕顶,留白放第一个 ListItem,内容自然滑进模糊带
09 List 尾部不预留 barOverlap 悬浮底栏遮住最后几项 末 ListItem Row() {}.height(SafeArea.bottom + 80)(子页 +40)
10 卡片用 width('100%') 同时又给横向 margin 溢出 → 边距不生效 横向间距归属看是哪张卡(详见 LSN-032):
· 子页 SettingsGroupCard → 组件自带 16,父级不许再给
· 主页 SettingsGroup → 组件只有 margin({bottom:16}),靠父容器 padding
10b 子页 List 再套 padding({left:16,right:16}) 与 SettingsGroupCard 自带的 16 叠加成 32vp 双倍内缩 子页 List 不设左右 padding(同 LSN-032 铁律①)
10c 卡内分割线用 colors.divider / 末项后再插 Divider 玻璃卡上"一条条白线" / 尾部线贴圆角下缘 用 colors.cardDivider(淡一档);末项之后严禁再插

标题栏渐变模糊(四处配套,缺一不可)

# 组件/位置 必须做的配置 缺了会怎样
11 标题栏配置对象 avoidLayoutSafeArea: true 标题文字被状态栏压住
12 HdsNavigation / SubPageScaffold .ignoreLayoutSafeArea([0], [0])(SYSTEM=0/TOP=0) 状态栏后面是一条白/黑带
13 根 Stack .expandSafeArea([SYSTEM],[TOP,BOTTOM]) + .backgroundColor(ThemeManager.colors.background) 顶部露出裸白条
14 滚动蒙层高度 maskExtraHeight: 28(内容短的页调小) 模糊带盖住卡片顶部

记忆口诀:标题栏避让 + 组件 ignore + 根节点 expand + 内容留白 = 四处配套。

HDS 组件

# 禁止行为 后果 正确做法
15 不逐层设透明 Hds 默认背景层压暗透出的自定义背景图 TabContent / HdsTabs / HdsNavigation 全部 .backgroundColor(Color.Transparent)
16 低 API 设备用 Hds 枚举名 闪退(release 打包无导出) 仅限 Hds 自有枚举:GRADIENT_BLUR=2、HdsNavigationTitleMode.MINI=2、HdsNavDestinationTitleMode.MINI=100、SYSTEM=0/TOP=0。⚠️ 系统 ArkUI 枚举(NavigationMode/BarMode/BarPosition/TabsCacheMode)必须用枚举名,数值字面量会踩错值——真机实证:.mode(1) 意图 Stack 实为 Split=1(Stack=0)→ 整页压左半屏右侧空白;TabsCacheMode.CACHE_BOTH_SIDE=0(写 1 是 CACHE_LATEST_SWITCHED)。拿不准就查 SDK d.ts 的枚举数值,别凭记忆写
17 设置页不复位滚动位置 内容短不可滚时模糊带常显(像一直在滚) aboutToAppear 里 scroller.scrollToIndex(0, true)
18 Tab 切换动画用默认 用户感知「点了要等」 .animationDuration(0) 瞬时切换 + cachedMaxCount(TAB_COUNT, TabsCacheMode.CACHE_BOTH_SIDE) 预加载

主题

# 禁止行为 后果 正确做法
19 以为 ThemeManager.colors 会响应式刷新 改了主题界面不变 colors 是 static 非响应式;组件必须 @StorageLink('themeColorsVersion')(或用 @Prop themeVersion 从父级传入),版本号 +1 后重读
20 弹窗背景用 ThemeManager.colors.background 自定义背景时该色是半透明 → 弹窗能看见底层内容 弹窗一律 ThemeManager.opaqueBackground
21 判深色写 colorMode === 2 深浅判定完全反了 DARK = 0(LIGHT=1、NOT_SET=-1)

生命周期

# 禁止行为 后果 正确做法
22 在 @Entry 页面组件里写 onConfigurationUpdate 根本不会被调用(非标准回调) 只能在 UIAbility 级实现
23 在 onConfigurationUpdate 里调 setColorMode 再次触发回调 → 无限循环几百次 只刷新 ThemeManager + 系统栏字色
24 applyColorMode 只调一次 窗口未建时首帧材质按错误模式解析 调两次:onCreate + onWindowStageCreate
25 不监听 avoidAreaChange 状态栏显隐不触发 windowSizeChange → SafeArea.top 残留旧值 windowSizeChange + avoidAreaChange 都监听
26 只取 TYPE_SYSTEM 算左右安全区 横屏挖孔在左右缘却没避让 左右取 max(TYPE_SYSTEM, TYPE_CUTOUT)
27 复位 appBackgrounded 版本号 依赖 +1 增量的 @Watch 永不再触发 版本号只增不减;「当前是否在后台」用布尔 appInBackground
28 依赖非 @Entry 组件的 onBackPress 不会被系统调用 统一在 App.ets 的 onBackPress 里判断状态 → 广播版本号信号 → 目标组件 @Watch 消费

弹窗

# 禁止行为 后果 正确做法
29 多个弹窗不设 group 两层蒙层叠压 同页传同一 group(Index 根级用 'index',页面级用 'page:xxx');嵌套弹窗留空/换组
30 bindSheet/bindMenu 挂在自定义组件外层 点击不生效 挂原生组件(Row/Column/Stack)上
31 onDisappear 里立即 show=false 打断关闭动画 →「回弹一下才关」 延迟 300ms;且延迟期内若 show 已是 true(用户重开)不能再设 false
32 宽屏 dialog 不处理退后台 回前台弹窗残留 AppSheet 已内置 appBackgrounded 订阅;自定义弹窗必须自己加

状态管理

# 禁止行为 后果 正确做法
33 用 AppStorage 当持久化 重启即丢(它是内存态,≠PersistentStorage) 持久化一律走 SettingsManager(preferences)
34 声明了 @StorageLink 却在 build 里没引用 值变了不触发重建 跨层传递靠 @Prop themeVersion 显式传参
35 用布尔值做变更信号 同值 set 不触发 @Watch 用版本号信号(每次 +1 恒触发)
36 嵌套括号三元 a ? (b ? x : y) : z 老 es2abc 解析器报 ',' expected 必须纯链式三元(用 && 拍平并列分支)
38 在 @Builder 里写非 UI 语句(const d = this.calc() / if 语句块) 编译错 10905209 Only UI component syntax can be written here 计算逻辑抽成 private 方法(返回文案/值),Builder 内只留组件调用;条件用组件级 if (...)(ArkUI 的 if 是合法 UI 语法)

构建

# 禁止行为 后果 正确做法
37 并行构建 / 构建中编辑文件 hvigor [safe-delete] 缓存损坏 删 entry/build/default/cache/default/default@CompileArkTS,前台串行 --no-daemon 重建;本项目构建必须前台串行。「改了没生效」加强:改源码后 CompileArkTS 仍 UP-TO-DATE 时,删 entry/build/default/cache + .hvigor/report 可能仍不够——再删 entry/build/default/intermediates/loader* 并 touch 源文件才能强制重编(Icey-Days 实证,同一会话内连续踩两次)
38 只在 signingConfigs 数组里配签名材料,不在 products[] 里引用它 产出 entry-default-unsigned.hap,真机安装报 error:9568320 no signature file(2026-09-16 实测,白折腾一轮) 两处都要改:app.signingConfigs[](材料)+ app.products[].signingConfig: "default"(引用)。模板里 signingConfigs 默认为空数组,配签名时才新增,故不要给模板预设引用(空数组时会报引用不存在)(见 LSN-017)
39 改完 bundleName 直接 Run,不 Sync IDE 缓存 .idea/.deveco/project.cache.json 的 BUNDLE_NAME 未更新 → 日志仍 Launching <旧包名>,装了个不存在的包 改 app.json5 后必须 Sync / 重启 DevEco;并注意 p7b 描述文件绑定 bundle-name,改包名即证书失效需重申请(见 LSN-017)

3. 工作流索引

完整可复制代码见 references/workflows.md,此处只给决策要点。

编号 场景 涉及文件 关键点
W1 从模板新建项目 复制 assets/template/ → build-profile.json5、AppScope/app.json5、string.json、Index.ets 先按附录 C 修 D1~D9(否则编不过) → 改 bundleName → targetSdkVersion 对齐本机 sdk-pkg.json 的 apiVersion → DevEco 关联 SDK → 按裁剪清单删模块
W2 加一个底部 Tab layout/Index.ets Tab 数 +1、tabTitles、TabItemBuilder、注意 2<N≤5
W3 加一个子页 + 路由 新文件 + AppRouter.ets + Index.ets 四步:建组件 → 加 ROUTE 常量 → navDestinationBuilder 分发 → push
W4 加一个持久化设置项 SettingsManager.ets + UI 五步(漏第 3 步 = 重启丢失,见 LSN-003)
W5 加一个弹窗 AppSheet/ConfirmSheet/OptionSheet 按用途三选一;group、dismissible、不透明背景三要素
W6 加一个图标 无需新增任何文件(用系统符号) 从 id_defined.json 检索 "type": "symbol" 选名 → 直接 SymbolGlyph($r('sys.symbol.xxx')).fontSize(22).fontColor([...])。禁止自研 SVG 图标组件(见 LSN-020)
W7 给卡片/控件加沉浸光感 services/SystemMaterial.ets + components/MaterialCard/(模板已内置) 卡片改 MaterialCard{...};小控件 Stack{MaterialCardLayer+控件};hero 卡用「材质+半透明沾色」;弹窗配 bindSheet backgroundColor:Transparent。加「强制光感」开关走 SettingsManager 五步(键 settings_forceMaterial)。⚠️ 材质不生效不报错,必须真机截图验证(见 LSN-022)。实色大卡改多卡(表单 / 详情页多卡片化)见 LSN-025(控件描边 / 属性链位置 / 分卡边界)
W8 加自定义壁纸 复用 ThemeManager.setCustomBg + FilePermission PhotoViewPicker.select(字段是 photoUris)→ SettingsManager.setCustomBgUri(uri) → ThemeManager.setCustomBg 背景半透明 → 根 Stack 底层 Image.objectFit(Cover).expandSafeArea。三处链路重放状态(App 启动 / onConfigurationUpdate / 设置变更)(见 LSN-023)
W7 接自定义背景/毛玻璃 Index.ets + ThemeManager hasCustomBg 走 applyThemeMode;卡片自行判定 mao玻璃 or 实色
W8 排障 — 五层模型:源码 → 编译产物/缓存 → 工具 → 环境 → 改动是否为文件真身

所有工作流的共同收尾动作(不可跳过):

  1. 前台串行构建一遍(#37)
  2. 真机跑一次并 hilog 确认 TAG 输出(每个模块都有 const TAG)
  3. 若涉及既有判断逻辑的改动 → 全局 grep 同模式引用,改全(LSN-001)

4. 坑点速查表(想实现 X → 错写法 → 对写法)

想实现 ❌ 常见错误写法 ✅ 模板正确写法
页面占满剩余高度 .height('100%') .layoutWeight(1)(Hds TabContent 内 100% 溢出 50vp)
顶部避让标题栏 Blank().height(112) Blank().height(SafeArea.top + 72)
读取当前主题色 定义 @State color 缓存 colors 值 每次 build 直接读 ThemeManager.colors.xxx,版本号驱动重建
弹窗背景色 ThemeManager.colors.background ThemeManager.opaqueBackground
判断深色模式 colorMode === 2 colorMode === 0(DARK=0)
push 子页 Navigation() 新建一套栈 / 层层透传 stack AppRouter.getInstance().push(ROUTE_XXX)
子页读参数 子页 @StorageProp('xxx') 读初值 AppRouter.getLastParam() + aboutToAppear 读(时序可靠)
加一个开关 组件内 @State @StorageLink('settings_xxx') + SettingsManager.setXxx()
组件上弹菜单 .bindMenu() 挂自定义组件外 挂原生 Row 上(见 SettingItem 的 withMenu 分支)
子页分组卡片对齐 List 套 padding({left:16,right:16}) 不套 —— SettingsGroupCard 自带 16,套了变双倍内缩 32vp
主页导航卡对齐 给 SettingsGroup 自己加 margin 父容器(ListItem)给 padding(它 width('100%'),横向 margin 会溢出)
卡内行分割线 ThemeManager.colors.divider ThemeManager.colors.cardDivider(淡一档,否则玻璃卡上像"压着白线")
搜索框与标题栏对齐 搜索框放内容区 List 里 放 SubPageScaffold({ titleBottomBuilder: this.searchBar, titleBottomHeight: 56 }),列表留白再加 56
设置项下拉菜单 menuOptions: string[] + onMenuPick @BuilderParam menu + menu: this.xxxMenu(传 builder 引用)
菜单项点击写状态 onClick(() => { this.setXxx(0) }) 调模块级函数(bindMenu 闭包内 this 未绑定 → 真机闪退)
列表项图标 iconSymbol: $r('sys.symbol.xxx') icon: SFIcons.sf_xxx(SVG 名,先 grep 确认常量存在)
App 图标分层比例 直接用上游 ic_launcher_foreground.png 的内容占比(如 45.8%) 量上游「成品」ic_launcher.png 里 logo 占整图的比例(如 53.1%)—— 前景层会被 Android 再缩放到安全区,占比必然偏小
卡片标题位置 标题画在卡内顶部 padding.left:12 标题移到卡外,padding.left 改 0
关闭动画流畅 onDisappear 立即 show=false 延迟 300ms + 「期间用户重开则不覆写」竞态保护
底栏中央「+」大按钮 隐藏自带 bar(barHeight(0))+ 自绘 customTabBar + 外层 Stack 叠加 5 页签方案:「+」= 空 TabContent 占位(如 index=2)+ onContentWillChange 对该 index 返回 false 阻止切换 + onTabBarClick 弹弹窗——全程 HdsTabs 标准 API,无叠加层(用户实测否决自绘方案)。对齐细节:其他页签 padding({bottom:4}) 使重心偏上,红钮需同级 padding(不够再 +2 微调)
日期选择(公历/农历) 手写三列滚轮(List+spacer+onScrollStop 回写索引,易索引/值错位)+ 手动闰月开关 getUIContext().showDatePickerDialog({start, end, selected, lunar, lunarSwitch: false, onDateAccept});单一公历事实源,口径 chip 弹窗外控制;onDateAccept 只回公历 Date(见 LSN-011)
跑马灯「滚完暂停 N 秒」 指望 Marquee 自带暂停参数(没有)、loop=-1 一直滚、或 .key(轮次) 变更重建(普通组件上的 key 被忽略,告警「key 只能用于测试目录」,Marquee 停在 loop 结束态不再滚) if 卸载/重挂载:暂停阶段渲染静态 Text(显示开头),滚动阶段挂载 Marquee({start:true, loop:1}),onFinish → 切回暂停并 setTimeout N 秒后重新挂载,循环往复;组件销毁 clearTimeout
让组件重新初始化 改 .key() 期望重建 普通组件 .key() 无效(仅 ForEach/if 的 diff key 生效);用 if (条件) 卸载再挂载
弹窗配色随主题 官方弹窗默认蓝选中项 #ff007dff showDatePickerDialog 支持 selectedTextStyle/disappearTextStyle/textStyle(PickerTextStyle: color+font{size,weight})+ acceptButtonStyle/cancelButtonStyle(API 12+),全部走 ThemeManager.colors
bindSheet 弹窗带预填打开 @Prop 传 class 实例(@Prop 同步与 @Watch 顺序不保证,惰性构建首开不触发) AppStorage 信号:宿主先写 pendingXxx* 字段 + 版本号 +1,弹窗 @StorageLink+@Watch 回填,读后即清
子页请求宿主弹窗 子页自己挂 bindSheet(违反单组件 1 bindSheet;且子页 pop 后弹窗随之销毁) 子页写 AppStorage 信号(版本号 +1)后 pop;宿主页 @StorageLink+@Watch 收信号再置 show=true——弹窗始终单挂宿主根 Stack
保存自定义文件 picker.save() DOWNLOAD 模式 直写 Download/<包名>/ 路径(绕开 13900002),见 AppLogger.downloadDirPath
fileShare 传路径 直接传 /storage/... 先 fileUri.getUriFromPath() 转 file://docs/...(否则 code=3 Invalid path)
批量文件授权 一次性传全量 URI 分批 200 条 + 处理 13900001 的 err.data 部分失败明细
注册字体 在 onCreate 里注册 必须在 loadContent 回调里注册
Service 层打日志 console.log AppLogger.info/warn/error(console 不进用户导出日志)
列表顶部/尾部留白 ListItem() { Blank().height(SafeArea.top + 72) }(编译失败:Blank 只能嵌在 Row/Column/Flex) ListItem() { Row() {}.height(SafeArea.top + 72).width('100%') }
给子页挂 bindSheet SubPageScaffold({...}) { ... }.bindSheet(...)(编译失败:build 被解析成两个根节点) 把 bindSheet 挂到内容里的原生组件上:SubPageScaffold({...}) { List(){...}.bindSheet(...) }
声明 SDK 版本 "targetSdkVersion": "26.0.0"(构建直接失败 00306042) "6.1.1(24)" —— 先读 <DevEco>/sdk/default/sdk-pkg.json 的 apiVersion,格式必须 x.y.z(api)
写 Hds 标题栏 menu opts.content.menu = {...}(可能是 undefined,编译报错) const c = opts.content; if (c !== undefined) { c.menu = {...}; }
多个 @Watch 顺序 @StorageLink('k') @Watch('cb') x(静默失效) @Watch('cb') @StorageLink('k') x(Watch 恒在前)
卡片左右内缩 卡片自己挂 width('100%') 加 .margin({left:16,right:16})(编译通过但被裁:margin 在尺寸之外,总占位 = 父宽 + 32,右端越界被 List 切直角 —— 用户会报"显示不全") 横向内缩只由宿主 List 的 .padding({left:16,right:16}) 提供;卡片保留 width('100%')、去掉横向 margin,只留 .margin({top:12})(见 LSN-014)
List 子项里的横向滚动条(筛选 chips) Row(){ Scroll(){ chips } .layoutWeight(1) } 不给 Scroll 设高(ListView 子项内高度不确定 → 塌成 0 或撑高整行) Scroll 显式 .height(36) + .scrollable(ScrollDirection.Horizontal) + .scrollBar(BarState.Off)
给子组件传列表数据渲染 @Prop items: AssetItem[](含方法的类实例经 @Prop 深拷贝后方法丢失,且违反"UI 只读不重算") 在 model 侧聚合成不含任何方法的展示型数据类(比例、文案都算好),子组件 @Prop 只收纯数据(见 LSN-015)
给 SettingItem 配下拉菜单 跨组件传 @Builder:SettingItem({ withMenu:true, menu: this.themeMenu })(能编译、菜单能展开、点菜单项才崩:builder 执行时 this 被重绑成子组件实例,this.xxx() → TypeError: undefined is not callable;同时 .selected(this.xxx) 恒 false、打钩静默失效) 配置化而非调用方塞 @Builder(沿用 SheetContainer 的既有约定):传 @Prop menuOptions: string[] + @Prop menuSelected: number + 普通函数属性 onMenuPick: (i:number)=>void;Menu/MenuItem 在 SettingItem 内部用 ForEach 构建(见 LSN-016)
排查"跨组件 @Builder 是否丢了 this" 只验菜单能否弹出(能弹就以为没问题) 两类症状成对出现,必须同时验:① 点菜单项报 undefined is not callable;② .selected() 打钩永不显示。反证法:同一处"只调用模块级导入"的 builder(如 SettingsManager.xxx())不崩 → 丢的是 this,而非 API 不支持
让 DevEco 能装到真机 只在 build-profile.json5 的 signingConfigs 数组里配好签名材料(声明 ≠ 使用) 还必须在 products[].signingConfig 里引用它(如 "signingConfig": "default");否则产出 entry-default-**unsigned**.hap,安装报 error:9568320 no signature file(见 LSN-017)
改完 bundleName 后重新 Run 只改 AppScope/app.json5 就 Run(日志仍 Launching <旧包名>) 三步缺一不可:① 改 app.json5;② 在 DevEco Sync / 重启(.idea/.deveco/project.cache.json 缓存了 BUNDLE_NAME);③ 重新申请绑定新包名的调试证书(p7b 内嵌 bundle-name,改包名即失效)(见 LSN-017)
给 UI 打"启动完成"标记 AppStorage.set('dataLoaded', true)(键不存在时静默落空)+ App.aboutToAppear 无条件 setOrCreate('dataLoaded', false)(会覆盖已置的 true) 写入方一律 setOrCreate;建键方只在键不存在时初始化(if (get(...) === undefined));再配一个 300ms 自愈兜底(判断仓库 isReady())。三者缺一,恢复够快时列表页就永久停在「加载中…」(见 LSN-018,模板 App.ets/EntryAbility.ets 原样自带此缺陷 D10)
在 @Builder 里显示会变化的值 this.valueRow(AssetMath.formatDateCn(this.effectivePurchaseDate()), ...) —— 按值传参,状态变化不刷新 Builder 内部 UI:编译通过、值也存对了,只是界面不动,重开才见新值(同理:分段控件 selected 高亮不动、开关行 isOn 不回显) 三选一:① 内联到 build()(最稳,首选);② 传对象字面量走按引用传参(class P{v: string=''} + this.row({ v: this.someState }));③ 交给子组件 @Prop。纯静态文案可继续按值传(见 LSN-019)。⚠️ 别用"保存后重开"验证 —— 值本来就对,会误判
给 UI 加图标 自研 SVG 图标组件 + resources/base/media/*.svg + 名字常量表(SFIcon 那套:要维护几十个 SVG、要分 normal/fill 两套、还无法随系统字体缩放) 直接用官方符号:SymbolGlyph($r('sys.symbol.xxx')).fontSize(22).fontColor([color])。选名查 id_defined.json 的 "type": "symbol"(本机 4042 个);选中态用配对 xxx / xxx_fill。⚠️ $r() 只能写字面量,通用组件要接 Resource(见 LSN-020)
写 compatibleSdkVersion / targetSdkVersion 一律写 "6.1.1(24)" 或一律写 "26.0.0" —— 两种写法各有适用区间,写错直接构建失败 按 API 级别分界:API < 26 用旧式 x.y.z(api)(如 "6.1.1(24)");API ≥ 26 用点分制 major.minor.patch(如 "26.0.0")。给 26 加 (26) 报 00308018;26 之前不写 (api) 报 00306042(见 LSN-021)
升级 SDK 版本 只改 compatibleSdkVersion(或只改 targetSdkVersion)就以为完事 两个字段一起改,并保证 compatibleSdkVersion ≤ targetSdkVersion ≤ compileSdkVersion。改半截(如 compatible 改 26、target 还留 24)→ IDE sync 报 00303015 Configuration Error。⚠️ 该错误 CLI 构建可能不报(compileSdkVersion 由 IDE 解析的 SDK 决定)⇒ --sync 与 assembleHap 必须各验一次(见 LSN-021)
给卡片加沉浸光感 直接给卡片 .backgroundBlurStyle(Thin) 或给外壳 backgroundColor 设材质 卡片外壳 = Stack{ MaterialCardLayer + Column{内容} }(模板 components/MaterialCard)。承载层只能用 ToggleType.Button(Checkbox 未适配;Switch 只覆盖开关本体),五条属性缺一不可:LayoutPolicy.matchParent(百分比会顶大卡片)/ borderRadius 同外壳 / backgroundColor(Transparent) / enabled(false)+focusable(false)+accessibilityLevel('no')+hitTestBehavior(None) / attributeModifier 放链末。应用级前置 module.json5 metadata ohos.arkui.UIMaterial.state="enable"(模板已自带,改后须重装)。⚠️ 材质不报错不失效提示,必须真机截图验证(见 LSN-022)
光感材质画出来了但不刷新/不显示 ① attributeModifier 写成内联对象字面量(拨开关后已画卡片不更新)② 外壳/承载层有不透明底色(材质层级在 backgroundColor 之下,被盖住)③ interactive:true 配空 lightEffect:{}(材质完全不渲染)④ bindSheet 不设透明底(默认白底整块盖住) ① modifier 写成方法调用并读订阅变量;② 材质开时底色一律 Color.Transparent,关闭时回实色;③ 无流光色时省略 lightEffect 字段;④ bindSheet(..., { backgroundColor: Color.Transparent })。材质不可用时降级:半透明底色 + backgroundBlurStyle(Thin),缺半透明底色则模糊被盖住(见 LSN-022)
加自定义壁纸(选图作背景) ① result.uris(PhotoSelectResult 没有 uris 字段,直接编译报错)② 对象字面量不标类型(arkts-no-untyped-obj-literals)③ 改了壁纸但重启后背景变回不透明 正确写法:const opts: photoAccessHelper.PhotoSelectOptions = {...}(显式类型)→ result.photoUris[0](字段名 photoUris,class 定义在 openharmony 侧 @ohos.file.photoAccessHelper.d.ts)→ SettingsManager.setCustomBgUri(uri) 联动 ThemeManager.setCustomBg。picker 返回的 URI 自带永久授权(persist 仅保险)。⚠️ 三处链路必须重放壁纸状态:App 启动 / onConfigurationUpdate(updateFromConfig 重算 colors 会打回不透明)/ 设置变更(见 LSN-023)
把实色卡片换成光感 / 玻璃卡 卡内控件仍用 surface 浅填充 → 与玻璃卡面几乎同色,「输入框 / 开关行 / 分段控件」看不出边界;surface 次级按钮直接贴在页面背景上 → 浅色下 #F2F3F4 vs #F5F5F5 几乎看不见按钮 控件加 0.5vp 发丝描边(复用 colors.fieldBorder,实色填充保留 ⇒ 文字对比度不受材质影响);次级按钮留在卡片内(见 LSN-025)
给已有嵌套组件插卡片边界 内层容器与外层组件两个 } 都写完了才写 .width()/.padding() → 10505001 Declaration or statement expected + Cannot find name 'width',报错点比真实元凶低一层 修饰符写在内层容器 } 之后、外层自定义组件 } 之前(自定义组件闭括号后不能再挂属性链,与附录 D7 同源)(见 LSN-025)
设了自定义壁纸,但页面/顶部仍发白(用户口语「白色蒙版」) 壁纸之上还有两层无色不透明底在盖:① 主界面 HdsNavigation.backgroundColor = colors.background(壁纸时 ≈70% 白,整屏压暗)② 首页标题栏滚动蒙层 #CCFFFFFF(80% 白块)。子页两条链默认已透明 ⇒ 只有主界面发白 改成与子页同口径:HdsNavigation.backgroundColor 条件透明(注意属性链上 backgroundColor 可能写了两次,后写生效)+ buildHomeTitleBar(..., transparentScrollMask = true)(见 LSN-023)
材质下方是纯色背景(无壁纸 / 页面无内容) 材质/毛玻璃无信息可采 → 卡片退化成**「与页面同色的灰玻璃」**(浅色下只剩一圈模糊边,比原来的实色白卡更差);不报错 把「有底层」做成 materialActive(forceOn, backdrop) 的第二形参;无底层时不下发材质 + 回落实色 cardBg(浅色白 / 深色 #212121)+ 去掉模糊(见 LSN-026)
修改模板/工程里的资源引用($media/$color/$string) 引用写对了名字但 resources/base/media/ 里没有对应文件 → 构建报 The resource reference '$media:xxx' is not defined,且报错指向 build 中间产物,难以定位源头 引用与资源成对检查:改完先 grep -rn '\$media:' --include="*.json5" 列出全部引用,再 ls resources/base/media/ 核对。⚠️ 报错路径是 entry/build/.../module.json(中间产物),真实源头在 src/main/module.json5(见 LSN-024)
给卡片/列表项做「长按」交互 写 .onLongPress(() => {...}) 挂在 Stack/Column 上 → 报 10505001 Property 'onLongPress' does not exist on type 'StackAttribute';再改成 import { LongPressGesture } from '@kit.ArkUI' → 报 10311006 ... is not exported from Kit '@kit.ArkUI' LongPressGesture 是 ArkUI 全局声明、无需 import,写法 = 容器根节点 .gesture(LongPressGesture({ duration: 500 }).onAction(() => {...}))。内部"不吃手势"的承载层(hitTestBehavior(None) + enabled(false))不拦截。改交互时回调属性同步改名让调用点漏改编译失败(见 LSN-027)
同一组件既要「点击」又要「长按」(如列表卡:点击看详情 / 长按编辑) .onClick(...) + .gesture(LongPressGesture(...)) 并存(两个独立手势):长按命中后抬手又触发点击 ⇒ 连跳两页(进编辑页又跳详情页)。编译全绿、只有真机暴露 绑成互斥手势组:.gesture(GestureGroup(GestureMode.Exclusive, LongPressGesture({duration:500}).onAction(...), TapGesture({count:1}).onAction(...))) —— 独占模式"先满足条件者胜出"。❌ 不要 GestureMode.Parallel(两个都触发)(见 LSN-028)
子页需要「外部数据变更后自动刷新」(编辑保存返回详情要显示新值) 把重算结果放普通字段缓存、在 build() 里读 ⇒ 刷新链尾段是"父组件重渲染会不会顺带重跑 @Builder"这一不确定行为 ⇒ 返回后仍是旧值、退出重进才对 用 @State 持有视图模型 + @Watch 驱动重算(@Watch 写在状态装饰器之前,LSN-012);入参只取 id、不持快照,页面永远 repo.find(id),取到 null 渲染「记录不存在」兜底卡(见 LSN-028)
ForEach 里的数值变了但界面不更新(编辑后明细行还显示旧值) key 只写"身份":(r) => \kv_${r.label}`⇒ 项还在、值变了 ⇒ **key 不变 ⇒ 复用既有节点、不重跑@Builder`** key 里带上会变的值:\kv_${r.label}_${r.value}_${r.accent}_${r.isLast}`。同理列表页 key 带上 _${this.assetsVersion}` 做双保险(见 LSN-028)
新建工程/写 .gitignore 或把工程推上 Git 照抄模板 .gitignore 的裸 signing/ ⇒ 整个签名目录被忽略,仓库里没有 p7b/cer/p12/csr;git status 里永远看不到,clone 到新机器构建不出可安装包(unsigned.hap / 9568320 no signature file) 签名材料必须进仓库:p7b/cer/p12/csr 和 <mode>/material/ 都要进(后者是解密密码密文的密钥材料,IDE 生成、CLI 只读不重建,缺它 SignHap 报 00308018/00304032/00303242);只忽略根级 /signing/material/。⚠️ git check-ignore 判目录不可信,用 git ls-files | grep material 复核(见 LSN-029)
详情/只读页的某个字段为空 整块隐藏(if (hasNote) { 备注卡 })⇒ 用户分不清"没填"还是"不支持"(本工程实测:详情页备注卡「无备注不渲染」,随即被用户报「详情页要能显示备注」) 详情/只读页的字段卡常驻,空值给占位文案(「暂无备注」)——"这一栏存在"本身要被表达出来。表单/编辑页相反:空字段不渲染更清爽(没有"存在与否"的疑问)。备注等正文块要用与输入框同款承载(surface + 0.5vp fieldBorder 描边),别直接铺在玻璃上(见 LSN-025)
本地书/音乐"存入库、不复制文件"后重启打不开(导入当次正常、重开白屏/无声) 只调了 FilePermission.persist([uri]),没有每次启动 activatePermission。持久授权跨重启不自动生效 ⇒ openSync(原始URI) 抛 13900001 EPERM ⇒ 读取 catch 静默 return ⇒ 画布/列表全空 ① 启动恢复链路(EntryAbility.startupReady,数据仓库 init 之后)加 await BookRepo.activatePermissions()(遍历全部 bookUrl 调 FilePermission.activate);② 读取路径加 FilePermission.ensureActive(uri) 单条兜底。★ 指纹 = 导入当次能看、杀进程重开就白;日志找 13900001/EPERM(见 LSN-030)
读取失败导致"整片空白"的页面(阅读页纯色、列表空态) catch 里只 AppLogger.error(...) 然后 return ⇒ 用户看到一块底色,没有任何提示,长相差不多的根因(权限/文件移动/偏移错位)无法区分,排查几轮都在猜 绝不静默失败:把失败落到 @State loadError,渲染可见错误态 + 重试按钮;并对"内容为空""排版 0 页"等非异常分支同样报错(见 LSN-030)
给 Canvas 自绘的正文加"长按划选" 用比例估字符下标(x / 行宽、y / 页高)⇒ 随字号 / 字距 / 段距变化必然错位(多选少选一个字、跨行乱选)。且排版产物只有整串文本、没有每行的 charStart ⇒ 拿不到章内偏移,选中的内容无法与书签 / 笔记定位对齐 三层收敛:① localX/px2vp − 页边距 ⇒ 页内容区坐标;② 按行的 y 区间定位行,行高 = 字号 × lineSpacing,标题行字号 +2(行高不同);③ 行内找最后一个左缘 ≤ cx 的字形,过其右缘中点则 +1 ⇒ 章内偏移 line.charStart + idx。★ 排版期就把每行 charStart 存下来,别在交互期反推。底衬要逐行画(求行 ∩ 选中区间的交集),不能拿章内偏移当页面坐标(见 LSN-031)
在已有 struct 里新增 @Builder / 方法 把新成员写到结构体 } 之后(如插在文件末尾)⇒ 一条改动引发几十条级联假错误:Property 'xxx' does not exist on type 'Xxx' + UI component 'Column' cannot be used in this place + Cannot find name 'b'。元凶不在报错点上,逐条改会白费 用脚本扫「独占一行的 }」定位那个提前闭合处(本工程实证在 615 行),把成员移回结构体内(去掉多余的 })即可,别按报错逐条补(见 LSN-031)
从管理页(书签列表 / 搜索结果)跳回阅读页 一律 pop() ⇒ 若用户是从书架 → 书籍详情页进入的管理页,路由栈里没有阅读页 ⇒ pop 退到错页(详情页)而不是阅读页 先判存在再决定:AppRouter.has(ROUTE_READER) ? pop() : push(ROUTE_READER, bookUrl)(has(name) = getAllPathName().indexOf(name) >= 0)。配套:多个入口共用一条 @StorageLink 跳转信号,消费方 @Watch 读到后立即清空(见 LSN-031)

5. FAQ(覆盖高频提问)

Q1:我要新起一个鸿蒙 App,最省事的做法?
复制整个模板目录 → 改 AppScope/app.json5 的 bundleName 与 AppScope/resources/base/element/string.json 的 app_name → DevEco 打开自动关联 SDK → 按 W1 裁剪清单删除不需要的部分。不要新建空工程再往里搬,容易丢配置文件之间的隐式依赖。

Q2:加一个设置开关,为什么重启后回到默认值?
三选一的锅:① SettingsManager.init() 里没 getSync 读回该 key(最高频,模板自身就有两处遗漏,见 LSN-003);② setter 里没调 save();③ onBackground/onDestroy 没 saveNow(),防抖未到期被杀进程。按顺序查这三点。

Q3:改了主题色/深浅模式,界面不刷新怎么办?
ThemeManager.colors 是 static,不响应式。组件必须持有 @StorageLink('themeColorsVersion') 或接收父级传来的 @Prop themeVersion,版本号 +1 时重跑 build 重读 colors。仅声明未引用不会触发重建。

Q4:新增子页四步是哪四步?漏一步会怎样?
① pages/subPages/XxxPage.ets 用 SubPageScaffold 包裹;② model/AppRouter.ets 加 ROUTE_XXX 常量;③ layout/Index.ets 的 navDestinationBuilder 加 else if 分发;④ 调用处 AppRouter.getInstance().push(ROUTE_XXX)。漏 ③ → 白屏且无报错;漏 ② → 到处硬编码字符串,改名必崩。

Q5:为什么我的弹窗能看见底下的内容?
用了半透明的 ThemeManager.colors.background(自定义背景时它是 #B3...)。弹窗必须 ThemeManager.opaqueBackground。

Q6:宽屏(平板/2in1)上弹窗要居中,要不要自己写?
不要。用 AppSheet,它内置:≥600vp 或 deviceType=tablet/2in1 → 自动切 promptAction.openCustomDialog 窗口级居中;窄屏 → bindSheet 底部弹窗。

Q7:加图标最简做法?
直接用鸿蒙官方符号,不要自研 SVG 图标组件。 一行搞定:
SymbolGlyph($r('sys.symbol.xxx')).fontSize(22).fontColor([ThemeManager.colors.textPrimary])。
符号名从 <DevEco>/sdk/default/openharmony/toolchains/id_defined.json 检索 "type": "symbol" 得到
(本机 HarmonyOS 26.0.0 / API 26 共 4042 个可选);Tab 选中态用配对的 xxx / xxx_fill。
⚠️ $r('sys.symbol.xxx') 是编译期字面量,不能用变量拼接 → 通用组件要接 Resource 参数而非名字字符串(见 LSN-020)。

Q8:hvigor 报 [safe-delete] 怎么修?
= 构建缓存损坏(构建期间并行编辑或并行构建所致)。删 entry/build/default/cache/default/default@CompileArkTS,然后前台串行 --no-daemon 重建。本项目禁止并行构建。

Q9:AppStorage 和 Preferences 到底用哪个?
AppStorage 是内存态全局(重启即丢),用于跨组件通信/信号广播;持久化一律走 SettingsManager(preferences,增量写 + 防抖 + 原子 flush)。两者通过 fillAppStorage() 单向同步:preferences → AppStorage。

Q10:返回键要收起某个浮层,该怎么接?
非 @Entry 组件的 onBackPress 不会被系统调用。统一在 App.ets(@Entry)的 onBackPress 里判断 AppStorage 状态 → 广播版本号信号(如 appBackRequest +1)→ 目标组件 @Watch 消费。别忘了模板默认顶层返回 = moveAbilityToBackground() 最小化而非退出。

Q11:这个 Skill 覆盖 HarmonyOS API 多少?
以本机实装 SDK 为准,不要照抄文档里的版本号:先 cat <DevEco>/sdk/default/sdk-pkg.json
读 apiVersion / platformVersion(2026-09-17 实测为 HarmonyOS 26.0.0 / API 26)。
SDK 版本号的书写格式按 API 级别分界(API ≥ 26 用点分制 "26.0.0",API < 26 用 "6.1.1(24)"),
写错会直接构建失败 —— 详见 LSN-021。Hds 组件(@kit.UIDesignKit)相关写法按此范围验证;
更低版本请参考铁律 #16 退数值字面量。

Q12:光感材质画出来了但看不到 / 拨开关不刷新?
四选一排查(全部只能靠真机截图确认,材质不报错):
① 有不透明底色盖在材质上(材质层级位于 backgroundColor 之下)——外壳与承载层材质开时一律
Color.Transparent;② attributeModifier 写成了内联对象字面量——改成方法调用并读订阅变量;
③ interactive:true 配了空 lightEffect:{}——无流光色时省略该字段;
④ bindSheet 没设 backgroundColor: Color.Transparent(默认白底整块盖住)。
建议设置页放诊断行(materialDiagnostics()),五段判定一眼看出卡在哪一环。
Q13:自定义壁纸和深浅模式/光感怎么协同?
壁纸不动深浅模式(主题仍由用户选择/系统决定);有壁纸时页面背景变半透明充当压暗层,
文字可读性由半透明背景 + 玻璃卡/材质保证。壁纸是材质的最佳舞台:玻璃卡采样窗口背后的壁纸,
零适配即得毛玻璃效果。⚠️ 三处链路(App 启动 / onConfigurationUpdate / 设置变更)都要重放
壁纸状态,漏了就会出现「重启或旋屏后壁纸失效」(见 LSN-023)。

Q12:模板里没有的能力(网络/播放/数据库)怎么加?
新建放 entry/src/main/ets/services/(无 UI 逻辑;用 AppLogger 打日志)或 model/(数据模型)。不要把业务塞进 Index.ets 或 App.ets——它们是骨架层,只允许放全局装配逻辑。


6. 踩坑经验库(LSN)

每条都来自真实项目,不是编的。来源:HarmonyAppTemplate + 其母体项目(Icey-Player 系)2026-08 ~ 2026-09。

LSN-001 改 A 先看 B/C/D —— 执行者的完整责任

  • 风险:🔴 高频(修一半漏一半)
  • 场景:改了一处判断逻辑(如某个 Map 的取值方式),模板/工程里有 4 处相同模式
  • 错误:改了 2 处就提交,用户发现「另外两处怎么没改」
  • 正确:改前先 grep -rn "关键词" entry/src/main/ets/,把所有同模式引用一次改全
  • 口诀:司令员说「改 a」,跑步员要把 a 的上下游全部清完

LSN-002 「改了没生效」五层排障

  • 风险:🔴 高频
  • 场景:编译通过、产物正确,但用户看到的还是旧内容
  • 排查顺序:源码 → 编译产物/缓存 → 工具(DevEco 加载了哪个目录?) → 设备/环境 → 是否改到了文件的真身
  • 血案:改了 home.vue/Index.ets 2 小时无效果 → grep 发现实际渲染在另一个文件 → 10 秒解决
  • 铁律:方向试 2 次无效必须换思路,禁止死磕第三次

LSN-003 新增持久化项「五步」而非「三步」

  • 风险:🔴 高频(重启静默丢失)
  • 场景:模板 SettingsManager 里 HANDEDNESS_KEY、MATERIAL_LEVEL_KEY 有常量、有 setter、
    但 init() 没有 getSync 读回 → 这两个设置重启必然回默认值
  • 五步:① KEY 常量 → ② SettingsRecord 字段 + 默认值 → ③ init() 里 getSync 读回 → ④ fillAppStorage() 回填 → ⑤ setter(set 内存 + AppStorage + save)
  • 防御:加完运行 grep -n "<KEY>" model/SettingsManager.ets,出现次数必须 ≥ 4(定义/读/写常量各一处 + setter)

LSN-004 静态类的单例幻觉(编译期必炸)

  • 风险:🔴 必现编译失败
  • 场景:SettingsManager 写成纯 static 类,但 8 处调用写成 SettingsManager.getInstance().xxx()
    (EntryAbility ×4、SettingsTab ×2、SettingsSubPage ×2)
  • 后果:ArkTS 编译直接失败,且报错是「找不到 getInstance」而非「缺新能力」,容易误判成 SDK 问题
  • 防御:改任何 Manager 前先确认它属于哪一种范式:
    static 全静态(直接 Mgr.fn()) vs 单例 getInstance()(见 AppRouter)。两种不可混写

LSN-005 路径硬编码陷阱

  • 风险:🔴 高频(换机/换盘必踩)
  • 场景:提示框里说文件在 E:\project\...,实际用户把工程搬到了别的盘
  • 规则:① 每次会话开头先定位模板源(见 §0);② 输出前 ls 验证路径存在;③ 不用缓存里的旧盘符
  • 附带:身份文件(SOUL.md / IDENTITY.md / AGENTS.md)是只读的,非用户明确要求绝不改动

LSN-006 编码与写入工具

  • 风险:🔴 高频(乱码/白屏)
  • 规则:① 所有 .ets/.json5/.json 必须 UTF-8 无 BOM;
    ② 禁止用 PowerShell Set-Content/Out-File 写文本文件(自动加 BOM),用 Edit/Write 工具或 Node.js fs;
    ③ 禁止用 Python 做文本替换(用户级规则),一律用 Edit/Write

LSN-007 已 push / 已验证过的代码,最小影响原则

  • 风险:🟠 中频(成型系统上大动干戈是灾难)
  • 规则:对已上线/已验证模块,只动必须动的那一处;重构与功能改动分离提交

LSN-008 版本号信号 ≠ 布尔状态

  • 风险:🟠 中频
  • 场景:把 appBackgrounded(版本号,每次 +1)当布尔用并尝试复位 → 依赖它的 @Watch 从此不触发
  • 规则:版本号 = 事件(单调递增,永不复位);布尔 = 状态(可查询)。两者并存,语义勿混用

LSN-009 项目规模→是否值得沉淀

  • 风险:🟢 低频但高价值
  • 规则:跨 2 个以上模块、或修了超过 8 次工具调用才定位的问题 → 立刻写 LSN 更新到本文件;
    单次小改动不必

LSN-010 HdsTabs 底栏中央「+」按钮:标准方案与视觉对齐

  • 风险:🔴 高频(导航栏加 FAB 是普遍需求,且第一直觉都是错的)
  • 场景:用户要求「+」按钮内嵌 HdsTabs 底栏正中央。三轮迭代实证:标题栏悬浮→被否决;隐藏自带 bar(barHeight(0))+ 自绘 customTabBar + 外层 Stack 钉底→被否决(「必须使用 hdstabs」)
  • SDK 事实(读 @hms.hds.hdsBaseComponent.d.ets 实证,官方文档页 WebFetch 拿不到正文):HdsTabsOptions extends TabsOptions,页签栏完全由 TabContent().tabBar(builder) 驱动,没有中间异形按钮专用 API;但复用系统 Tabs 的 onContentWillChange((currentIndex, comingIndex) => boolean)(返回 false 阻止切换)
  • 最终方案(5 页签):「+」= 空 TabContent 占位(index=2)+ onContentWillChange 对该 index 返回 false + onTabBarClick(index) 弹弹窗;自带 bar 恢复 barFloatingStyle(barBottomMargin=导航条+2、ADAPTIVE 材质)
  • 两个隐藏坑:① TabContent 从 4 变 5 后,所有按 index 取值的数组/参数必须同步补位——TabItemBuilder(index) 参数(选中高亮靠 currentIndex===index)、tabTitles 数组(标题栏按 tabTitles[currentIndex] 取值,漏补则后段标题整体错位、末项越界空标题;占位 index 填空串)——改完 grep -rn "currentIndex\]\|TabItemBuilder(" 全查一遍;② 「+」红钮与其他页签的视觉对齐——TabItemBuilder有padding({bottom:4})` 使图标+文字组重心偏上,红钮必须带同级 padding,且 44vp 大目标对 1vp 偏差敏感(4 不够上 6,用户截图逐轮反馈)
  • 口诀:FAB 想进 tabBar,占位页签 + willChange 拦截;改完页签数,index 参数全 grep

LSN-011 日期选择优先官方 DatePickerDialog,勿手写滚轮

  • 风险:🟡 中(自定义日期滚轮代码量大、坑多——索引/值换算错位、闰月联动、月天数收敛全要自己维护)
  • 场景:表单需要选公历/农历日期。手写三列滚轮(List + spacer 居中 + onScrollStop 回写)真机暴露年份错乱 bug(回调传「索引+1」当值,月/日蒙对、年错位成 126),最终整体推翻改官方弹窗
  • SDK 事实:this.getUIContext().showDatePickerDialog(options)(API 11+,替代已废弃的静态 DatePickerDialog.show);DatePickerDialogOptions 支持 start/end/selected(Date)/lunar(boolean)/lunarSwitch(boolean)/onDateAccept?: Callback<Date>;onDateAccept 只回传公历 Date,读不回用户是否切了农历开关(DatePickerResult 仅年/月/日无农历字段)
  • 最终方案:删除自研滚轮 + 闰月开关;日期状态收敛为单一公历事实源(@State selSolar: Date),弹窗 lunar: this.lunarMode 控制展示口径、lunarSwitch: false 隐藏弹窗内开关(避免开关状态读不回的语义分裂),公历/农历由弹窗外 chip 单一事实源控制;农历中文显示由 LunarUtils 换算(闰月自动带出);保存按钮 enabled 不再判「换算失败」——公历事实源恒有效
  • 注意:官方弹窗不支持深浅色热更新(需重开弹窗);弹窗内年月列联动/天数收敛全部系统自理
  • 口诀:选日期用 showDatePickerDialog;回传只有公历 Date,口径开关自己管、别开 lunarSwitch

LSN-012 @Watch 装饰器顺序硬性约束 + 跨 Tab 信号驱动 UI 走「父级级联」

  • 风险:🔴 高频(静默失效,编译不报错,最难查)
  • 场景:用 @StorageLink('sig') @Watch('onSig') 想在某个信号变化时重算列表,结果列表「有初始数据但永远不更新」
  • 根因①(装饰器顺序):ArkTS 规定 @Watch 必须写在状态装饰器之前(@Watch('cb') @StorageLink('x') x),写反(@StorageLink('x') @Watch('cb'))SDK 静默忽略 @Watch——回调永不触发,依赖它的 @State 缓存只算一次
  • 根因②(跨 Tab 不可靠):仅依赖「子 Tab 自己的 @StorageLink 信号」触发重渲染,对当前非活跃 Tab 不可靠(组件未激活时状态变更未必驱动其重渲染)
  • 正确做法:
    • 顺序:@Watch('cb') @State/@StorageLink/@Link(...) x(Watch 在前)
    • 更稳的排序/重排:让父组件(如 Index)在 build() 里引用该信号(类比主题 themeColorsVersion 放 .opacity(...) 里被引用),父级整树重渲染级联到子 Tab 重排——这是模板已验证的通路(深浅切换即此机制),比依赖子 Tab 各自 @StorageLink 可靠得多
    • 列表计算直接在 build() 内调 EventSorter.sorted(...) 重算(不缓存到 @State),彻底绕开 @Watch 顺序陷阱;ForEach key 仍带信号做双保险
  • 口诀:@Watch 写前面;跨 Tab 信号让父组件 build 引用→级联,别只靠子 Tab 自己监听

LSN-013 内置副本 compile-ready 校验:原样复制后必须"全表扫一遍"

  • 风险:🔴 高频(新建项目第一小时全耗在来回编译上)
  • 场景:2026-09-16 用内置副本起「资产日耗」App,按 §0 只修了 D1~D4 就构建 → 仍然连挂 3 轮
  • 实测错误链(逐层暴露,每次只报一层):
    1. hvigor 00306042:targetSdkVersion: "26.0.0" 格式非法 → D5
    2. ArkTS 编译 10 个错:HdsNavigationTitleBarOptions 未 import + opts.content 可能 undefined → D6;
      AboutPage 自定义组件后链式 bindSheet → D7;ListItem 直接持有 Blank → D8
  • 正确流程:复制模板后一次性核对 附录 C 全表(D1~D9)再构建;构建前先 cat <DevEco>/sdk/default/sdk-pkg.json
    确认本机 apiVersion,别照抄模板里的 26
  • 判定经验:ArkTS 报错位置经常不是元凶——AboutPage 那条报的是"@Entry 组件 build 只能有一个根节点"
    (AboutPage 根本不是 @Entry),真实原因只是链式属性把解析器带偏。先看报错点所在语法结构是否合法,再怀疑逻辑
  • 口诀:模板不是"复制即可用",是"复制 + 全表体检 + 对齐本机 SDK"

LSN-014 width('100%') 与横向 margin 不可同挂:卡片右端被裁切(用户只说"显示不全")

  • 风险:🟠 中频但最难自查——编译通过、无任何告警、纯视觉缺陷,且只在真机截图上才看得见
  • 场景:2026-09-16「资产日耗」首页总览卡:AssetTab 的 List 没加左右 padding,
    卡片组件内部却挂 width('100%') + margin({left:16,right:16})
  • 症状:卡片左边距正常(≈16vp)、右边距为 0,右端被切成直角紧贴屏幕边缘
  • 根因:ArkUI 中 margin 计算在组件尺寸之外。width('100%') 取父内容区宽,
    再加左右 margin 16 → 总占位 = 父宽 + 32vp;起点右移 16、右端越界 16,被父 List 裁切
  • 定量量测(截图取证法):取卡片中间一行逐像素扫蓝/背景边界 → 若「左内缩 ≈ 16vp 且右内缩 = 0 且右上角无圆角」,
    即可确诊;若左右都正常则与本坑无关
  • 正确写法:横向内缩只由宿主 List 的 .padding({left:16,right:16}) 提供;
    卡片保留 width('100%')、去掉横向 margin,只留 .margin({top:12})。
    模板中 SettingsGroup.ets 已按此实现(注释:"width('100%') + margin 会溢出导致边距不生效"),
    SettingsTab / AboutPage 的 List 也都带 .padding({left:16,right:16}) —— 新增页面照抄
  • 孪生反例(不会裁切但会"窄一圈"):卡片既被 List padding 又自带横向 margin → 双倍内缩 32vp,
    同一 App 内两页卡片左右边界不一致。归一化时把这两类一起清
  • 口诀:横向内缩归 List,卡片只留纵向间距

LSN-015 跨组件只传"展示型数据类":@Prop 深拷贝会丢方法,视图层禁止重算

  • 风险:🟠 中频(页面一多、图表一加就撞上),且后果是运行时 undefined / 白屏而非编译错误
  • 场景:2026-09-16 给「资产日耗」加趋势页,需要把聚合结果喂给柱状图/排行条组件
  • 两条硬约束(互相印证,指向同一个架构):
    1. @Prop 对类实例做深拷贝,方法不保证存活 —— 传 AssetItem[] 进子组件后调 it.dailyCost()
      就是运行时炸弹
    2. 视图层不应做业务计算 —— 在 build() 里遍历列表求和/求比例,列表一长就是每帧白算一遍
      (模板既有约定:build() 内连局部变量都不允许声明)
  • 正确做法:把"算"全部留在 model,产出一个只有字段、没有任何方法的展示型数据类,
    连比例、宽度百分比、右侧说明文案都预先算好;子组件 @Prop 收进来只做排版
    // model/TrendStats.ets —— 无方法,专供 @Prop 传递
    export class NameValue { name = ''; value = 0; ratio = 0; count = 0; extra = ''; }
    // components/Charts —— 只排版
    @Component export struct RankRows { @Prop items: NameValue[] = []; /* ... */ }
  • 判别口诀:"这个类要被 @Prop 传吗?"→ 要,就一个方法都别加
  • 附带收益:@Prop 深拷贝的语义变得完全可预测(纯数据,拷贝后行为一致),也天然满足
    "bar 宽度 / 百分比文案不在 UI 层重算"的可维护性要求

LSN-016 跨组件传 @Builder 会丢 this:菜单能弹出、点一下才崩("undefined is not callable")

  • 风险:🔴 高频陷阱(凡是"给通用组件塞 builder"的封装都会撞),且编译零报错,只在真机点击那一刻崩
  • 场景:2026-09-16「资产日耗」设置页切换主题模式闪退:
    Reason:TypeError / Error message:undefined is not callable
    at anonymous entry (entry/src/main/ets/pages/SettingsTab.ets:106:32)
    106:32 正好是 .onChange(() => { this.applyThemeMode(2); }) 里的 applyThemeMode
  • 根因:SettingItem 用 @BuilderParam menu 接收父组件的 @Builder,父组件以
    menu: this.themeMenu 传未绑定引用。菜单展开时正常(.selected(...) 只是取值,
    读不到也只是 false,不抛),但点击菜单项时执行 builder 体内闭包,this 已被重绑为
    子组件 SettingItem 实例 —— this.applyThemeMode → undefined → 调它即抛
  • 三步闭锁定性(照抄即可复现此判据):
    1. 看列号落到哪个标识符:106:32 指向 this.applyThemeMode 而非 this,说明
      this 是"有效对象但缺这个成员" → 是被重绑,不是 undefined
    2. 找同文件对照物:同页「沉浸光感材质」菜单不崩,因其 builder 只调用
      模块级导入的 SettingsManager.setMaterialLevel(),体里根本没有 this
    3. 反推静默 bug:既然 builder 里 this 错了,.selected(this.themeMode === 0) 读到的
      就是 undefined,undefined === 0 → false → 选中打钩从来没显示过(用户只报了闪退,
      不会报这个)—— 修此 bug 必须两个症状一起收
  • 正确做法(配置化,对齐模板 SheetContainer 的"配置化而非调用方塞组件"约定):
    // 子组件:不再收 @Builder,改收数据 + 普通函数属性(普通函数属性的 this 是正常的)
    @Prop withMenu: boolean = false;
    @Prop menuOptions: string[] = [];        // 父组件传模块级常量数组,引用稳定
    @Prop menuSelected: number = -1;         // 选中下标;@Prop 保证父级切换后重渲染
    onMenuPick: (index: number) => void = (index: number) => {};
    
    @Builder
    private menuBuilder() {
      Menu() {
        ForEach(this.menuOptions, (opt: string, idx: number) => {
          MenuItem({ content: opt })
            .selected(this.menuSelected === idx)
            .onChange(() => { this.onMenuPick(idx); })
        }, (opt: string, idx: number) => `${idx}-${opt}`)
      }
    }
    // 父组件:只给数据 + 回调(回调是箭头函数,this 在创建点被词法捕获,正确)
    SettingItem({
      /* ... */
      withMenu: true,
      menuOptions: THEME_OPTIONS,           // 下标即 mode 值:0 跟随系统 / 1 浅色 / 2 深色
      menuSelected: this.themeMode,
      onMenuPick: (index: number) => { this.pickTheme(index); },
    })
  • 注意区分:同组件内 bindMenu(this.xxxMenu) 是安全的。AssetTab.sortMenu、
    AssetFormSheet.categoryMenu 都这么写且正常 —— 因为 builder 与调用点在同一个组件里,
    this 未被重绑。只有"跨组件传递"才触发本坑
  • 判别口诀:
    • "这个 builder 要传给别的组件吗?"→ 要,就别用 @BuilderParam,改成「数据 @Prop + 回调函数属性」
    • "builder 体里出现 this. 了吗?"→ 一旦跨组件出现,就一定会丢
  • 附带收益:通用组件不再依赖 ArkUI 的 builder 编译细节,调用方只需提供数据,
    组件也更易测(Menu 结构固定在组件内,不在每个调用点重复写一遍)

LSN-017 签名与安装三连坑:signingConfigs 声明 ≠ 使用、改包名后 IDE 仍启旧包、签名无法靠魔数判定

  • 风险:🔴 装不上就完全无法真机验证(打断整条交付链),且报错信息指向"没签名",
    很容易误判成"证书过期/账号没登录"而白折腾

  • 场景:2026-09-17「资产日耗」改包名后 DevEco 运行失败:

    Launching com.icey.assetscost          ← 旧包名(已改过 app.json5)
    Install Failed: error: failed to install bundle.code:9568320
                    error: no signature file.
  • 三个独立故障点(逐一定位):

    ① signingConfigs 里声明了材料 ≠ product 会使用它(致命)
    build-profile.json5 里 app.signingConfigs[] 只是一个候选清单,
    product 必须显式引用才生效:

    "products": [ { "name": "default",
                    "signingConfig": "default",   // ★ 缺这行 → 产出 unsigned.hap
                    "targetSdkVersion": "6.1.1(24)", "runtimeOS": "HarmonyOS" } ]
    • 症状:entry-default-**unsigned**.hap 与 -signed.hap 同时存在,IDE 却装了 unsigned
    • 判据:CLI 构建日志出现 WARN: No signingConfig found for product default,
      且 SignHap 任务名不出现(修好后 SignHap 会实际执行数秒)

    ② 改 bundleName 后 IDE 仍启动旧包名
    .idea/.deveco/project.cache.json 的 BuildOptions.BUNDLE_NAME 会缓存包名,
    DevEco 的运行配置据此启动。改包名后必须 Sync / 重启 IDE 重建缓存。
    (该文件属 IDE 生成物、不纳入版本控制,可临时手改验证,但 Sync 才是正解)

    ③ 签名无法靠"魔数/条目 diff"判定(验证方法论)

    • ❌ 找 Hap Signature Block 魔数 → 本 SDK(HarmonyOS 6.1.1 / API 24)找不到,
      因为签名实体是「内嵌描述文件(p7b 内容) + 证书链」,文件尾部仍是普通 zip EOCD
    • ❌ 对比 signed / unsigned 的 zip 条目 → 所有条目内容与大小完全相同,
      差异仅在条目顺序 + 多一个 .pages.info(96B),会得出"没签名"的错误结论
    • ✅ 唯一可靠判据:解包后检索 kebab-case 字段 bundle-name /
      app-identifier / debug-info —— 它们只存在于 p7b 描述文件;
      module.json 用的是 camelCase bundleName,因此不会误判
      python -c "d=open('<hap>','rb').read();i=d.find(b'bundle-name');print(d[i-60:i+260].decode('utf-8','replace'))"
  • 改包名的连带影响(做前先问清):

    • p7b 描述文件绑定 bundle-name → 改包名后旧证书失效,需重新申请
    • 描述文件的 debug-info.device-ids 是设备白名单(device-id-type: udid),
      仅名单内设备可安装调试包
    • bundleName 即应用身份 → 已安装设备的 preferences 数据不迁移,新包为空白数据
  • 真机验证标准动作(hdc 在 <SDK>/default/openharmony/toolchains/hdc.exe):

    hdc list targets                 # 连接设备(真机序列号形如 62T0226127013362,非 127.0.0.1:port)
    hdc shell bm get --udid          # 取 UDID,核对是否在证书白名单内
    hdc install -r <signed.hap>      # 安装 → "install bundle successfully"
    hdc shell bm dump -a             # 确认包已注册;appIdentifier 应等于描述文件里的值
  • 口诀:"材料配了"要问"引用了没";"包名改了"要问"IDE 知道了没";
    "有没有签名"只看 kebab-case 描述文件字段,别找魔数


LSN-018 启动竞态:AppStorage.set() 对不存在的键是静默空操作 → 列表页永久「加载中…」

  • 风险:🔴 高危害 + 极高隐蔽性——编译零报错、必现与否取决于机器速度,
    且"加载中"看起来像正常的慢,很容易被误判成"数据没读出来"去查持久化

  • 场景:2026-09-17「资产日耗」改包名重装后,资产/心愿/趋势三个 Tab 全部永久转圈。
    日志是决定性证据:

    I .../Startup: 启动恢复总耗时 34ms                              ← 恢复链路已完成
    I .../App: App colorMode=1 themeMode=0                          ← UI 此时才开始建树
    W .../App: launch fallback: dataLoaded false after 300ms        ← 标志位仍是 false
  • 双重根因(必须同时理解,缺一会误判):

    ① 写入落空(主因):AppStorage.set 不会创建键

    AppStorage.set('dataLoaded', true)   // ← 键尚不存在时:静默丢弃,不报错、不创建

    EntryAbility 的恢复链路(34ms)快于 App.aboutToAppear 里
    setOrCreate('dataLoaded', false) 的建键时刻(55ms)→ set(true) 落空。

    ② 建键覆盖(次因):App.aboutToAppear 无条件初始化

    AppStorage.setOrCreate('dataLoaded', false)   // ❌ 无条件 → 会覆盖已置的 true

    两者叠加 = 顺序敏感:

    顺序 结果
    链路晚于 UI 建键(旧版:恢复 382ms) set(true) 命中已存在的键 → 正常 ✅
    链路早于 UI 建键(新版:恢复 34ms) set(true) 落空 → 随后被建键为 false → 永久加载 ❌
    → 恢复越快越容易触发,所以本地调试"有时好有时坏",上线到快设备/空数据时 100% 复现
  • 正确做法(三处协同,任一处都不能省):

    // ① 写标记方(EntryAbility 恢复链路):一律用 setOrCreate,与键是否存在/先后顺序无关
    AppStorage.setOrCreate<boolean>('dataLoaded', true);
    
    // ② 建键方(App.aboutToAppear):只在键不存在时初始化,绝不回退已完成的状态
    if (AppStorage.get<boolean>('dataLoaded') === undefined) {
      AppStorage.setOrCreate<boolean>('dataLoaded', false);
    }
    
    // ③ 自愈兜底:数据源已就绪却仍未标记 → 强行补标记(只打日志是不够的)
    setTimeout(() => {
      if (AppStorage.get<boolean>('dataLoaded') === true) return;
      if (YourRepository.isReady()) {              // 仓库需暴露静态 isReady()
        AppStorage.setOrCreate<boolean>('dataLoaded', true);
        hilog.warn(0x0000, TAG, 'fallback: 数据源已就绪但标志位未置,已兜底补标记');
      }
    }, 300);

    配套:数据仓库在 init 的 try/catch 之后(成功失败都)置 loaded = true,
    让"载入结束"(失败也算结束,UI 应显示空态而非无限转圈)可被查询。

  • 判别口诀:

    • "这个键谁先创建?"→ 凡是 AppStorage.set,就必须确认键已存在,否则换 setOrCreate
    • "跨模块共享的完成标记,只允许单向(false→true),任何地方都不许写回 false"
    • 看到"永久加载中"先排查顺序,而不是先查持久化
  • 正确验证姿势:不要只看 UI,看 hilog 的应用域日志确认
    启动恢复总耗时 已打印、且 launch fallback 告警数为 0
    (hdc shell hilog -x | grep launch fallback)——告警为 0 才说明不依赖兜底、链路本身正确


LSN-019 @Builder 按值传参不刷新:改了值界面不变、但"重开又对了"

  • 风险:🔴 高频 + 极难自查——编译零报错、数据也存对了,只是界面文本/高亮/开关态不跟着变。
    最坑的是验证方式:如果只用"保存后重开看看对不对",会看到正确值而误判为无 bug(见下)
  • 场景:2026-09-17「资产日耗」新增资产表单:改完购买日期,界面日期文本纹丝不动;
    但保存后重新点进去编辑,日期却是对的
  • 根因(ArkUI 明文规则):

    @Builder 按值传参时,状态变量的改变不会引起 @Builder 方法内的 UI 刷新;
    只有传对象字面量才是按引用传递,才会随状态刷新。

    @Builder valueRow(value: string, placeholder: boolean, onTap: () => void) {
      Row() { Text(value) /* ... */ }        // ← value 是按值参数
    }
    // ❌ 状态变了不会刷新:只是首次构建的值生效
    this.valueRow(AssetMath.formatDateCn(this.effectivePurchaseDate()), false, () => { ... })
  • 同文件"对照实验"(决定性证据,照抄可复现):该表单里
    • 分类 / 目标日期 / 到期时间 → 都是在 build() 里内联 Row { Text(this.xxx) } → 全部正常
    • 购买日期 → 走 this.valueRow(...) 按值传参 → 唯一异常
      → 同一页面、同一状态机制,唯一差异就是这个 @Builder 边界 ⇒ 确诊
  • 正确做法(三种,按推荐度排序):
    1. 内联到 build()(最稳,首选):该行直接在 build 里写 Row { Text(this.xxx) } + .onClick
      —— 本工程四处日期/分类行均为此写法
    2. 按引用传参:参数改成单个类实例,调用处传对象字面量
      class SegmentsParams { labels: string[] = []; selected: number = 0; onChange: (i: number) => void = (i: number) => {}; }
      @Builder segments($$: SegmentsParams) { /* 内部用 $$.selected ... */ }
      this.segments({ labels: KIND_LABELS, selected: this.kind, onChange: (i: number) => { this.kind = i; } })
      (旧版 SDK 要求形参名为 $$;对象字面量必须写在调用处才算按引用)
    3. 交给子组件用 @Prop 传值(子组件的 @Prop 会随父级刷新)
  • 可以继续按值传的情况:参数是纯静态内容(如 fieldLabel('名称')、sectionTitle('基础信息'))
    —— 永不变化就没有刷新需求。但一旦文案依赖状态就必须改,例如
    fieldLabel(this.isWish() ? '目标金额(元)' : '价格(元)') 在切换类型后不会更新
  • 同一根因的其他受害者(排查时一并看):
    • 分段控件的 selected → 选中高亮不动
    • 开关行的 isOn → 开关态不回显(尤其互斥开关:开了 A 应关 B,B 的显示不会跟着关)
    • 任何 @Builder(param) 里 param 由 this.someState 算出来的显示值
  • 判别口诀:
    • "这个值会变吗?会变就别当参数传"
    • "@Builder 的参数里出现 this.<状态>(或依赖状态的表达式)→ 一定会 stale"
    • "UI 不跟着变、但数据是对的" → 先怀疑 Builder/参数边界,而不是数据层
  • ⚠️ 验证姿势(这条最容易骗人):
    不要用「保存 → 重开 → 看值对不对」来验证 —— 值本来就是对的,会误判为没 bug。
    必须在改完值后停留在当前页面看 UI 是否立刻跟着变。
    UI 自动化可用 hdc shell uinput -T -c <x> <y> 点击 + snapshot_display 截图比对。

LSN-020 图标统一用鸿蒙官方符号(SymbolGlyph),不要自研 SVG 图标组件

  • 风险:🟠 中频,但成本极低收益极高;反之自研图标体系的维护成本会持续累积
  • 决策来源:2026-09-17 用户明确要求「不要用 sficon,使用鸿蒙官方的图标」
  • 为什么要放弃自研 SVG 图标组件(SFIcon 那套):
    维度 自研 SVG 体系 官方符号
    资源 每个图标一个 .svg 文件(本项目曾达 28 个) 零文件
    选中态 需手工备 normal / fill 两套文件 系统已提供 xxx / xxx_fill 配对
    缩放 位图/矢量需自己适配 随系统字体缩放自动适配
    配色 Image.fillColor 单色(多色层次会丢失) .fontColor([...]) 支持分层多色
    动态 $r(\app.media.${name}`)` 需拼字符串 见下方约束(改传 Resource)
  • 选名方法(唯一权威来源):SDK 内置清单
    # 本机 HarmonyOS 26.0.0 / API 26 共 4042 个符号
    grep -A1 '"type": "symbol"' \
      "<DevEco>/sdk/default/openharmony/toolchains/id_defined.json" \
      | grep -o '"name": "[a-zA-Z0-9_]*"' | sed 's/.*"name": "//; s/"$//' | sort -u
    搜索关键词示例:grep -E "yensign|coin|wallet"(货币)、grep -E "^heart|^chart|^histogram"(图标族)
  • ⚠️ 核心约束:$r('sys.symbol.xxx') 是编译期字面量,不能拼接变量
    因此通用组件不能收「符号名字符串」再自己解析($r(\sys.symbol.${name}`) 无效), 必须把参数类型改成 **Resource`**:
    // 通用组件(SettingItem)
    iconSymbol: Resource | null = null;          // 非装饰成员:图标不中途变化,无需观察
    // 组件内
    if (this.iconSymbol !== null) { SymbolGlyph(this.iconSymbol).fontSize(16).fontColor(['#FFFFFF']) }
    // 调用处(字面量写在调用点)
    SettingItem({ iconSymbol: $r('sys.symbol.trash'), iconBg: '#E60012' })
    接口字段同理:interface SettingsGroupEntry { iconSymbol?: Resource; },
    透传时用 iconSymbol: item.iconSymbol ?? null 兜住「不传图标」的条目
  • 两个易错点:
    1. .fontColor() 收的是数组:.fontColor([color])(不是 .fontColor(color))
    2. Tab 选中态在 @Builder 内直接读 this.currentIndex(可刷新);符号本身是静态资源不参与刷新,不触发 LSN-019
  • 本项目的落地映射(可作选名参考):
    用途 符号
    Tab 资产 / 心愿 / 趋势 / 设置 wallet(_fill) / heart(_fill) / histogram(_fill) / gearshape(_fill)
    主题·外观 paintpalette
    声音/材质 speaker_wave_2
    删除/清空 trash
    关于/信息 info_circle
    会员皇冠 crown
    标题栏新增 plus
  • 验证姿势:符号名写错时不报错、不崩溃,只是渲染空白。
    故必须真机截图确认图标真的画出来了,不能只看构建通过。
  • 附带收益:本项目资源目录从 33 个文件精简到 5 个(仅剩应用图标与应用内空态插图)

LSN-021 SDK 版本号书写格式按 API 级别分界:API ≥ 26 用点分制,别再加 (api) 后缀

  • 风险:🔴 写错直接构建失败(连编译都进不去),且两条错误信息互相矛盾,极易来回试错
  • 场景:2026-09-17 用户按 DevEco 提示把 SDK 升到 26,compatibleSdkVersion 写成 "26.0.0"——
    与本文档旧版 D5「必须写 x.y.z(api)」冲突,试错两轮
  • 权威依据(读 hvigor 源码取得,非记忆):
    hvigor-ohos-plugin/node_modules/@ohos/sdkmanager-common/build/src/core/constants/component-contants.js
    exports.API_VERSION_PATTERN     = /^(?:[1-9]\d{0,2}|...)$/;        // 旧式
    exports.DOT_API_VERSION_PATTERN = /^[1-9]\d?\.(?:[0-9]|[1-9]\d)\.(?:[0-9]|[1-9]\d)$/;  // 点分制
    exports.FIRST_DOT_API_VERSION   = 26;
    utils.shouldUseDotApiVersion() 注释原文:「api版本大于等于26时…点分制版本号取major位比较」
    即:major >= 26 → 用 DOT_API_VERSION_PATTERN(恰好三段、每段 1–2 位数字);
    否则用 API_VERSION_PATTERN。官方文档亦同:「从 API 26.0.0 开始,HarmonyOS 和 OpenHarmony
    配置统一,字段类型是字符串,配置示例 "compatibleSdkVersion": "26.0.0"
    」。
  • 正确姿势:
    // ① 先读本机 SDK 的真实版本
    //    cat <DevEco>/sdk/default/sdk-pkg.json  →  apiVersion / platformVersion
    // ② 按 API 级别选格式
    "compatibleSdkVersion": "26.0.0",   // API ≥ 26:点分制纯数字三段式
    "targetSdkVersion": "26.0.0",
    // 若 SDK 是 API 24 时代,则写:
    // "compatibleSdkVersion": "6.1.1(24)", "targetSdkVersion": "6.1.1(24)"
  • 两种错误码对照:
    错误码 触发 含义
    00306042 API < 26 时漏写 (api),如 API24 环境写 "26.0.0" must be string, Example: '5.0.0(12)'
    00308018 API ≥ 26 时多写了 (api),如 "26.0.0(26)" api version parameter is illegal! Expected format: <major>[.<minor>][.<patch>]
  • 附加事实(官方文档):runtimeOS 为 HarmonyOS 时 compileSdkVersion 不需要显式配置
    (默认用 DevEco 内置 SDK),且只能配成本机配套的 SDK 版本;targetSdkVersion 未配则默认与
    compileSdkVersion 一致。所以通常只改 compatibleSdkVersion + targetSdkVersion 两个字段。
  • 第三个错误码 00303015(IDE sync 比 CLI 构建更严,务必单独验):
    hvigor ERROR: 00303015 Configuration Error
    Error Message: In the project-level configuration file build-profile.json5, the version
    relationship between compileSdkVersion, compatibleSdkVersion and targetSdkVersion is incorrect.
    * Configure the API version according to the rule of
      compatibleSdkVersion <= targetSdkVersion <= compileSdkVersion.
    • 触发场景(本项目真实踩到):把 SDK 升到 26 时只改了 compatibleSdkVersion 为 "26.0.0",
      而 targetSdkVersion 还留着 "6.1.1(24)"
      → compatible(26) > target(24) → 违反规则。
      升版本必须两个字段一起改,改半截必挂。
    • 校验实现(hvigor-ohos-plugin/src/utils/one-sdk-validator.js 的 apiInspection)末行即:
      (c <= d && c <= p && d <= p) || printErrorExit("SDKVERSION_ORDER_ERROR")
      —— c=compatible.version、d=target.version、p=compile.version
    • 重要:compileSdkVersion 未配置时由 IDE 解析到的 SDK 决定,
      因此同一个工程「CLI 构建通过」不代表「IDE sync 通过」(两者可能解析到不同 SDK)。
    • 排查姿势:先 hvigorw.js --sync -p product=default --no-daemon 复现(CLI 可直接看到该错误),
      而不是只在 IDE 里反复点 Sync。
  • 口诀:"先读 sdk-pkg.json,再按 26 分界选写法:26 前带 (api),26 起纯三段数字";
    "升 SDK 三个版本字段要一起动,且 sync 与 build 都要各验一次"

LSN-022 沉浸光感材质(Immersive Material):承载层五条属性 + 四条层级规则 + 三档降级

  • 风险:🟠 中频;材质不生效、不刷新、不报错——所有问题都只能靠真机截图发现
  • 来源:2026-09-17 AssetDailyCost 全量接入(卡片/hero/弹窗/chips)+ 官方《沉浸光感常见问题》
  • API 侦察先行:@ohos.arkui.uiMaterial(经 @kit.ArkUI 导出,API 26+)——
    ImmersiveMaterial({style, materialColor, colorInvert, applyShadow, interactive, lightEffect})、
    isImmersiveMaterialSupported()、getMaterialInfo(){state,type}。
    应用级前置:module.json5 metadata ohos.arkui.UIMaterial.state="enable"(模板已自带;改动后必须重装)
  • 承载层只能用 ToggleType.Button:官方文档明确 Checkbox 未适配材质、
    Switch 只覆盖开关本体那一小片;只有 Button 影响背景/边框/阴影,才能得到一整块矩形材质
  • 五条属性缺一不可(模板 components/MaterialCard/MaterialCardLayer 已内置,逐条注释了缺失后果):
    ① LayoutPolicy.matchParent(百分比按"父级可用尺寸"解析,卡片在列表里会把卡片顶大)
    ② borderRadius 与外壳一致(否则 Toggle 画出自己的小圆角描边)
    ③ backgroundColor(Color.Transparent)(不透明底色垫在材质之下,叠灰甚至完全盖住)
    ④ enabled(false)+focusable(false)+accessibilityLevel('no')+hitTestBehavior(None)(只是画布)
    ⑤ attributeModifier 放属性链最末(systemMaterial 要在其他样式属性之后设置)
  • 四条层级规则(官方硬规则,逐条踩过):
    ① 材质视觉层级位于 backgroundColor/backgroundBlurStyle 之下 → 承载层与外壳必须透明
    ② systemMaterial 放其他样式属性之后 → 用 attributeModifier 挂链末
    ③ 材质不采样同一 Stack 的兄弟节点(采样组件背后的窗口内容)→ 内容画在材质之上
    ④ 弹窗面板不例外 → bindSheet(..., { backgroundColor: Color.Transparent }),否则默认白底盖住
  • attributeModifier 必须写成方法调用(this.carrierModifier())并在 applyNormalAttribute
    里读订阅变量(组件用 @StorageLink 持有开关值)——写成内联对象字面量 →
    拨开关后已画出来的组件不更新;函数内直接读 AppStorage 也登记不上依赖
  • interactive: true 配空 lightEffect: {} → 材质完全不渲染:无流光色时省略该字段
  • 惰性构造:export const X = uiMaterial.ImmersiveStyle.THIN 模块级求值会在低版本设备
    启动即闪退 → 一律包在函数里;构造失败返回 null(别用 Material.empty 伪装成功)
  • 缓存分档:静态带影 / 静态无影 / 可交互按流光色存 Map —— 共用一档会出现
    「关掉阴影的那档把带阴影的覆盖了」
  • 降级三件事(API<26 / 应用开关关 / 系统总开关 DISABLE):外壳模糊回 Thin +
    半透明底色(fallbackSurface,约 70% 不透明——不透明底色会把模糊盖住)+ 不下发材质
  • 分形态接入(模板已验证):普通卡片=MaterialCard{内容};小控件(chips/按钮)=
    Stack{MaterialCardLayer(同控件圆角, layerShadow:false)+控件}(chip 选中态保持实色深底);
    hero 渐变卡=Stack{材质层+内容层(半透明品牌渐变沾色)}——沾色放内容层自身 linearGradient
    (不能放外壳 backgroundColor,会被材质层级盖住),材质关时三元回退不透明渐变
  • ⚠️ 验证姿势:材质是否生效无法读回,只能真机截图看视觉结果(承载层区域应通透而非纯色);
    建议在设置页放诊断行(materialDiagnostics():材质支持/构造/系统状态/应用开关/承载 五段摊开)
  • 模板内置文件:services/SystemMaterial.ets(惰性构造/三档缓存/applyMaterialCarrier/
    pressLightColor/fallbackSurface/诊断)+ components/MaterialCard/(MaterialCardLayer + MaterialCard)
    • module.json5 metadata。键名约定:settings_forceMaterial(默认 true,须与 KEY_FORCE_MATERIAL 一致)

LSN-023 自定义壁纸:PhotoViewPicker 的 photoUris + 三处链路重放

  • 风险:🟠 中频;选择器字段名写错直接编译失败,链路漏放则「重启/旋屏后壁纸失效」
  • 来源:2026-09-17 AssetDailyCost 新增功能,真机全流程走通
  • 选择器:photoAccessHelper.PhotoViewPicker(免权限)——
    ① 字段名是 photoUris(PhotoSelectResult 是 class,定义在 openharmony 侧
    @ohos.file.photoAccessHelper.d.ts,不是 hms 目录;网络资料常误写 uris)
    ② PhotoSelectOptions 对象字面量必须显式类型标注(arkts-no-untyped-obj-literals)
    ③ ⚠️ 反例(2026-09-29 修正):不要写「picker 返回的 URI 自带永久授权,persist/activate 仅作保险」——
    这只对「本次进程内」成立。把 URI 存起来跨重启再用(书库/音乐库这类"存原始 URI 不复制文件"的
    场景),持久授权必须每次启动重新 activatePermission,否则 openSync(uri) 抛
    13900001(EPERM)。详见 LSN-030(本条曾误导 Icey-Reader 的阅读器漏掉启动激活 → 重启后白屏)
  • 持久化:SettingsManager 五步新增 customBgUri(键 settings_customBgUri);
    键名与模板 SubPageScaffold 的既有订阅一致 → 子页自动获得同一壁纸,零成本
  • 主题联动:ThemeManager.setCustomBg(has) —— 有壁纸时页面背景色变半透明
    (约 70%,背景色退化为压暗层保证文字可读);不动深浅模式
    (applyThemeMode 里按图片亮度联动的分支保持 dormant,需要时再启用)
  • 渲染:根 Stack 最底层 Image(uri).objectFit(Cover).width('100%').height('100%') .expandSafeArea([SafeAreaType.SYSTEM],[TOP,BOTTOM])——有壁纸时既有的
    .backgroundColor(colors.background) 自动变半透明压暗层,无需改
  • ⚠️ 三处链路必须重放壁纸状态(漏一处就「某场景下壁纸消失」):
    ① App 启动(在 applyThemeMode 之后调 setCustomBg)
    ② EntryAbility.onConfigurationUpdate——updateFromConfig 重算 colors 会把背景打回不透明,必须补放
    ③ 设置变更(setter 内联动)
  • 与光感的协同:壁纸是材质的「最佳舞台」——玻璃卡采样窗口背后的壁纸,零适配即得毛玻璃效果;
    hero 卡半透明沾色同样透出壁纸
  • ⚠️ 两组载体各自的「蒙版」来源——必须都改成透明,否则壁纸被压白(用户口语:「白色蒙版」):
    载体 ① 页面底色 ② 标题栏滚动蒙层
    主界面(tab) Index 的 HdsNavigation.backgroundColor(壁纸时 = colors.background #B3F1F3F5 ≈70% 白,整屏压暗) buildHomeTitleBar 的 scrollEffectStyle.backgroundColor = #CCFFFFFF(浅) / #CC000000(深) —— 80% 白块
    子页(push) SubPageScaffold 已写成 customBgUri ? Color.Transparent : colors.background buildSubPageTitleBar(..., transparentScrollMask = true, ...) → #00000000
    子页两条链默认就是透明的,主界面极易漏(本模板即出现「子页干净、主界面发白」的不一致)。
    做法:HdsNavigation.backgroundColor 改条件透明 + 给 buildHomeTitleBar 加 transparentScrollMask 形参并传
    wallpaperUri.length > 0(口径与子页同名参数对齐)。
    ⚠️ 排查注意:HdsNavigation 的属性链上可能重复写了两次 backgroundColor(先 Transparent 后 colors.background,
    后写生效)——要改透明的是最后那一次,只删前面那次没有任何效果。
    ⚠️ 根 Stack 的底色保留不透明:它在壁纸 Image 之下(Stack 自身底色先于子节点绘制),
    稳态不遮挡,仅作壁纸解码期间的兜底底色(避免首帧露 window 底色)。
  • 模板内置依赖:services/FilePermission.ets(persist/activate,分批+错误明细)
    • components/SubPageScaffold(子页壁纸渲染,模板自带)

LSN-024 资源引用($media/$color/$string)与文件不成对 → 构建报错且指向中间产物

  • 风险:🔴 高频;报错路径是 entry/build/.../module.json(中间产物),
    看不到真实源头,极易在错误的位置排查
  • 场景:2026-09-17 模板构建报 The resource reference '$media:tab_music' is not defined At file: ...\entry\build\default\intermediates\process_profile\default\module.json——
    真实源头是 src/main/module.json5 里残留的旧图标引用,而 resources/base/media/ 已被精简
  • 防御:引用与资源成对检查——改完先
    grep -rn '\$media:' --include="*.json5" src/main/ 列出全部引用,
    再 ls resources/base/media/ 核对;报错指向 build 目录时直接去 src/main 找同名引用
  • 附带教训:模板/工程每次精简资源后必须跑一次完整构建——资源阶段(CompileResource)
    在 ArkTS 编译之前,资源坏了会掩盖后面的所有错误信息

LSN-025 「实色大卡」改「多卡光感」时的三件事:控件可辨性 / 属性链位置 / 分卡边界

  • 风险:🟠 中频。①是纯视觉缺陷(编译全绿、不报错,只有真机截图才看得出);
    ②直接编译失败但报错点误导;③只是改动量问题
  • 来源:2026-09-18 AssetDailyCost 新增/编辑表单——由「单张实色 cardBg 大卡」改为
    「8 张 MaterialCard + 强制光感」,卡片节奏与「趋势」tab 对齐(用户要求「样式参照趋势界面,采用多卡片」)

① 卡内控件可辨性(最容易漏,也最容易被用户一眼看出)
实色卡时代,卡内控件靠 surface(#F5F5F5) 填充与 cardBg(#FFFFFF) 的亮度差划定边界;
换成光感材质(或降级毛玻璃)后卡面亮度 ≈ 页面背景,浅填充与卡面几乎同色 ⇒ 输入框/开关行/分段控件"消失"。
两种修法:

  • (a) 加 0.5vp 发丝描边(推荐):复用现成的 colors.fieldBorder(浅色 #1A000000 10% 黑 /
    深色 #33FFFFFF 20% 白)。实色填充保留不动 ⇒ 输入文字对比度不受材质影响,深浅两种模式都成立
  • (b) 把填充改成比卡面更亮的 cardBg:依赖卡面实际亮度,材质/降级两条路径表现不一致,不如描边稳

⚠️ 次级按钮同理,且更隐蔽:surface 填充的按钮直接贴在页面背景上在浅色下几乎不可见
(#F2F3F4 vs #F5F5F5);原先"按钮贴在实色大卡里"之所以正常,只是因为卡片是 cardBg(#FFFFFF)。
⇒ 操作区(取消/保存/删除等)必须留在卡片内,或同样加描边;主按钮(brand 色实心)不受影响。
原注释「表单卡片用实色 cardBg 保证输入可读性」这一取舍,在多卡 + 光感方案下应改写为
"实色填充 + 发丝描边"。

⚠️ 只读内容块(备注 / 说明文字)同样要"承载":玻璃卡面上直接铺一段正文,深浅两色都可能不够清晰;
应给内容套一层与输入框同款的容器(surface 底 + borderRadius 12 + 0.5vp colors.fieldBorder 描边)。
好处是"同一份内容在表单页是 TextArea、在详情页是同款承载",视觉语言一致、切换无割裂。

⚠️ 字段为空时不要整块隐藏(用户会分不清"没填"与"不支持"):详情/只读页的字段卡应常驻,
空值给占位文案(如「暂无备注」)。本工程实证:详情页备注卡原为"无备注不渲染",
用户随即报「详情页要能显示备注」——隐藏掉一整栏的代价是用户不知道这一栏存在。
(表单/编辑页相反:空字段不渲染更清爽,因为没有"这一栏存在与否"的疑问。)

② 属性链位置:报错点比真实元凶低一层
给已有嵌套结构插入卡片边界时,内层容器的属性链必须写在
内层容器 } 之后、外层自定义组件 } 之前:

ListItem() {
  MaterialCard({ cardRadius: 20, cardMarginTop: 12 }) {
    Column({ space: 12 }) { ...字段... }
    .width('100%')            // ✅ 修饰符跟着内层容器走
    .padding(16)
  }                           // ← 这里才结束 MaterialCard
}

写成 }(内层)→ }(外层)→ .width(...) 会报
10505001 Declaration or statement expected + Cannot find name 'width',
报错落在那个 } 的下一行,与真实元凶差一层,极易去查无关的地方。
与附录 D7(自定义组件后链式挂 bindSheet)同源:自定义组件(含 @BuilderParam 尾随 lambda 的组件)
闭括号之后不能再挂属性链
。
自查手段:改完 grep -n '^\s*}\s*$' -A2 或直接读改动的边界块——} 后面若紧跟 .xxx( 就是本坑。

③ 分卡边界直接取原有「区块标题」
长表单改多卡时,以原 sectionTitle('xxx') 调用点作为切割锚点——一个标题 = 一张卡的标题,
改动量最小、语义不变。两个附带事实:

  • if (分支) { ListItem() ... } 里可以放多个 ListItem(同一分支拆多张卡无需额外包裹),
    List 的 space 只对实际渲染出的 ListItem 生效(分支不成立时不产生空档)
  • 卡标题样式统一成卡片规格(14 / Medium / textPrimary / width 100%)——
    原先区块标题常用的 12 / 次级色在"每块都是一张卡"的语境下层级不足
  • 卡片间距口径与参照页保持一致:List({ space: 12 }) + MaterialCard.cardMarginTop: 12

口诀:"卡从实色换玻璃,控件靠描边找边界、按钮留在卡内;属性链挂内层容器;标题即卡界"


LSN-026 沉浸光感必须有「可采样底层」:纯色背景上必然退化为灰玻璃

  • 风险:🟠 中频,纯视觉缺陷、编译全绿,只有真机截图能发现;且默认状态就会命中
    (模板/大多数用户初始都没有自定义壁纸)
  • 来源:2026-09-18 AssetDailyCost 真机反馈——「强制光感没有自定义背景图的时候,应该变回白色」

现象:没有设置自定义壁纸时打开「强制光感」,卡片不是通透的实色卡,
而是一块与页面底色几乎相同的灰玻璃(浅色下只剩一圈模糊边)。
比不开材质的实色白卡更差,看起来像"卡片没画出来"。

根因(一句话):材质采样的对象是组件背后的窗口内容(LSN-022 规则③:不采样同 Stack 兄弟节点)。
应用在无壁纸时页面背景是一整片纯色(colors.background)⇒ 材质与毛玻璃背后没有任何信息可采,
只能把纯色再模糊一遍 ⇒ 卡面 ≈ 页面底色。

⇒ 材质的观感依赖「背后有内容」。没有可采样底层时,材质不是"低配玻璃",而是"错误的视觉"。

修法(四步,缺任一都会出现"只有一部分卡片正常")

  1. 判定独立成形参:materialActive(forceOn) → materialActive(forceOn, backdrop)
    (本工程底层来源 = 自定义壁纸 ⇒ backdropAvailable(customBgUri) + KEY_CUSTOM_BG_URI)。
    做成第二形参而非函数内部读 AppStorage:既满足"依赖必须由调用方传订阅值"的局部更新规则,
    又能让编译器强制所有调用点表态——漏改即报参数数量不匹配,不会静默漏掉某个卡片形态。
    (本工程首改即 6 个调用点:MaterialCard / SheetContainer / AppSheet /
    AssetSummaryCard / AssetTab / WishTab)
  2. 回落实色,而不是"降级模糊":无底层时 .backgroundColor(cardBg)(浅色白 / 深色 #212121)
    且 backgroundBlurStyle(NONE)——背后是纯色,模糊不仅无收益还会拖性能。
    注意这多出一态:原来的两态(材质 / 半透明+模糊)变成三态
    (材质 / 有底层但材质不可用=半透明+模糊 / 无底层=实色)
  3. 同源控件一律同步(否则用户会看到"有的卡白了、有的还是灰的"):
    • GlassCard / SettingsGroupCard 等 MaterialCard 派生组件:自动继承,无需改
    • hero 卡(材质 + 半透明沾色):无底层 ⇒ 沾色改完全不透明(品牌色不再被玻璃洗淡)
    • chips / 排序按钮:无底层 ⇒ 实色 cardBg
    • 弹窗卡片:无底层 ⇒ 实色 cardBg,且弹窗宿主的底也要改回不透明
      (否则弹窗卡片实色了、弹窗外壳还透明,会透出底层内容)
  4. 诊断串加一档:materialDiagnostics() 输出「底层=自定义壁纸 / 无」,
    并把承载结论细化为「下发材质 / 降级毛玻璃 / 实色卡片(无壁纸)」,避免"开关打开却看不出效果"的困惑

通用推论(不限于壁纸):任何"采样背景"的视觉能力(材质、毛玻璃、backgroundEffect)
都隐含前置条件——背后得有东西。设计这类开关时应当同时定义它的回退形态,
并在设置项副标题里写清前置条件:本工程即改为
「有自定义壁纸时卡片走系统「沉浸光感」材质,无壁纸时回落实色卡片」。

口诀:"玻璃要看得见,背后先得有东西;没有底层就回实色,别把纯色糊一遍"


LSN-027 长按手势:Stack/Column 没有 .onLongPress 修饰符,必须用 LongPressGesture + .gesture()

  • 风险:🟡 低频但必踩一次——写"长按"时直觉会去写 .onLongPress(...),
    该修饰符在容器组件上不存在,直接编译失败;且失败后若去"补 import",会踩第二个错
  • 来源:2026-09-18 AssetDailyCost——用户要求「列表卡片改为长按编辑」,连报两种编译错误后定稿

两个连环编译错误(现场记录)

  1. .onLongPress(() => {...}) 挂在 Stack / Column 上:
    10505001 Property 'onLongPress' does not exist on type 'StackAttribute'(换 Column 报 'ColumnAttribute')。
    ⇒ API 26 的 StackAttribute / ColumnAttribute 均无 onLongPress 成员,不是拼写问题。
  2. 于是改 import { LongPressGesture } from '@kit.ArkUI':
    10311006 'LongPressGesture' is not exported from Kit '@kit.ArkUI'。
    ⇒ LongPressGesture 属于 ArkUI 全局声明,本来就无需 import;从 kit 里导反而报错。

正确写法(手势描述符 + .gesture())

// 不要 import LongPressGesture —— 它是全局声明
Stack() { /* 内容 */ }
  .width('100%')
  .gesture(
    LongPressGesture({ duration: 500 })   // 默认 500ms 即系统长按阈值
      .onAction(() => { this.onLongPress(); })
  )
  • 挂载点选容器根节点(如卡片外壳的根 Stack):手势判定范围 = 该组件矩形;
    内部若有"不吃手势"的承载层(hitTestBehavior(HitTestMode.None) + enabled(false),
    如 MaterialCardLayer)不会拦截,整卡区域都能触发
  • .gesture() 默认 GesturePriority.Normal:与宿主 List 的滚动手势可共存——
    长按要求"按住不动 500ms",滚动要求"位移",二者判定条件互斥,列表内长按可正常工作
  • 需要"长按后持续触发"才加 repeat: true(如连发按钮),编辑入口用默认单次即可

与 .onClick 的取舍:把交互从点击改为长按 = .onClick 整段替换为 .gesture(LongPressGesture)。
改完后:

  • 短按不再有响应(这正是"防误触浏览列表"要的效果)
  • 回调属性建议同步改名(如 onCardTap → onLongPress),让调用点漏改编译失败而不是静默沿用旧语义
  • 复用组件(本工程 MaterialCard 有 12+ 调用点)应把回调做成带默认空实现的可选属性
    (onLongPress: () => void = () => {}),不传的调用点零副作用

口诀:"长按不用 onLongPress,LongPressGesture 不用 import、配 .gesture()"


LSN-028 同一组件「点击 + 长按」必须绑成 GestureGroup(Exclusive);详情页刷新走 @State 视图模型

  • 风险:🟠 中频——三处都编译全绿、只在真机暴露:① 长按后双跳(进编辑页又跳详情页);
    ② 编辑保存返回详情页还显示旧值;③ ForEach 列表值变了界面不更新
  • 来源:2026-09-18 AssetDailyCost——用户要求「长按编辑」后再要求「点击查看详情」,一轮内三坑齐现

坑 ①:.onClick 与 .gesture(LongPressGesture) 并存 → 长按抬手后点击也触发
一次「长按进编辑页」,抬手又被点击命中「跳详情页」⇒ 路由栈里连进两页,用户看到的是莫名其妙的跳转。
正确写法 = 互斥手势组(保证"先满足条件者胜出"):

.gesture(
  GestureGroup(GestureMode.Exclusive,
    LongPressGesture({ duration: 500 }).onAction(() => { this.onLongPress(); }),
    TapGesture({ count: 1 }).onAction(() => { this.onTap(); })
  )
)
  • 按住 500ms → 长按命中、点击判负;快速抬手 → 点击命中、长按判负
  • 回调属性仍建议成对提供并带默认空实现(onTap / onLongPress),未传的调用点零副作用
  • ❌ 不要用 GestureMode.Parallel(并行 = 两个都触发);❌ 不要写两个 .gesture() 叠加

坑 ②:子页需要「外部数据变更后自动刷新」时,别用「build 现算 + 普通字段缓存」
把重算结果放进非状态字段(private cache)再在 build() 里读,刷新链变成
「@StorageLink 变化 → 父组件重渲染 → 会不会顺带重跑 @Builder」——后半段是不确定行为,
表现为「编辑保存返回后详情页仍是旧值,退出重进才对」。
正确写法 = @State 持有视图模型 + @Watch 驱动重算(@Watch 必须写在状态装饰器之前,见 LSN-012):

@Watch('reload') @StorageLink('assetsVersion') assetsVersion: number = 0;
@State view: DetailView = DetailView.of(null, [], 0);
reload(): void { this.view = DetailView.of(this.row(), AssetRepository.all(), Date.now()); }

配套两条:

  • 入参只取 id、不持有记录快照:快照会被编辑页替换掉,页面永远读 repo.find(id);
    find 返回 null ⇒ 渲染「记录不存在」兜底卡(不要在 build 里 pop,也不要不处理)
  • 口径仍归 model(LSN-015):DetailView 只做文案拼装,日均/进度/达标/汇总一律调既有 model 方法
    —— 视图模型可以是页面内私有 class(同 SegmentsParams 范式),但不许复算业务

坑 ③:ForEach 的 key 只写"身份"不写"值" → 值变了不刷新
ForEach 对 key 相同的项复用既有节点、不重跑 @Builder。明细行若用 kv_${label} 作 key,
「已使用 / 目标成本 / 已存金额」这类行还在、值变了的就永远停在旧值(最隐蔽的一种)。
对策:key 里带上会变的值(必要时连"是否最后一行/是否强调"一起带):

ForEach(v.rows, (r: KvRow) => { this.kvRow(r) },
  (r: KvRow) => `kv_${r.label}_${r.value}_${r.accent}_${r.isLast}`)

口诀:"ForEach 的 key 要能代表'这一屏长什么样',代表不了就会赖着不更新"

口诀:"点击长按绑一组、子页刷新用 @State、ForEach 的 key 带上值"


LSN-029 .gitignore 签名约定:signing/ 必须进仓库,<mode>/material/ 也必须进(2026-10-08 重大更正)

  • 风险:🔴 仓库里一个签名文件都没有 ⇒ 新机器 clone 后 assembleHap 产不出可安装包
    (entry-default-unsigned.hap / 安装报 9568320 no signature file),
    也无法复现签名类问题(00303074 p7b bundle-name 不匹配等)。
    排查时才发现"拿不到 profile,只能回头补提交"。

  • 场景一:2026-09-29 Icey-Reader —— 照抄本 Skill 内置模板的 .gitignore,
    其第 13 行是裸的 signing/ ⇒ 整个签名目录被 ignore,git status 永远看不见;
    用户复核 commit 时发现并纠正。

  • 场景二(本条更正来源):2026-10-08 BiliDownload-Harmony —— 我给模板补了
    /signing/debug/material/ 这条忽略规则,方向反了。用户换机/重打包时
    assembleHap 在 SignHap 阶段连续失败,逐层试出三种报错,读 hvigor 源码才定位:

    现象 报错 含义
    material 目录缺失 00308018 + ENOENT: no such file or directory, stat '.../signing/debug/material' 找不到解密材料
    material 目录存在但为空 00304032 "Signing materials ... is an empty directory" 材料为空
    material 与密文不配对 00303242 "unable to authenticate data"(AES-GCM 认证失败) 材料对不上密文
  • signing/<mode>/material/ 的真实性质(读 hvigor-ohos-plugin/src/utils/decipher-util.js 确认):
    它是解密 build-profile.json5 里密码密文所需的密钥材料,结构为

    fd/{0,1,2}   3 × 16B   密钥分量
    ac/          16B       salt
    ce/          48B       AES-128-GCM 包裹的工作密钥(解出 16B 实际 key)
    派生:rootKey = pbkdf2( xor(fd0,fd1,fd2, 固定component常量).toString(), salt, 10000, 16, sha256 )
          key     = AES-128-GCM-decrypt(rootKey, ce内容)
          明文    = AES-128-GCM-decrypt(key, hex解码后的密文)

    ⇒ 它由 DevEco IDE 首次配置签名时生成,hvigorw CLI 只有读取逻辑、不会重建
    (源码里只有 decryptPwd/getKey,无任何生成分支);
    且与密文一一配对(算法内不含项目路径,故两者是"可移植的配对")。
    因此它必须入库,否则新机器须先开一次 IDE 才能构建。

  • 定案规则(与 Icey-Player-Harmony 实测一致):

    **/.test
    /.appanalyzer
    
    # 签名文件:**不忽略,随仓库版本化**(务必不要写成 `signing/`)
    # p7b / cer / p12 / csr 与 <mode>/material/ 都是"可复现构建"的必要部分
    /signing/material/          # ← 只忽略根级(另一套缓存)
    路径 是否版本化 理由
    signing/debug/*.p7b .cer .p12 .csr ✅ 进仓库 唯一能让新机器构建出可安装 HAP 的材料
    signing/release.* ✅ 进仓库 同上(发布链)
    signing/debug/material/** ✅ 进仓库 ★ 解密密码密文的密钥材料,IDE 生成、CLI 不重建;缺它 SignHap 直接失败
    signing/material/**(根级) ❌ 忽略 与 debug 下的用途不同,是另一套缓存
  • ⚠️ 权威证据:Icey-Player-Harmony(可正常打包上线)的 .gitignore 只写了
    /signing/material/
    ,其 signing/debug/material/ 的 5 个文件全部入库、一直可正常签名。
    —— 这不是"漏写",是正确做法。(我 2026-09-29 曾把它误判为"参考项目漏写",
    并据此让模板多忽略一条,属反向修改,已更正。)

  • ⚠️ 判定口径:git check-ignore <目录> 对目录路径的判定不可靠
    (目录返回 TRACKED,但其下文件实际已被忽略);
    验证一律用 git status --porcelain + git ls-files | grep material(权威)。

  • 口诀:"签名材料全进仓库,只忽略根级 material;check-ignore 不可信,用 ls-files 验"

  • 验证动作(换机后想确认环境完好,跑这两条):

    git ls-files signing/            # 应看到 p7b/cer/p12/csr + debug/material 的 5 个哈希文件
    # 若签名仍失败,按报错对症:
    #   ENOENT / 00308018         -> material 目录没了(补提交或开一次 IDE 重新生成)
    #   00304032 空目录            -> 目录在但为空
    #   00303242 认证失败          -> material 与 build-profile.json5 密文不配对(两者要一起换)

LSN-030 存 URI 不复制文件 ⇒ 每次启动必须重新 activatePermission(漏掉=重启后静默白屏)

  • 风险:🔴 高危且极难定位 —— 症状是"导入当次完全正常,杀进程重开就白屏",
    代码里没有任何报错,画布只剩一层底色;不看日志完全想不到是权限。
  • 场景:2026-09-29/30 Icey-Reader(本地电子书阅读器):用户两次报"进入阅读页白屏"。
    前几轮我一直在怀疑 canvasW 尺寸 / 排版口径 / px2vp(都不成立,见 LSN-029 上一条的排查史)。
    真因是剪裁模板时漏抄了权限激活。
  • 机理(三步锁死):
    1. 本地书/音乐不拷贝进沙箱(省空间 + 秒导入)⇒ 仓库里存的是导入时的原始 URI
      (file://docs/storage/.../x.txt),正文靠 fileShare 持久授权直读原始 URI。
    2. fileShare.persistPermission(policies) 授予的权限跨应用重启不会自动生效,
      必须每次启动对每个 URI 调 fileShare.activatePermission(policies)。
      ⚠️ 「picker 返回的 URI 自带永久授权」只在本次进程内成立(LSN-023 旧文曾据此写成"仅作保险",已修正)。
    3. 漏掉第 2 步 ⇒ 重启后 fileIo.openSync(原始URI) 抛 13900001 EPERM ⇒
      读取函数的 catch 只 AppLogger.error 就 return(静默)⇒ 渲染层拿到空内容 ⇒
      drawPageContent(ctx, undefined) 只填了个底色 ⇒ 用户看到纯色白屏。
  • 指纹(怎么一眼认出它):
    现象 说明
    导入当次能看,重开就白屏 ★ 最强指纹:导入时 picker 刚授的权还在
    冷启动后所有"历史数据"都打不开 但新建的(本次导入的)正常
    日志出现 13900001 / EPERM / PERMISSION_NOT_PERSISTED(4) 直接坐实
  • 正解(两件事都要做,缺一仍复发):
    // ① 启动恢复链路里复激活(EntryAbility.startupReady 内,数据仓库 init 之后)
    await BookRepo.activatePermissions();   // 遍历全部 bookUrl 调 FilePermission.activate(uris)
    
    // 仓库侧(BookRepo / MediaScanner 同款)
    static async activatePermissions(): Promise<void> {
      const uris = BookRepo.books.map(b => b.bookUrl).filter(u => u.length > 0);
      if (uris.length === 0) return;
      try { await FilePermission.activate(uris); }        // 内部分批(≤200) + 逐 URI 错误明细
      catch (e) { AppLogger.error(TAG, `activatePermissions 失败: ${JSON.stringify(e)}`); }
    }
    // ② 读取路径再加"单 URI 兜底"(覆盖"本次导入但 persist 失败"/"用户刚移动过文件")
    try { await FilePermission.ensureActive(uri); } catch (_) {}   // 内部 activate 失败会 persist+activate 重试
  • 参考实现:Icey-Player-Harmony/services/MediaScanner.ets:540 注释即写着
    「存原始 URI,重启后通过 activatePermission 恢复访问,不复制文件」,并在 :152/:624 分批激活。
    本项目模板 services/FilePermission.ets 已自带 persist/activate/ensureActive/waitActivateDone ——
    剪裁模板时"删掉没被引用到的服务"很容易把它连带删掉,务必保留。
  • 配套原则(本轮一并确立):绝不静默失败。凡是"内容读到空 ⇒ 界面整片空"的路径
    (记录缺失 / 目录为空 / 正文 0 字 / 排版 0 页 / 读文件抛异常),都必须落到**可见错误态
    • 重试入口**,而不是只打日志。这次白屏之所以拖了两轮才定位,根因就是失败被静默吞掉。
  • 口诀:"存 URI 不复制 ⇒ 每次启动 activate;导入能看重开白 ⇒ 先查 13900001"

LSN-031 Canvas 自绘正文要支持「长按划选」+ 功能面横向铺开(划选命中 / 手势分流 / 跨页返回)

  • 场景:2026-09-30 Icey-Reader:用户要求「照着 legado 把该做的功能都做了」——
    阅读页一次性补齐 书签 / 笔记 / 划选 / 书内搜索 / 阅读统计 / 书架分组 / 排版扩展。
    其中长按划选是唯一需要"在 Canvas 上做字符命中测试"的能力;其余几个坑都是
    一次横铺多个能力时才暴露的(这类"能力面对齐"任务建议一次做完 + 一次编译,比零敲碎打省事)。
  • 坑① Canvas 自绘文本的字符命中(最容易写错,也最值得一次写对):
    • ❌ 错法:用「局部 y / 页高」或「x / 行宽」的比例线性估字符下标 ——
      行高、字宽随字号 / 字距 / 段距变化,估算必然错位(多选一个字、少选一个字、跨行乱选)。
    • ✅ 正解(三层收敛,逐层都用真实数据、不用比例):
      1. localX/px2vp − 页边距 ⇒ 得到页内容区坐标
        (⚠️ 别拿画布原始坐标去比字形坐标,页边距会整体偏移);
      2. 按行的 y 区间定位到行:行高 = 字号 × lineSpacing,且标题行字号 +2
        (标题与正文行高不同;只用正文行高 ⇒ 从标题之后开始整页错位一整行);
      3. 行内按字形 x 找最后一个「左缘 ≤ cx」的字形;若已越过该字形右缘中点则 +1;
        章内字符偏移 = line.charStart + idx(注意是章内偏移,不是页内偏移)。
    • ★ 关键前提:排版产物必须每行都带 charStart(章内起始偏移)。若排版结果只有
      一个字符串(无偏移表)⇒ 无法映射回章内位置 ⇒ 划选只能拿到"屏幕上一段",无法与
      书签 / 笔记的 chapterPos 对齐
      。排版期就把偏移存下来,别在交互期反推。
  • 坑② 划选必须与翻页手势「同一手势分流」,不要再加一个手势:
    阅读页已用 PanGesture 做翻页;再挂独立 PanGesture / 手势组做划选 ⇒ 两者争抢,
    出现"想选字结果翻页"。正解 = 复用同一个 Pan,按状态分流:
    .gesture(PanGesture()
      .onActionStart(e  => { if (this.selecting) this.extendSelect(e); })
      .onActionUpdate(e => { if (this.selecting) this.extendSelect(e); })          // selecting ⇒ 扩选
      .onActionEnd(e    => { if (this.selecting) return; this.handlePageSwipe(e); })// 否则 ⇒ 翻页
    )
    进入划选的入口是长按(LongPressGesture({ repeat: false, duration: 400 }) → beginSelect);
    退出用页面点击(selecting 态下 exitSelect() 后 直接 return,不要再触发翻页 / 呼出菜单)。
  • 坑③ 划选底衬要「逐行画」,不能把章内偏移当页面坐标:
    选中区间是章内字符偏移 [selStart, selEnd),而绘制发生在当前页坐标系里。
    正解 = 遍历当前页每一行,求行与选中区间的交集,逐行铺半透明色块
    (ctx.fillRect(行左缘, 行顶, 按交集字符数折算的行宽, 行高));直接拿 selStart/selEnd
    当 x/y 去画会跑到页外。色块用 accent 色 alpha≈0.22 半透明,叠在文字之下。
  • 坑④ 划选浮动条 / 长按菜单「只放文字项」:浮条 position({ x: 16, y: 96 }) 固定贴顶,
    底色 #E6000000,项 = 「笔记 / 复制 / 取消」纯文字(不引图标);
    破坏性项(删除笔记)用红色文字表达,不靠图标 —— 与本项目一贯 UI 口径一致(§2)。
  • 坑⑤ 从「管理页」返回阅读页:栈里不一定有阅读页:
    书签列表 / 搜索结果可能从书架 → 书籍详情页直接进入,此时路由栈里没有阅读页,
    一律 pop() 会退到错页(详情页)而不是阅读页。正解 = 先判存在再决定:
    AppRouter.getInstance().has(ROUTE_READER) ? pop() : push(ROUTE_READER, bookUrl);
    // has(name) = pathStack.getAllPathName().indexOf(name) >= 0
    配套:搜索页与书签页共用一条跳转信号(@StorageLink('readerSearchJump')),
    阅读页 @Watch 消费后立即清空信号(否则同一条会被反复触发)。
  • 配套:功能面横向铺开时的 3 个必查项(本轮全部踩到):
    1. $r('sys.symbol.xxx') 必须先核实存在 —— 符号名写错不报错、只是渲染空白(LSN-020)。
      查 <DevEco>/sdk/default/openharmony/toolchains/id_defined.json 里 "type":"symbol" 的记录。
      本轮实测存在:bookmark / bookmark_fill / bookmark_notepad / magnifyingglass /
      timer / folder / xmark_circle_fill / square_and_pencil / trash /
      exclamationmark_circle / arrow_counterclockwise;
      不存在:bookmark_square / note_text / highlighter / chart_bar / text_quote / pencil。
    2. 单文件内同一模块只写一行 import —— import { AppRouter } 与 import { ROUTE_SEARCH }
      各写一行 ⇒ 视同重复声明报错;合并成 import { AppRouter, ROUTE_SEARCH } from '...'。
    3. 新增成员一旦写到 struct 的 } 之后,会引发几十条级联假错误(见坑点速查表):
      报 Property 'xxx' does not exist on type 'Xxx' + UI component 'Column' cannot be used in this place + Cannot find name 'b'。元凶在报错点之外 —— 用脚本扫「独占一行的 }」
      定位那个提前闭合处(本工程实证在 615 行),把成员移回结构体内即可,别按报错逐条补。
  • 口诀:"划选三层收敛(局部→行→字形),手势同源分流;跨页先问 has,符号先查 id_defined"

LSN-032 设置页「间距四铁律」+ 两套卡片不可混用(2026-10-08 用户定案,模板已同步)

用户验收原话:「我的页面的设置卡片啥的细节也和 player 项目等不统一,设置的子页面也是,完全没统一」。
统一后定案成四条间距铁律,违反任一条都会肉眼可见地"不对齐":

# 铁律 正确 错误(症状)
① 子页 List 不设左右 padding SettingsGroupCard 自带 padding({left:16,right:16}) List 再套 16 ⇒ 双倍内缩 32vp,卡片与页面边缘对不齐
② 顶部留白 = SafeArea.top + 72 状态栏 + MINI 标题栏 + 间距 硬编码 112 / 旧值 64 ⇒ 首项被标题栏压住
③ 卡内分割线用 cardDivider 比 divider 淡一档 用 divider(20%)⇒ 半透明玻璃卡上显成"一条条白线压着"
④ 末项之后严禁再插 Divider 卡片底部留白仅 6vp 尾部多一条线紧贴圆角下缘,视觉极不协调

⚠️ 两套卡片的横向边距归属相反,这是最容易踩的:

组件 用于 横向 16 由谁提供
SettingsGroupCard(有分组标题) 子页 组件自带 padding ⇒ 父级 List 不许再给
SettingsGroup(无标题纯导航卡) 主页 tab 靠父容器 padding ⇒ 组件自身只有 margin({bottom:16})
  • width('100%') + 横向 margin = 溢出(LSN-014),所以主页那张只能靠父容器。
  • 同页混用时必须逐个处理(如 SettingsTab:导航组在自己的 ListItem 上套 padding,其余用 SettingsGroupCard,
    List 本身不带 padding),否则必然出现"一半 16、一半 32"。
  • 标题在卡片外面(Column{ 标题; Stack{材质层; 内容} })—— 移出卡后标题 padding.left 必须改 0,
    旧值 12 是"卡内对齐 SettingItem 行内距"的口径,移到卡外即失效(会右移 12、两边不齐)。
  • 卡间距 = 16vp,必须与主页 SettingsGroup 的 margin({bottom:16}) 严格一致(曾用 12,来回切换可见"挤了一下")。

⚠️ 分割线左缩进两边不同且都是对的:主页 left:52(项带图标 12+32+8 对齐文字列)、
子页 left:12(项无图标)。这是唯一允许两边不同的间距。

LSN-033 SettingItem 下拉菜单签名 + bindMenu 闭包的 this 陷阱(2026-10-08)

  • 签名是 @BuilderParam menu,不是 menuOptions: string[] + onMenuPick:
    SettingItem({ title: '光感强度', value: label, withMenu: true, menu: this.materialMenu })
    调用点是传 builder 引用(menu: this.xxxMenu),不是传数组。
  • @Builder 绝不能带 private(模板自身曾踩):写 @Builder private emptyMenu() {} 会让解析器
    在装饰器行直接中断,首个错误报在 @Builder 声明行附近(Identifier expected),
    随后文件后半段连锁爆出几十条 ';' expected / ':' expected 假语法错 —— 排查成本极高。
    对比:普通成员函数 private vipColor() 带 private 完全合法,只有 @Builder 不行。
  • 🔴 bindMenu 闭包内 this 未绑定:MenuItem({content}).onClick(() => { this.setXxx(0); })
    真机直接 undefined is not callable 闪退(菜单能弹出、点一下才崩)。
    一律调模块级函数:onClick(() => { applyMaterialLevel(index); }),组件方法只负责算展示文案。

LSN-034 搜索框要放标题栏 content.bottomBuilder,放内容区两头不对(2026-10-08)

用户验收原话:「搜索框和按钮加起来不能和标题栏左侧对齐,然后和顶部的距离也不对」。

  • 根因是"放错位置",不是 padding 写错:搜索框放在内容区 List 里时,
    ① 左对齐要手动猜 padding(List 有 16 + 搜索框内部又有 16 ⇒ 双倍内缩,必然与标题栏错位);
    ② systemMaterial 只认 Navigation/NavDestination 标题栏官方生效区,放内容区会打
    Material inactive: out of scope —— 材质从未生效。
  • 正解:Hds 从 6.0.0(20) 起支持 titleBar content.bottomBuilder(官方描述原文即
    「标题栏底部区域增加搜索框、页签」)。SubPageScaffold 已封装 titleBottomBuilder / titleBottomHeight:
    SubPageScaffold({ title: '搜索', titleBottomBuilder: this.searchBar, titleBottomHeight: 56 }) { ... }
  • ⚠️ 为什么不是第二个 @BuilderParam:尾随闭包要求组件内有且仅有一个 @BuilderParam,
    加第二个会破坏其余子页 SubPageScaffold({...}) { } 的写法 ⇒ 用普通成员承载 builder 引用。
  • 连带:标题栏增高 titleBottomHeight 后,列表顶部留白必须加上它
    (SafeArea.top + 72 + TITLE_BOTTOM_SEARCH_VP)——内容区是从屏幕顶算起的(NavDestination 有
    ignoreLayoutSafeArea,标题栏浮在内容之上),不加首项会被盖住。两个常量同值同改。

LSN-035 图标契约:SettingItem.icon 是 SFIcon SVG 名(string),不是 iconSymbol: $r(...)(2026-10-08)

  • SettingItem / SettingsGroupEntry 的图标字段是 icon: string(SFIcons.sf_xxx),
    由 SFIcon 组件 Image($r('app.media.<name>')) 渲染;旧写法 iconSymbol: $r('sys.symbol.*') 已不存在,
    照抄会报 Property 'sf_xxx' does not exist on type 'typeof SFIcons'(或反之)。
  • SVG 资源在 entry/src/main/resources/base/media/*.svg,由 sficon_names.ets 的常量一一对应。
    ⚠️ 常量名与 SVG 文件名不是一一同名(同一图形有多个变体名,如 sf_pause / sf_pause_fill /
    sf_pause_rounded 都指向 pause.svg);写新页面前先列一遍可用常量:
    grep -o "sf_[a-z0-9_]*" entry/src/main/ets/components/SFIcon/sficon_names.ets | sort -u
    (sf_tab_music / sf_tab_cd 这类"想当然"的名字不存在,真名是
    sf_music_note_square_stack / sf_opticaldisc。)
  • ⚠️ 与 LSN-020「图标统一用官方 SymbolGlyph,不要自研 SVG」并不矛盾:LSN-020 说的是
    页面级功能图标(播放/暂停/搜索…)用系统符号;SettingItem 的列表项彩色圆角背景图标
    是另一套(SVG + fillColor 变色 + 主题色背景),两者按位置分工,不互相替换。

LSN-036 App 图标(layered_image)分层比例:按"成品"反推,别用前景层原始占比(2026-10-08)

把 Android 图标搬到鸿蒙时,鸿蒙要两张 1024×1024:background(纯色)+ foreground(居中 logo)。
最容易错的是 logo 该占多大。

  • 🔴 别直接用 upstream ic_launcher_foreground.png 的内容占比。
    Android 会把 adaptive-icon 的 foreground 层再缩放到安全区
    (108dp 画布里的 72dp 中心区)后才渲染 ⇒ 前景层自身占比 ≠ 用户看到的观感比例。
    本项目实测:前景层内容占 45.8%,但成品 ic_launcher.png 里 logo 占整图 53.1%
    (白色像素 bbox 102 / 192)。用 45.8% 生成的图标会明显偏小。

  • 正解:量"成品"。若上游有已合成的 mipmap-*/ic_launcher.png,直接量它里面
    logo 相对整图的占比(用亮度阈值区分 logo 与底色即可),按这个值生成。

  • ⚠️ 鸿蒙侧没有唯一硬性比例 —— 实测 DevEco 官方三个新建工程模板各不相同:

    官方模板位置 logo 占画布
    plugins/openharmony/.../New Project/code template/AppScope/res/media/ 67.4%
    plugins/harmony/lib/templates/ability/common/media/ 44.8%
    plugins/codegenie-plugin/previewProjectTemplate/AppScope/.../media/ 44.5%

    ⇒ 落到 44%~67% 区间内且圆形遮罩不裁切即可,别去找"标准值"。

  • ⚠️ 两处 media 都要写:AppScope/resources/base/media/ 与
    entry/src/main/resources/base/media/ 各放一份 background.png + foreground.png
    (module.json5 的 icon / startWindowIcon 从 entry 侧解析资源引用,只放 AppScope 会报缺资源,见附录 C D4)。

  • ⚠️ 上游常有两套图标资产,别混:
    · adaptive-icon(现代 Android)= 纯色 @color/ic_launcher_background + foreground 层 ← 对齐这个
    · 旧版 ic_launcher.png = 常带渐变背景(本项目采样到 (253,160,170) 就是它,不是纯色底)
    底色要以 colors.xml 的定义值为准(本项目 #FA7298),不要从旧版 png 里取色。

  • ⚠️ Pillow 做预览/对照时的两个坑:
    ① 合成必须先 alpha_composite(bg, fg) 再整体套遮罩;用 Image.paste(fg, pos, mask)
    会忽略 fg 自身 alpha ⇒ 白色 logo 直接盖掉底色(预览看着是全白,容易误判"图标坏了")。
    ② 做 A/B 对照图时两边必须同画布、同裁切口径;一边裁了留白一边没裁 ⇒ 占比基准不同,
    会看着"一边大一圈",那是假象不是 bug(本项目踩过,白折腾一轮)。


7. 自查清单

动手前(每次改动)

  • 已定位模板/工程真实路径并验证存在(§0)
  • 已读 Index.ets + 目标文件全貌,而非凭记忆改
  • 已 grep 同模式引用,明确改动范围边界
  • 方案已向用户描述(改哪个文件 / 哪一处 / 改成什么 / 不动什么),模糊指令不脑补

写完后

  • 前台串行构建通过(禁止并行;hvigor safe-delete → 清 CompileArkTS 缓存)
  • 真机跑一遍 + hilog 看对应 TAG
  • 走一遍 §2 铁律表中与本次改动相关的条目
  • 新增文件中所有硬编码尺寸/颜色是否替换成 ThemeManager.colors / SafeArea.*

提交通道前

  • 版本号如需更新:同步 AppScope/app.json5(versionCode/versionName)+ rawfile/changelog.txt
  • ChangelogManager 靠 versionCode 判「每次更新弹一次」——改了日志别忘了 versionCode +1
  • 若基于模板发布了新版本,回头更新本 Skill:LSN / 坑点速查表 / version + changelog

附录 A:AppStorage 全局键表

键 类型 写入方 读取方 语义
safeAreaTop/Bottom/Left/Right number EntryAbility → SafeArea.set 所有页面 全屏后的系统区高度(vp)
windowWidth/windowHeight number App.ets 窗口监听 一多布局、SheetContainer 窗口 vp 尺寸
windowIsLandscape boolean App.ets SheetContainer 限高 横竖屏(布尔单独广播,避免宽高 set 竞态)
themeIsDark boolean ThemeManager.bumpVersion 任意 当前是否深色
themeColorsVersion number ThemeManager.bumpVersion 全部颜色消费者 版本号,+1 触发重建
dataLoaded boolean EntryAbility.startupReady 列表页 数据恢复完成标记(防启动闪空态)
appInBackground boolean Ability onBackground/onForeground 动画/轮询 状态(true=在后台)
appBackgrounded number Ability onBackground AppSheet 事件版本号,只增不减
settings_* any SettingsManager.fillAppStorage UI @StorageLink 持久化设置的 UI 镜像
settings_customBgUri string (主项目背景模块) GlassCard/SettingsGroup/Scaffold 有无自定义背景 → 毛玻璃 or 实色
sheetReopen number 外部 AppSheet 强制弹窗先关再开(被系统 picker 顶掉时)

附录 B:目录结构

B1. Skill 自身(可移植,整体拷走即可在其它机器使用)

harmony-app-template/
├── SKILL.md                         # 主文件(铁律/工作流/坑点/LSN)
├── references/
│   ├── workflows.md                 # W1~W8 可复制代码
│   └── file-map.md                  # 文件职责全图 + AppStorage 键表
└── assets/
    ├── TEMPLATE_MANIFEST.md         # 内置副本清单 / 排除项 / 同步约定
    └── template/                    # ★ 模板工程完整副本(79 文件 / 1.2MB)
        └── ...(结构见 B2)

B2. 模板工程(= assets/template/,复制出去后即新工程根)

HarmonyAppTemplate/
├── AppScope/app.json5                     # bundleName / versionCode / versionName / 图标 / 应用名
├── build-profile.json5                    # compatibleSdkVersion / targetSdkVersion(书写格式随 API 级别分界,见 LSN-021)
├── entry/
│   ├── build-profile.json5                # apiType: stageMode
│   ├── oh-package.json5
│   └── src/main/
│       ├── module.json5                   # 沉浸光感元数据、权限、Ability 配置
│       ├── resources/base/{element,media,profile}/
│       ├── resources/rawfile/changelog.txt
│       └── ets/
│           ├── App.ets                    # @Entry 根:窗口广播/主题初始化/返回手势
│           ├── entryAbility/EntryAbility.ets
│           ├── layout/Index.ets           # HdsNavigation + HdsTabs + 子页分发
│           ├── pages/{HomeTab,SettingsTab}.ets
│           ├── pages/subPages/{HomeDetailPage,SettingsSubPage,AboutPage}.ets
│           ├── model/{AppRouter,SafeArea,SettingsManager,ThemeManager,TitleBarStyles}.ets
│           ├── services/{AppGlobalContext,AppLogger,ChangelogManager,FilePermission,
│           │             FormatUtils,SheetManager,WindowHelper}.ets
│           └── components/                # 每个组件一个独立目录

附录 C:模板已知缺陷(v1.0.0 待修,动手前先确认是否已修)

以下为通读工程时实证发现的问题,复用模板前建议先修:

# 现象 位置 修法
D1 编译失败:'getInstance' does not exist EntryAbility.ets L43/69/89/197、SettingsTab.ets L76/96、SettingsSubPage.ets L43/58 要么给 SettingsManager 加 getInstance(),要么把 8 处调用改成静态直调(推荐后者,与 AppLogger/ChangelogManager 范式统一)
D2 编译失败:Cannot find name 'WindowHelper' EntryAbility.ets L220 使用了 WindowHelper 但未 import 加 import { WindowHelper } from '../services/WindowHelper';
D3 handedness / materialLevel 重启回默认值 SettingsManager.init() 只读了 exampleSwitch 与 themeMode 补上两个 key 的 getSync 读回(见 LSN-003)
D4 App 图标可能不显示 entry/.../media/layered_image.json 引用 $media:background/$media:foreground,但 png 只在 AppScope/.../media/ ✅ 已修(2026-10-08):AppScope 与 entry 两处 media 都放了 background/foreground(module.json5 的 icon/startWindowIcon 从 entry 侧解析,两边都要有)
D9 静默失效:背景图变化时子页不重新淡入 SubPageScaffold.ets 的 @StorageLink(...) @Watch(...) 顺序 ✅ 已修:SubPageScaffold 已换成 @Watch('cb') @StorageLink('k') 顺序(见 LSN-012)
D5 构建直接失败(连编译都没进):hvigor ERROR 00306042 Specification Limit Violation — the value of compileSdkVersion/compatibleSdkVersion/targetSdkVersion must be string, Example: '5.0.0(12)' build-profile.json5 的 compatibleSdkVersion / targetSdkVersion —— 版本号书写格式与本机 SDK 的 API 级别不匹配 ⚠️ 本条已按 SDK 版本修正(2026-09-17):格式按 API 级别分界——
· API < 26 → 旧式 x.y.z(api),如 "6.1.1(24)"(此前模板值 "26.0.0" 缺后缀即报 00306042,但那是因为当时 SDK 是 API 24)
· API ≥ 26 → 点分制纯数字三段式 major.minor.patch,如 "26.0.0"。此时补 (26) 后缀反而报 00308018「api version parameter is illegal」
做法:先 cat <DevEco>/sdk/default/sdk-pkg.json 读 apiVersion + platformVersion,再按上面分界选格式。本项目 SDK 已升级为 HarmonyOS 26.0.0 / API 26 → 用 "26.0.0"。详见 LSN-020
D6 编译失败:Cannot find name 'HdsNavigationTitleBarOptions' + 'opts.content' is possibly 'undefined' layout/Index.ets 的 private homeTitleBar(): HdsNavigationTitleBarOptions —— 该类型只在 TitleBarStyles.ets 里 import 过;且 content 是可选字段 ① Index.ets 补 import { HdsNavigationTitleBarOptions } from '@kit.UIDesignKit';;② 写 menu 前先取局部判空:const content = opts.content; if (content !== undefined && ...) { content.menu = {...}; }
D7 编译失败:10905210 In an '@Entry' decorated component, the 'build' method can have only one root node + 10505001 Cannot find name 'bindSheet'(报错点与真实元凶无关,极易误判) pages/subPages/AboutPage.ets:SubPageScaffold({...}) { ... }.bindSheet(...) —— 自定义组件调用后不能链式挂属性,尤其在其后还有尾随 lambda 时 把 bindSheet 移到内容里的原生组件(如 List)上:SubPageScaffold({...}) { List(){...}.bindSheet(...) }。这也天然满足铁律 #30(bindSheet 挂原生组件)
D8 编译失败:10905201 The 'Blank' component can only be nested in the 'Row,Column,Flex' parent component 模板各页 ListItem() { Blank().height(...) }(HomeTab / SettingsTab / AboutPage 的顶部与尾部留白) 换成空 Row 占位:ListItem() { Row() {}.height(SafeArea.top + 72).width('100%') }(空容器写法与 AppSheet 里 Stack() {}.width(1).height(1) 同源)
D9 静默失效:弹窗退后台不自动关 AppSheet.ets 的 @StorageLink('appBackgrounded') @Watch(...) 与 @StorageLink('sheetReopen') @Watch(...) —— @Watch 写在状态装饰器之后 按 LSN-012 调换顺序:@Watch('cb') @StorageLink('k') x。模板自身就踩了这条,抄代码时务必一并改(SubPageScaffold 侧已修)
D10 运行时(编译通过、最容易漏):启动恢复够快时列表页永久停在「加载中…」 ① App.ets 的 aboutToAppear 无条件 AppStorage.setOrCreate('dataLoaded', false);② EntryAbility.ets 的恢复链路用 AppStorage.set('dataLoaded', true)(键不存在时静默落空) 两侧都改,缺一不可:① App 侧改为仅在键不存在时初始化;② EntryAbility 侧改用 setOrCreate。详见 LSN-018
D11 观感问题(不报错、真机才看得出):弹窗卡片底边贴住手势条被压 / 横屏时左右被挖孔压 —— 用户表述为「弹窗的卡片边距不大对」 弹窗容器(如 SheetContainer)的卡片边距写成死值 margin({left:24,right:24,bottom:24}),而页面内容普遍有 SafeArea.bottom + 80 尾部留白 ⇒ 弹窗卡片是全局唯一漏掉安全区的边距 卡片边距改为 24 + SafeArea.*(左右 + 上下底部),顶部保持 24(bindSheet 不在屏幕顶,加顶部安全区会把内容整体下推)。连带两处必须同步:① 标题行的横向 padding(与卡片左右缘对齐);② 卡片外底栏的左右 padding(否则横屏与卡片错位)。cardMaxHeight() 的净空扣除本就含安全区,不用改。这是 LSN-014「横向内缩只由卡片自身提供」的同族坑:边距语义只在容器一处定义时,务必连安全区一起定义

⚠️ 结论:内置副本「原样复制 + 只修 D1~D4」仍然编不过。
v1.1.5 实测:完整跑通一次 assembleHap 至少需要修 D1、D2、D5、D6、D7、D8(6 项)。
建议新建项目后直接按本表从上到下全扫一遍再构建,避免"改一处、编一次"的来回。

附录 D:反馈通道

本 Skill 由 HarmonyAppTemplate 工程实践沉淀。使用中遇到:

  • 模板里某条规则不适用 / 与实测不符
  • 新增工作流希望加入 W 系列
  • 发现新的平台坑点

→ 请直接反馈给工程维护者,并附:做了什么 + 期望什么 + 实际得到什么 + 涉及文件路径。
每次都要同步更新:本 Skill 的 version + changelog + LSN 库 + 坑点速查表。


版本历史

✅ 模板健康状态(2026-09-17 修复,assembleHap 全绿)

模板的 23 个既有编译错误已全部修复,当前 assembleHap 构建通过,可直接用模板源码起工程。
修复的错误模式本身就是高频真实坑,记录如下(全部机械可修):

  • SettingsManager.getInstance() ×8 —— 模板 SettingsManager 是静态方法风格,
    调用方却写成实例单例风格 → 改为直接类名静态调用
  • Blank() 直接嵌在 List/Stack 中 ×7 —— Blank 只能嵌在 Row/Column/Flex 内;
    列表占位改 Row() {}.height(...).width('100%')(AssetTab 有现成写法)
  • 缺 import ×4(WindowHelper;HdsTabsBarChangeMode / HdsNavigationTitleBarOptions
    ——后两者是 @kit.UIDesignKit 合法导出,只是没写进 import 块)
  • .bindSheet(...) 链在自定义组件上 ×1 —— bindSheet 是原生组件方法,
    包一层 Stack 把链挪到 Stack 上(SubPageScaffold 场景实证)
  • AboutPage build 括号残缺 ×1;opts.content possibly undefined ×1(可选字段先判空再赋值)

教训:模板/工程每次大改后必须跑一次完整 assembleHap —— 资源错误(CompileResource)
在 ArkTS 编译之前
,资源坏了会掩盖后面所有错误;本次先修 $media:tab_music 才暴露出这 23 个。


版本 日期 变更
v1.3.9 2026-09-30 新增 LSN-031(Canvas 自绘正文「长按划选」+ 功能面横向铺开)。来源:Icey-Reader 用户要求「照着 legado 把该做的功能都做了」——阅读页一轮补齐 书签 / 笔记 / 划选 / 书内搜索 / 阅读统计 / 书架分组 / 排版扩展,全部用鸿蒙特性(沉浸光感 / 符号 / 系统组件)实现。核心坑五条:
① Canvas 字符命中(最易写错):禁比例估下标,走
三层收敛
—— localX/px2vp − 页边距 ⇒ 页内容区;按行 y 区间定位行(行高 = 字号 × lineSpacing,标题行字号 +2);行内找最后一个左缘 ≤ cx 的字形、过其右缘中点 +1 ⇒ 章内偏移 line.charStart + idx。★ 排版期必须存每行 charStart,交互期无法反推。
② 划选与翻页同一手势分流:复用既有 PanGesture(selecting 态改作 extendSelect,否则翻页),❌ 不要新增第二个 Pan / 手势组(会争抢成"想选字却翻页");进入划选用 LongPressGesture({repeat:false,duration:400}),退出后直接 return 不再触发翻页。
③ 划选底衬逐行画:选中区间是章内偏移、绘制在当前页坐标系 ⇒ 求行 ∩ 区间的交集后逐行 fillRect(accent alpha≈0.22,叠在文字之下);直接拿 selStart/selEnd 当 x/y 会跑到页外。
④ 跨页返回先问 has:管理页可能从书架→详情页进入,栈里没有阅读页 ⇒ 一律 pop() 退到错页;正解 AppRouter.has(ROUTE_READER) ? pop() : push(ROUTE_READER, bookUrl)。多个入口共用一条 @StorageLink 信号,@Watch 消费后立即清空。
⑤ 功能面横铺三必查:$r('sys.symbol.xxx') 先核 id_defined.json(写错不报错只渲染空白,本轮实测存在 bookmark/bookmark_fill/bookmark_notepad/magnifyingglass/timer/folder/trash 等,不存在 bookmark_square/note_text/highlighter/chart_bar/text_quote/pencil);单文件同模块只写一行 import(拆两行视同重复声明);新成员写到 struct 的 } 之后会引发几十条级联假错误(Property 'xxx' does not exist + UI component 'Column' cannot be used in this place),元凶在报错点之外 ⇒ 扫「独占一行的 }」定位提前闭合处(实证 615 行),别逐条改。
坑点速查表 +3 行。附:划选浮动条/长按菜单只放文字项、破坏性红色(§2 UI 口径)
v1.3.8 2026-09-30 新增 LSN-030(存 URI 不复制文件 ⇒ 每次启动必须重新 activatePermission)+ 修正 LSN-023 的误导性表述。🔴 极难定位,症状 = 「导入当次完全正常、杀进程重开就白屏」,代码零报错、画布只剩一层底色。机理:① 本地书/音乐不拷进沙箱 ⇒ 仓库存原始 URI,靠 fileShare 持久授权直读;② persistPermission 跨重启不自动生效,必须每次启动逐 URI activatePermission(LSN-023 旧文「picker URI 自带永久授权、persist/activate 仅作保险」只在本次进程内成立,已就地改正);③ 漏掉 ⇒ openSync 抛 13900001 EPERM ⇒ catch 静默 return ⇒ 渲染层拿空内容 ⇒ 纯色白屏。
正解(双保险):启动链路(EntryAbility.startupReady,数据仓库 init 之后)await BookRepo.activatePermissions() + 读取路径 FilePermission.ensureActive(uri)。参考实现 Icey-Player-Harmony/services/MediaScanner.ets:540(注释原文「存原始 URI,重启后通过 activatePermission 恢复访问,不复制文件」,:152/:624 分批激活)。⚠️ 剪裁模板时「删掉没被引用的服务」极易把 services/FilePermission.ets 连带删掉,务必保留。
配套确立「绝不静默失败」:凡「内容读到空 ⇒ 界面整片空」的路径(记录缺失 / 目录为空 / 正文 0 字 / 排版 0 页 / 读文件抛异常)都必须落到 @State loadError + 可见错误态 + 重试入口。这次白屏拖了两轮才定位,根因就是失败被静默吞掉。
坑点速查表 +2 行。来源:Icey-Reader 阅读器两次报白屏,前几轮误判在 canvas 尺寸 / 排版口径 / px2vp,真因是剪裁模板时漏抄权限激活
v1.3.7 2026-09-29 新增 LSN-029(.gitignore 签名约定)+ 修正内置模板 assets/template/.gitignore:其第 13 行原为裸 signing/ ⇒ 整个签名目录被忽略,仓库里没有 p7b/cer/p12/csr,git status 里也永远看不见(极易漏提交)。后果 = 新机器 clone 后构建不出可安装包(unsigned.hap / 安装报 9568320 no signature file),签名类问题(00303074)无法复现。
定案规则(2026-10-08 已更正):签名材料必须进仓库,含 <mode>/material/(它是解密 build-profile.json5 密码密文的密钥材料,IDE 生成、CLI 只读不重建);只忽略根级 /signing/material/。⚠️ 原写「debug/material 是缓存要忽略」方向反了 —— 参考项目(Icey-Player-Harmony)把 signing/debug/material/ 的 5 个文件一直入库且签名正常,那不是漏写而是正确做法。详见 v1.4.1。⚠️ 验证口径:git check-ignore <目录> 对目录判定不可靠,一律用 git status --porcelain + git ls-files | grep material。
坑点速查表 +1 行。来源:Icey-Reader 照抄模板导致签名被整体忽略,用户复核 commit 时纠正「不用 ignore 签名 / material 是要忽略的」
v1.3.6 2026-09-18 扩充 LSN-025(实色大卡改多卡光感),补两条"改版后才暴露"的规则:
① 只读内容块(备注 / 说明正文)同样要"承载":玻璃卡面上直接铺一段正文,深浅两色下都可能不够清晰 ⇒ 应套一层与输入框同款容器(surface 底 + borderRadius 12 + 0.5vp colors.fieldBorder 发丝描边)。附带好处:"同一份内容在表单页是 TextArea、在详情页是同款承载",视觉语言一致、切换无割裂。
② 详情 / 只读页的字段为空时不要整块隐藏:if (hasNote) { 备注卡 } ⇒ 用户分不清"没填"还是"不支持"。本工程实证:详情页备注卡原为"无备注不渲染",用户随即报「详情页要能显示备注」。正解 = 字段卡常驻 + 空值占位文案(「暂无备注」)——"这一栏存在"本身要被表达出来。⚠️ 与表单/编辑页相反:表单页空字段不渲染更清爽,因为没有"这一栏存在与否"的疑问。
坑点速查表 +1 行(只读页空字段)
v1.4.0 2026-10-08 ★ 设置页视觉统一 + 设置组件换代(对齐 Icey-Player-Harmony 实战基线)。
① 组件同步:SettingsGroupCard / SettingsGroup / SettingItem 从 Player 项目同步为最新规范 —— 标题移到卡片外(Column{标题; Stack{材质层;内容}},标题 padding.left 必须由 12 改 0,否则右移 12 两边不齐)、左右 16 由 padding 承载(width('100%')+横向 margin 必溢出,LSN-014 同源)、卡间距 12→16(与主页 SettingsGroup.margin({bottom:16}) 严格一致,曾差 4vp 来回切换可见"挤了一下")、卡内边距恒定上下 6 / 左右 1(不再按有无标题分档 3/6)。
② 新增组件:MaterialLayer(光感承载层)、SFIcon + sficon_names + 42 个 SVG。
③ ⚠️ 图标体系部分回调 v1.2.0:v1.2.0 曾弃用 SFIcon 全改 SymbolGlyph,本轮恢复 SFIcon并与 LSN-020 划清分工 ——页面级功能图标(播放/暂停/搜索)用系统 SymbolGlyph;SettingItem 列表项的彩色圆角背景图标用 SFIcon SVG(需要 fillColor 随主题变色 + 圆形色底,SymbolGlyph 做不到)。见 LSN-035。
④ SettingsManager 补齐光感方法组:materialAvailable/systemMaterialStyleFor/materialFor/materialSystem/materialTinted/systemMaterialColor + MaterialScope 类型 + materialColorInvert 设置项全链路(record 字段/getter/常量/init 读回/fillAppStorage/setter)+ deviceInfo/uiMaterial 两个 import。
⑤ ThemeManager 补 cardDivider(浅 #0D000000 / 深 #14FFFFFF,卡内专用,比 divider 淡一档)+ vipGold(static readonly #E6A23C,始终金色不随深浅色)。
⑥ 模板自身修 bug:SettingItem 的 @Builder private emptyMenu() 去掉 private(@Builder 带访问修饰符会让解析中断,首报 Identifier expected + 文件后半段连锁几十条假语法错,模板里就带着这个雷);HomeTab/SettingsTab/HomeDetailPage 的 iconSymbol 旧写法改 icon: string;AboutPage/HomeDetailPage 的 GlassCard 改 SettingsGroupCard 并去掉 List 左右 padding;顶部留白统一 64→72;SettingsTab 混用两种卡片改为「导航组在自己 ListItem 套 padding + 其余用 GroupCard」。
⑦ 新增 LSN-032~036(间距四铁律与两套卡片归属相反 / SettingItem 下拉签名 + bindMenu 闭包 this 陷阱 / 搜索框必须放标题栏 bottomBuilder / 图标契约 / App 图标分层比例)。⑧ SubPageScaffold 新增 titleBottomBuilder+titleBottomHeight+buildTitleBar()。⑨ 附录 C 的 D4/D9 标记已修。
✅ 模板实测 default@CompileArkTS 通过(0 错误),可直接起工程。
v1.4.1 2026-10-08 ★ 重大更正 LSN-029:signing/<mode>/material/ 必须入库(原写「是哈希缓存、每次构建可重建、要忽略」,方向反了)。
来源:BiliDownload-Harmony 重打包时 SignHap 连续失败,逐层试出三种报错并读 hvigor 源码 hvigor-ohos-plugin/src/utils/decipher-util.js 定位 —— material 是解密 build-profile.json5 里 storePassword/keyPassword 密文的密钥材料:fd/{0,1,2}(3×16B 密钥分量) + ac/(16B salt) + ce/(48B AES-GCM 包裹的工作密钥),经 pbkdf2(xor(fd…,固定常量), salt, 10000, 16, sha256) 派生出 AES key。由 DevEco IDE 生成,CLI 只有读取逻辑、不会重建,且与密文一一配对。
三种报错对症:目录缺失 → 00308018/ENOENT;目录空 → 00304032 "is an empty directory";与密文不配对 → 00303242 "unable to authenticate data"。
权威证据:Icey-Player-Harmony 的 .gitignore 只写 /signing/material/,其 signing/debug/material/ 的 5 个文件全部入库、签名一直正常。
改动:更正 assets/template/.gitignore(删掉 /signing/debug/material/,只留根级)、更正 LSN-029 正文、更正坑点速查表签名行。坑点速查表原地更正(不 +行)。
v1.3.5 2026-09-18 新增 LSN-028(同一组件「点击 + 长按」+ 详情页自动刷新)。三个坑都编译全绿、仅真机暴露:
① .onClick 与 .gesture(LongPressGesture) 并存 ⇒ 双跳:长按命中后抬手又触发点击 ⇒ 路由栈连进两页(用户看到"长按后莫名跳转")。正解 = 互斥手势组 GestureGroup(GestureMode.Exclusive, LongPressGesture({duration:500}).onAction(...), TapGesture({count:1}).onAction(...))(独占模式"先满足条件者胜出");❌ 不要 GestureMode.Parallel(两个都触发)、❌ 不要叠两个 .gesture()。
② 子页"外部数据变更后不刷新":把重算结果放普通字段缓存、在 build() 里读 ⇒ 刷新链尾段变成"父组件重渲染会不会顺带重跑 @Builder"这一不确定行为;症状 = 编辑保存返回仍是旧值、退出重进才对(极易被误判为"没问题")。正解 = @State 持有视图模型 + @Watch 驱动重算(@Watch 必须写在状态装饰器之前,LSN-012),并入参只取 id、不持有快照(页面永远 repo.find(id);返回 null ⇒ 渲染「记录不存在」兜底卡,不要在 build 里 pop)。视图模型可以是页面内私有 class(同 SegmentsParams 范式),但不许复算业务(口径仍归 model,LSN-015)。
③ ForEach 的 key 只写"身份"不写"值" ⇒ "项还在、值变了"(如明细行的"已使用 / 目标成本 / 已存金额")key 不变 ⇒ 复用既有节点、不重跑 @Builder ⇒ 永远停在旧值,是三者中最隐蔽的。正解 = key 带上会变的值(kv_${label}_${value}_${accent}_${isLast});列表页 key 再带 _${this.assetsVersion} 作双保险。
坑点速查表 +3 行(点击+长按 / 子页刷新 / ForEach key)。来源:AssetDailyCost「卡片改为长按编辑」后紧接着「点击卡片可以查看详情」,一轮内三坑齐现
v1.3.4 2026-09-18 新增 LSN-027(长按手势):把「点击」改成「长按」时,Stack / Column 等容器没有 .onLongPress 修饰符——写 .onLongPress(() => {...}) 报 10505001 Property 'onLongPress' does not exist on type 'StackAttribute'(换 Column 报 'ColumnAttribute')。正确写法 = LongPressGesture 描述符 + 容器根节点 .gesture(LongPressGesture({ duration: 500 }).onAction(() => {...}))。⚠️ 连环坑:LongPressGesture 是 ArkUI 全局声明、无需 import,试图 import { LongPressGesture } from '@kit.ArkUI' 会报 10311006 'LongPressGesture' is not exported from Kit '@kit.ArkUI'。含:挂载点选择(根容器;hitTestBehavior(HitTestMode.None) + enabled(false) 的材质承载层不拦截手势)、.gesture() 默认 GesturePriority.Normal 与宿主 List 滚动手势共存的原理("按住不动"与"位移"判定互斥)、repeat: true 适用场景。并给出**「点击改长按」口径变更规范**:.onClick 整段替换为 .gesture(...)、回调属性同步改名(onCardTap → onLongPress)让调用点漏改即编译失败、复用组件把回调做成带默认空实现的可选属性(不传零副作用)。坑点速查表 +1 行(素材来源:AssetDailyCost 列表卡改长按编辑,连报两种编译错误后定稿)
v1.3.3 2026-09-18 扩充 LSN-023(自定义壁纸)——补「两组载体各自的蒙版来源」对照表与排查细节。用户口语「白色蒙版」= 壁纸之上还有两层不透明底:① 主界面 Index 的 HdsNavigation.backgroundColor = colors.background(壁纸时 = #B3F1F3F5 ≈70% 白,整屏压暗);② 首页标题栏 buildHomeTitleBar 的滚动蒙层 #CCFFFFFF(80% 白块)/深色 #CC000000。子页两条链默认已透明(SubPageScaffold 的 customBgUri ? Color.Transparent : … + buildSubPageTitleBar(..., transparentScrollMask = true, ...))⇒ 只剩主界面发白,极易漏。修法:主界面同口径条件透明 + 给 buildHomeTitleBar 加 transparentScrollMask 形参。⚠️ 两个排查细节:HdsNavigation 属性链上 backgroundColor 可能被写了两次(后写生效),改透明要改最后那次;根 Stack 底色在壁纸 Image 之下(自身底色先于子节点绘制),应保留不透明作解码兜底。坑点速查表 +1 行
v1.3.2 2026-09-18 新增 LSN-026(沉浸光感必须有「可采样底层」):材质采样的对象是组件背后的窗口内容,若页面背景是纯色(无壁纸 / 无内容)⇒ 材质与毛玻璃都无信息可采,卡片退化成**「与页面同色的灰玻璃」(浅色下只剩一圈模糊边,比原来的实色白卡更差)。修法:① 把「有底层」做成 materialActive(forceOn, backdrop) 的第二形参**(与 forceOn 同规则,必须由调用方传订阅值;漏改任一调用点即编译失败,避免静默漏改);② 无底层时不下发材质并回落实色卡底(cardBg:浅色 #FFFFFF / 深色 #212121)+ 去掉模糊(模糊无内容可采);③ 同源控件一律同步(hero 卡回不透明渐变、chips 回实色、弹窗卡片回实色且弹窗底改不透明);④ materialDiagnostics() 增加「底层=自定义壁纸 / 无」一档。坑点速查表 +1 行
v1.3.1 2026-09-18 新增 LSN-025(实色大卡改多卡光感的三件事):① 卡内控件可辨性——卡片由实色 cardBg 换成光感材质/毛玻璃后,控件原有的 surface(#F5F5F5) 浅填充与卡面几乎同色 ⇒「输入框 / 开关行 / 分段控件」看不出边界(编译全绿、只有真机截图能发现),修法为加 0.5vp colors.fieldBorder 发丝描边(保留实色填充 ⇒ 文字对比度不受材质影响);次级按钮同理且更隐蔽——surface 填充按钮直接贴页面背景在浅色下几乎不可见 ⇒ 操作区必须留在卡片内;② 属性链位置:修饰符须写在内层容器 } 之后、外层自定义组件 } 之前,写成 }→}→.width() 报 10505001 Declaration or statement expected + Cannot find name 'width'(报错点比真实元凶低一层,与附录 D7 同源);③ 分卡边界直接取原 sectionTitle 锚点(一个标题 = 一张卡,if (分支) 内可放多个 ListItem,卡标题统一 14/Medium/textPrimary)。坑点速查表 +2 行(实色卡换光感卡 / 插卡片边界)
v1.3.0 2026-09-17 ★ 沉浸光感材质 + 自定义壁纸 双能力沉淀。
① 光感:模板内置 services/SystemMaterial.ets(惰性构造 ImmersiveMaterial——模块级求值低版本闪退 / 三档缓存:静态带影·静态无影·可交互按流光色 / applyMaterialCarrier / pressLightColor / fallbackSurface / materialDiagnostics 诊断)+ components/MaterialCard/(MaterialCardLayer 承载层——仅 ToggleType.Button 可承载整块矩形材质,五条属性缺一不可;MaterialCard 外壳 + 降级毛玻璃)。新增 LSN-022(四条层级规则 / attributeModifier 必须方法调用 / interactive+空 lightEffect 不渲染 / 三件事降级 / 卡片·hero沾色·弹窗·chips 四形态接入)。坑点速查表 +3 行。
② 壁纸:PhotoViewPicker 全流程(字段是 photoUris / options 显式类型 / URI 自带永久授权)+ ThemeManager.setCustomBg 半透明背景(不动深浅模式)+ 三链路状态重放。新增 LSN-023。
③ 模板修复:compatibleSdkVersion 6.1.0(23)→26.0.0(光感是 API 26 能力);修复 startWindowIcon 残留引用 $media:tab_music 资源缺失 → $media:layered_image;新增 LSN-024(资源引用与文件不成对,报错指向 build 中间产物)。
✅ 模板 23 个既有编译错误全部修复(getInstance 漂移×8 / Blank 嵌套×7 / 缺 import×4 / bindSheet 挂自定义组件 / 括号残缺 / 可选字段未判空),assembleHap 构建通过(见「模板健康状态」)
v1.2.0 2026-09-17 ★ 图标体系换代 + SDK 版本书写规范修正。
① 图标:按用户要求弃用自研 SVG 图标组件、统一改用鸿蒙官方符号——SymbolGlyph($r('sys.symbol.xxx')).fontSize(22).fontColor([color])。理由:零资源文件(原 28 个 SVG)、随系统字体缩放、系统自带 xxx/xxx_fill 选中态配对、.fontColor([...]) 支持分层多色。同步迁移内置模板:SettingItem 的 icon: string → iconSymbol: Resource | null(非装饰成员)、SettingsGroupEntry.iconSymbol?: Resource + 透传 ?? null、Index.ets 的 Tab 与 HomeTab/SettingsTab/HomeDetailPage 各页图标;删除 components/SFIcon/ 与 28 个失效 SVG。新增 LSN-020(选名方法 / $r 编译期字面量约束 / 组件 API 变形 / 易错点 / 符号映射表 / "符号名写错不报错只是空白 ⇒ 必须截图验证")。
② SDK:新增 LSN-021 并修正 D5——SDK 版本号格式按 API 级别分界:API < 26 用 x.y.z(api)("6.1.1(24)"),API ≥ 26 用点分制 major.minor.patch("26.0.0")。依据:hvigor 源码 FIRST_DOT_API_VERSION = 26 + DOT_API_VERSION_PATTERN,与官方文档「从 API 26.0.0 开始配置统一」互证。错误码对照:26 前漏 (api) → 00306042;26 加 (26) → 00308018;升 SDK 改半截(compatible 改 26、target 仍 24)→ IDE sync 报 00303015(校验实现在 one-sdk-validator.js,规则 compatible ≤ target ≤ compile;compileSdkVersion 由 IDE 解析的 SDK 决定,故 CLI 构建可能不报、必须另跑 --sync 验证)。原 D5 结论(一律补 (API) 后缀)对 API 26 SDK 已不成立。Q11 改为「以 sdk-pkg.json 实测为准」。
坑点速查表 +4 行(图标、SDK 版本写法、升 SDK 三字段联动、能力清单/W6/Q7 同步改写)
v1.1.11 2026-09-17 新增 LSN-019:@Builder 按值传参不刷新 —— 改了值界面不变、但保存后重开又对了。ArkUI 明文规则:按值传参时状态变量改变不刷新 Builder 内部,只有传对象字面量(按引用)才会刷新。含 ① 同文件对照实验确诊法(同一状态机制下「内联 Row 正常 / 走 @Builder 异常」⇒ 唯一差异即元凶);② 三种修法(内联 build() 首选 / 对象字面量按引用传参 / 子组件 @Prop);③ 同根因的其余受害者(分段控件 selected 高亮不动、开关行 isOn 不回显、随状态变化的 fieldLabel 文案);④ 极易骗人的验证姿势警告:别用"保存 → 重开 → 看值对不对"验证(值本来就对,会误判为无 bug),必须停留在当前页看 UI 是否即时刷新。实测来源:新增资产表单改购买日期后文本不动(同页分类/目标日期/到期时间因内联而正常)。坑点速查表 +1 行
v1.1.10 2026-09-17 新增 LSN-018:启动竞态导致列表页永久「加载中…」(编译零报错、恢复越快越必然复现)。双重根因——① AppStorage.set() 对不存在的键静默落空(恢复链路 34ms 跑在 App.aboutToAppear 建键 55ms 之前);② App.aboutToAppear 无条件 setOrCreate('dataLoaded', false) 覆写已置的 true;两者叠加使结果顺序敏感。给出三处协同修法(写入方恒 setOrCreate / 建键方仅缺键时初始化 / 300ms 就绪自愈兜底)+ 仓库 isReady() 约定 + 验证姿势(launch fallback 告警数须为 0)。★ 同步修复内置模板 → 附录 C 新增 D10(模板 App.ets 与 EntryAbility.ets 原样自带此缺陷);坑点速查表 +1 行
v1.1.9 2026-09-17 新增 LSN-017:签名与安装三连坑(真机交付链路上最耗时的一环)——① signingConfigs 声明 ≠ 使用,product 缺 "signingConfig": "default" 就产出 -unsigned.hap,安装报 error:9568320 no signature file;② 改 bundleName 后 .idea/.deveco/project.cache.json 缓存 BUNDLE_NAME 导致日志仍 Launching <旧包名>,必须 Sync 并重申请绑定新包名的 p7b;③ 签名判定方法论:本 SDK 下既找不到 Hap Signature Block 魔数,signed/unsigned 的 zip 条目也完全相同,唯一可靠判据是解包检索 kebab-case 的 bundle-name/app-identifier/debug-info。含 hdc 真机验证标准动作(bm get --udid 核白名单 → install -r → bm dump -a 核 appIdentifier);铁律 +2 条(#38/#39)、坑点速查表 +2 行
v1.1.8 2026-09-16 新增 LSN-016:跨组件传 @Builder 会丢 this——菜单能展开、点菜单项才崩(TypeError: undefined is not callable)。含三步闭锁定性法(列号落到成员名 ⇒ this 是"有对象但缺成员"即被重绑 / 同文件找"只调模块级导入的 builder"作反证 / 反推 .selected() 打钩静默失效,必须两个症状一起收)。确立规范:通用组件收菜单一律走**「@Prop 数据 + 普通函数属性回调」配置化**,禁止塞 @BuilderParam;并明确同组件内 bindMenu(this.xxxMenu) 是安全的,只有跨组件才触发。★ 同步修复内置模板 SettingItem(@BuilderParam menu → menuOptions/menuSelected/onMenuPick,Menu 由组件内 ForEach 构建);坑点速查表 +2 行
v1.1.7 2026-09-16 新增 LSN-015:跨组件只传展示型数据类——@Prop 深拷贝不保证类方法存活(传 AssetItem[] 后调实例方法=运行时炸弹),叠加"视图层不做业务计算"既有约定,共同指向同一架构:算都在 model,UI 只排版,比例/百分比宽度/说明文案全部预聚合。判别口诀"要过 @Prop 的类,一个方法都别加";坑点速查表再 +2 行(ListView 子项里的横向滚动条必须显式 .height(),否则高度不确定会塌成 0 或撑高整行;给子组件传列表数据用纯数据类)
v1.1.6 2026-09-16 新增 LSN-014:width('100%') 与横向 margin 不可同挂——margin 在尺寸之外,总占位 = 父宽 + 2×margin,右端越界被 List 裁切成直角(用户描述为「显示不全」)。含截图逐像素取证法(左内缩 ≈16vp / 右内缩 = 0 / 右上角无圆角 ⇒ 确诊)与孪生反例(List padding + 卡片 margin = 双倍内缩 32vp);确立规范:横向内缩只由宿主 List 的 padding 提供,卡片只留纵向 margin;坑点速查表新增「卡片左右内缩」行
v1.1.5 2026-09-16 ★ 附录 C 扩充 D5D9:targetSdkVersion "26.0.0" 格式非法导致 hvigor 直接拒绝构建(00306042)、HdsNavigationTitleBarOptions 未 import + opts.content 可选判空、自定义组件后链式 bindSheet 触发"build 只能有一个根节点"、Blank 不能直接作 ListItem 子组件、AppSheet/SubPageScaffold 的 @Watch 顺序写反;新增 LSN-013「内置副本 compile-ready 校验,原样复制后必须全表扫一遍」;坑点速查表同步 +6 行;明确"只修 D1D4 仍编不过,至少需修 D1/D2/D5/D6/D7/D8"
v1.1.4 2026-09-08 新增 LSN-012:@Watch 装饰器必须写在状态装饰器之前(写反被 SDK 静默忽略 → @State 缓存只算一次永不更新);跨 Tab 信号驱动 UI 优先走「父组件 build 引用信号→整树级联」(对齐主题 themeColorsVersion 已验证通路),列表计算在 build 内直接重算绕开 @Watch 陷阱
v1.1.3 2026-09-08 新增 LSN-011:日期选择用官方 showDatePickerDialog 替代手写滚轮(onDateAccept 只回公历 Date、lunarSwitch 建议关闭由外部口径 chip 单一事实源控制);坑点速查表新增「日期选择」行
v1.1.2 2026-09-08 新增 LSN-010:HdsTabs 底栏中央「+」标准方案(5 页签占位 + onContentWillChange 拦截 + onTabBarClick 弹窗,自绘底栏方案被用户否决的实证);坑点速查表新增「底栏中央大按钮」行;铁律 #37 补强 hvigor UP-TO-DATE 强制重编完整解法(删 cache + .hvigor + intermediates/loader* + touch 源文件)
v1.1.1 2026-09-07 铁律 #16 修正补强:明确区分 Hds 自有枚举(可退数值)与系统 ArkUI 枚举(必须用枚举名);新增真机实证——NavigationMode 数值 1=Split 非 Stack(整页压左半屏)、TabsCacheMode.CACHE_BOTH_SIDE=0
v1.1.0 2026-09-07 内置可移植模板副本 assets/template/(79 文件 / 1.2MB),换机器开箱即用;§0 改为三级定位(内置副本优先);新增铁律 #00;新增 assets/TEMPLATE_MANIFEST.md(清单/排除项/同步约定);W1 增加「先修 D1~D4」前置步骤
v1.0.0 2026-09-07 初始版本:8 条工作流、37 条铁律、15 行坑点速查表、9 条 LSN、模板缺陷清单 D1~D4

鸿蒙 App 模板基建 v1.4.0 | 内置模板,换机即用 | 读模板 → 按工作流剪裁 → 按铁律改代码 | 改前先看 §2,改后先过 §7,新建工程先扫附录 C