ArkTS 做应用内"快捷胶囊" —— 续航日记踩过的两个鸿蒙声明式 UI workaround

续航日记(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 项目的类型安全度会上一个档次。

相关推荐
用户114818678948425 分钟前
水印的实现方式逻辑
前端·python
孟陬2 小时前
Foobar 技术周刊#160:大模型让大规模形式化软件验证成为现实丨AI写C++代码之后会发生什么?丨我们真的需要alpha() 函数吗丨重试,有时会让故障更
前端·后端·typescript
flash俊杰3 小时前
屏幕感知双通道:截图之外,为什么还要一份 UIA 控件清单
前端
熟的生菜3 小时前
从零造一个腱绳驱动灵巧手——仿真器篇3:VTK 渲染与 3D 交互——看得见、点得着、转得动
前端
Henry-SAP4 小时前
SAP PP中的核心业务流程——离散制造、重复制造、流程制造三大模式解析
前端·数据库·人工智能·制造
徐小夕4 小时前
下载量超 30 万的开源智能体 Prime Agent:它把自己从 TypeScript 重写成了 Rust
前端·vue.js·github
Zhao-Jichao4 小时前
AI Prompting for Everyone 课程笔记 - P20 实验与期末项目 Lab and Final Project
前端·人工智能·笔记
Moment5 小时前
为什么这么多 Agent Harness 都选择 TypeScript
前端·后端·面试
toooooop85 小时前
CRMEB 故障排查笔记:安全网关只放行 GET/POST 怎么办
前端·javascript·vue.js·thinkphp6·crmeb