续航日记(BatteryLog)的首页有一排"快捷胶囊":用户点一下某个胶囊,就记一条能量变化(比如"+15 午睡"、"-10 开会"、"+20 散步")。听起来是桌面 ShortcutInfo 的典型场景,但续航日记走的不是鸿蒙系统的桌面快捷方式,而是应用内的自定义组件。做这个过程中撞到 ArkTS 和 TypeScript 的两个明显差异。记录一下,顺便分享给有需要的朋友。
一、桌面 ShortcutInfo 和应用内快捷胶囊的区别
鸿蒙有两种"快捷"概念容易混。
桌面 ShortcutInfo(真·系统快捷方式):
- 在
module.json5声明shortcuts.json+ metadata - 用户在桌面上长按 app 图标出现快捷方式菜单
- 点快捷方式启动 app 时 Want 带特定参数
- 典型场景:相机类 app 的"扫一扫 / 自拍 / 录像"快捷入口
应用内快捷胶囊(续航日记做的):
- 完全是 ArkTS 自定义组件,渲染在 app 内部
- 用户必须先打开 app 才能点
- 点击后触发 app 内部的业务动作(没有启动参数的概念)
续航日记选第二种,三个理由:
一是胶囊内容高度个性化。用户自己定义:胶囊的标题、增减值 (-50 到 +50)、emoji 图标、是否挂到某个项目。桌面 ShortcutInfo 是在 app 开发时固定的(4 个最多),不支持用户动态添加。
二是胶囊状态要上下文感知。续航日记里的胶囊有"置顶"、"按项目分组"、"按日活跃度排序"这些行为。桌面 ShortcutInfo 没这个能力。
三是离开 app 用不着。续航日记记录能量变化的操作一定发生在"用户打开 app 后",不是"桌面快速扫码"这种外部场景。所以不需要走系统快捷方式。
什么时候要用桌面 ShortcutInfo:你的 app 有"外部用户快速进入某个功能"的场景(如打车类 app 的"回家 / 公司"按钮)。续航日记没这个需求。
二、QuickAction 的数据模型
续航日记的快捷胶囊数据模型(纯 interface):
typescript
// domain/models/BatteryModels.ets
export interface QuickActionModel {
id: string // UUID
title: string // 用户填的标题,如"午睡"
delta: number // 能量变化:-50 到 +50,步长 5
icon: string // emoji,最多 2 code points
projectId: string | null // 可选:挂到某个项目
durationMinutes: number // 仅当 projectId≠null 且 delta<0 时非 0
sortOrder: number // 排序值
isPinned: boolean // 是否置顶
}
两个字段设计上有点意思。
delta 的范围和步长。-50 到 +50 是经验值------真实的"能量变化"不会超过 50 分(续航日记能量满分 100);步长 5 让 UI 的 slider 不至于太精细(滑块只有 21 档,交互友好)。这些约束不是技术选择,是产品决策,但要在 schema 层固定下来------否则有一天用户数据里会出现 "+73.5 分" 这种古怪值。
durationMinutes 的条件存在。"做了 30 分钟的事"只在"耗能 + 挂了项目"的组合里有意义------比如"-10 开会 挂到产品 X 项目"要记"开会多久",但"+15 午睡"(没挂项目)就不需要时长。续航日记用一个 helper 做条件化:
typescript
// domain/logic/QuickActionDurationHelper.ets
export class QuickActionDurationHelper {
static shouldTrackQuickActionDuration(
projectId: string | null | undefined,
delta: number
): boolean {
return !!projectId && delta < 0
}
static normalizeQuickActionDurationMinutes(
raw: number,
projectId: string | null | undefined,
delta: number
): number {
if (!QuickActionDurationHelper.shouldTrackQuickActionDuration(projectId, delta)) {
return 0 // 不符合条件强制归零
}
// 否则落到 [15, 30, 45, 60] 之一
return QuickActionDurationHelper.snapToOption(raw)
}
}
shouldTrackQuickActionDuration 是单一真相源------所有地方判断"要不要显示时长 UI / 要不要保存 durationMinutes" 都走这个函数。改规则只改一处。
三、时长为什么只给 4 个预设选项
typescript
export const QUICK_ACTION_DURATION_OPTIONS = [15, 30, 45, 60]
续航日记的时长只允许 15 / 30 / 45 / 60 分钟四个预设选项,不是任意分钟数。这是产品决策,但背后有工程考虑。
好处一:UI 一致。时长选择器就是四个 chip,点一下选中。不用数字输入 + 校验 + 单位 + 边界报错。用户决策速度快。
好处二:数据可比。所有 record 的 durationMinutes 都是这 4 个值之一,统计时按值 group by 很方便。如果允许任意分钟数,统计要先 bucket(0-15 / 15-30 / ...),复杂度上升。
好处三:序列化友好 。只有 4 个合法值意味着老数据里出现 "17 分钟" 时可以 snap 到最近的预设值:
typescript
private static snapToOption(raw: number): number {
if (!Number.isFinite(raw) || raw <= 0) return QUICK_ACTION_DURATION_OPTIONS[0]
// 找最近的预设值
let nearest = QUICK_ACTION_DURATION_OPTIONS[0]
let minDiff = Math.abs(raw - nearest)
for (const opt of QUICK_ACTION_DURATION_OPTIONS) {
const diff = Math.abs(raw - opt)
if (diff < minDiff) {
minDiff = diff
nearest = opt
}
}
return nearest
}
比如老数据里有 "17 分钟"(可能是旧版本不限制时写的),snapToOption(17) 返回 15;"50 分钟" 返回 45。schema 不断前进时老数据自动归一化。
什么时候不该这么做:如果你的产品真的要"精确到分钟"(番茄钟 app、健身计时器),不能这么 snap。但续航日记记录的是"大致花多久做了件事",精度不敏感。
四、ArkTS 不允许展开运算符怎么办
快捷胶囊有一个 icon 字段,用户用 emoji------比如"☕"(1 code point)、"👨💻"(4 code points,组合字符)。前端要限制"最多 2 code points"。
TypeScript 的朴素写法:
typescript
// TypeScript 下的写法(在 ArkTS 里不允许!)
function codePointCount(str: string): number {
return [...str].length // 展开 → 自动按 code point 拆
}
function truncate(str: string, max: number): string {
const points = [...str]
if (points.length <= max) return str
return points.slice(0, max).join('')
}
ArkTS 禁用了展开运算符 ([...iterable])。这是 ArkTS 的严格类型模式和 Static TypeScript 的一个约束。续航日记这里撞到了:
typescript
// ArkTS 下必须手写(pages/QuickActionEditorSection.ets:15-38)
// Count visible characters (code points) in a string without using spread,
// which is not permitted in ArkTS. Surrogate pairs count as 1 code point.
function codePointCount(str: string): number {
let count = 0
for (let i = 0; i < str.length; ) {
const code = str.codePointAt(i)
if (code === undefined) break
count++
i += code > 0xFFFF ? 2 : 1 // surrogate pair 占 2 个 UTF-16 slot,但算 1 个 code point
}
return count
}
function truncateByCodePoints(str: string, max: number): string {
if (max <= 0) return ''
let result = ''
let count = 0
for (let i = 0; i < str.length; ) {
const code = str.codePointAt(i)
if (code === undefined) break
const unitLen = code > 0xFFFF ? 2 : 1
if (count + 1 > max) break
result += str.slice(i, i + unitLen)
count++
i += unitLen
}
return result
}
关键逻辑 :surrogate pair 的判断用 code > 0xFFFF。UTF-16 里 code point 超过 0xFFFF 的字符要用两个 code unit(surrogate pair)表示,str.length 把它当 2 个字符,但人眼看是 1 个 emoji。
写完之后我盯着看了一会,才意识到这就是TypeScript 原生 [...str] 内部做的事 。ArkTS 禁用展开运算符意味着所有 iterable 的"一次性展开"都要手写循环。好在 string 的 iterable 行为是 well-defined 的,照着写不难。
ArkTS 禁用展开运算符的其他影响:
const copy = [...originalArray]→ 写成const copy = originalArray.slice()或const copy = Array.from(originalArray)Math.max(...numbers)→ 写成循环求 max,或者numbers.reduce((a, b) => Math.max(a, b), -Infinity)- 函数参数
function fn(...args)→ ArkTS 允许 rest 参数(这个还可以),但调用方不能用fn(...arr)展开传入
遇到的概率不低。TypeScript 代码库迁 ArkTS 时这类改动是最多的。
五、@Prop 在 @Builder 里不可靠的 workaround
快捷胶囊的编辑表单有一个 header 显示"字符数 X/20"------用户每输入一个字符,计数实时更新。朴素做法:
typescript
@Component
struct QuickActionEditor {
@Prop quickAction: QuickActionModel // 从父组件传入
@Builder private renderHeader() {
Text(`${this.quickAction.title.length}/20`) // 希望实时更新
}
build() {
this.renderHeader()
TextInput({ text: this.quickAction.title })
.onChange((v: string) => {
this.quickAction.title = v // 希望改了 header 跟着变
})
}
}
这个在 ArkTS 下不可靠 。@Prop 作为 @Builder 参数时,ArkUI 的响应式机制有时候不触发重渲染------用户敲字时 title 变了,但 header 还显示老值。源代码里有一段英文注释说明这个问题:
// Local state drives real-time header counters and delta display;// @Prop values are unreliable as @Builder parameters in ArkTS.
续航日记的 workaround 是把 @Prop 拷到 @State:
typescript
@Component
struct QuickActionEditor {
@Prop quickAction: QuickActionModel
// 把 @Prop 的字段分开拷到 @State,让 @Builder 读 @State
@State private localName: string = ''
@State private localNameLength: number = 0
@State private localIconValue: string = ''
@State private localIconLength: number = 0
@State private localDelta: number = -25
// 组件挂载时初始化 local state
aboutToAppear() {
this.localName = this.quickAction.title
this.localNameLength = codePointCount(this.quickAction.title)
this.localIconValue = this.quickAction.icon
this.localIconLength = codePointCount(this.quickAction.icon)
this.localDelta = this.quickAction.delta
}
@Builder private renderHeader() {
Text(`${this.localNameLength}/20`) // 读 @State,响应式可靠
}
build() {
this.renderHeader()
TextInput({ text: this.localName })
.onChange((v: string) => {
this.localName = v
this.localNameLength = codePointCount(v)
this.quickAction.title = v // 顺手同步到 @Prop
})
}
}
意思是把需要 @Builder 实时读的字段,先转成 @State 。@Prop 作为"父组件传入的初始值",@State 作为"@Builder 实时读的真相源",aboutToAppear 做一次初始化同步,用户输入时双写(@State + @Prop)。
这个 pattern 的代价是冗余字段。QuickActionModel 有 title + icon + delta 三个核心字段,组件里就要有 title / localName / localNameLength / icon / localIconValue / localIconLength / localDelta 一堆。
好处是**@Builder 可靠响应**。写法虽然冗余但运行时稳定。
什么时候这个坑能被绕开:ArkUI 后续版本修了 @Prop 在 @Builder 里的响应性。续航日记这段代码的英文注释里暗示作者在等 ArkUI 的更新;在没更新之前,这个 workaround 是最可靠的做法。
踩坑清单
- 应用内快捷操作用自定义组件,不走系统 ShortcutInfo,除非真的需要桌面入口
- 条件存在的字段用 helper 函数做"单一真相源",所有判断都走这个函数,改规则只改一处
- 用户填的数值字段落到预设选项列表,用 snap-to-nearest 做兼容老数据
- ArkTS 禁用展开运算符,手写 for 循环替代。string 要按 code point 遍历,判断 surrogate pair 用
code > 0xFFFF @Prop在@Builder里不可靠,aboutToAppear拷到@State,onChange双写(@State + @Prop)
尾声
一句话吐槽:ArkTS 比 TypeScript 多了几个意外约束 。展开运算符、@Prop 在 @Builder 里的响应性、index signature + any 的禁用------这些差异点在从 TS 项目迁 ArkTS 时会一一撞到,每个撞到都要写 workaround。工程成本不是零。
一句话平衡:ArkTS 的严格类型系统 + 装饰器 UI 的组合整体是向前进步。失去一些灵活(展开运算符、动态 index),换到一些稳定(编译期类型安全、UI 和数据的双向绑定)。续航日记这些 workaround 看起来麻烦,但一旦接受"这是 ArkTS 的既定约束",剩下的工程落地其实比预期顺------因为编译器能帮你把一堆运行时才会炸的类型错误提前到编译期。
对要迁项目的朋友的建议:预估 15%-20% 的代码要改写,主要是 iterable 操作和装饰器搭配。改完之后 ArkTS 项目的类型安全度会上一个档次。