前言

「诗境」在桌面放了两张卡片:2×2 的学习进度卡和 4×4 的每日一诗卡。它们跑在 FormExtensionAbility 所在的那个进程里,和应用主进程互不相识 —— 用户在应用里收藏一首诗,桌面上那个数字要变,只能靠应用主动把数据推过去。这套推送机制加上卡片的被动接收方式,构成了这一整套「双通道」。

alt text

六条配置决定卡片长什么样

两张卡片都声明在同一个 profile 里:

{
  "forms": [
    {
      "name": "progressWidget",
      "displayName": "$string:form_progress_name",
      "description": "$string:form_progress_desc",
      "src": "./ets/widget/pages/ProgressWidgetCard.ets",
      "uiSyntax": "arkts",
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "08:00",
      "defaultDimension": "2*2",
      "supportDimensions": ["2*2"],
      "isDynamic": true,
      "colorMode": "auto",
      "previewImages": ["$media:progressWidget_2x2"]
    },
    {
      "name": "dailyPoemWidget",
      ...
      "defaultDimension": "4*4",
      "supportDimensions": ["4*4"],
      "isDynamic": true,
      "colorMode": "auto",
      "previewImages": ["$media:dailyPoemWidget_4x4"]
    }
  ]
}

两处差异只在尺寸和 isDefault 上:

字段进度卡每日一诗卡作用
nameprogressWidgetdailyPoemWidget代码里按名字决定推哪套数据
defaultDimension2*24*4加卡时默认尺寸
supportDimensions["2*2"]["4*4"]只支持一种,不提供缩放
isDefaulttruefalse长按图标时默认展示进度卡
scheduledUpdateTime08:0008:00每天系统拉一次
isDynamictruetrue允许 updateForm 主动推数据
updateEnabledtruetrue允许定时刷新
colorModeautoauto跟随系统深浅色

isDynamic: true 是主动刷新的前提。没有它,formProvider.updateForm 推过去的数据不会生效,卡片只会在 scheduledUpdateTime 那一刻被系统叫起来重新拉一次。

supportDimensions 只写一个值,意味着这两张卡不能缩放 —— 2×2 就是 2×2,4×4 就是 4×4。这省掉了「同一张卡在四种尺寸下都要好看」的适配工作。卡片内部所有尺寸都按目标尺寸的可用高宽设计:进度卡按最小高度 148vp 排布,固定内容高度压在 140vp 以内,剩下的余量交给 Blank() 消化。

ExtensionAbility 那一侧的声明在 module.json5 里:

        "extensionAbilities": [
            {
                "name": "FormAbility",
                "srcEntry": "./ets/formability/FormAbility.ets",
                "type": "form",
                "description": "$string:formability_desc",
                "icon": "$media:icon",
                "label": "$string:formability_label",
                "exported": true,
                "metadata": [
                    {
                        "name": "ohos.extension.form",
                        "resource": "$profile:form_config"
                    }
                ]
            }
        ]

type: "form" 加上 ohos.extension.form 这条 metadata,把 profile 与 Ability 连起来。exported: true 让系统能找到它 —— 卡片是系统代为渲染的,Ability 必须对外可见。

数据怎么进到卡片里

这是整块最容易写错的地方。卡片不是普通页面,它的数据不由父组件传参,而是 FormBindingData 通过 LocalStorage 注入。所以卡片组件必须同时具备两样东西:一个自己的 LocalStorage 实例,和 @Entry 上的那个参数。

let storageDailyPoem = new LocalStorage();

@Entry(storageDailyPoem)
@Component
struct DailyPoemWidgetCard {
  @LocalStorageProp('dateText') dateText: string = '';
  @LocalStorageProp('sentence1') sentence1: string = '每日一诗加载中…';
  @LocalStorageProp('sentence2') sentence2: string = '';
  @LocalStorageProp('title') title: string = '';
  @LocalStorageProp('dynasty') dynasty: string = '';
  @LocalStorageProp('author') author: string = '';
  @LocalStorageProp('translation') translation: string = '';
  @LocalStorageProp('streakDays') streakDays: number = 0;
  @LocalStorageProp('collectedCount') collectedCount: number = 0;

LocalStorage 实例声明在 struct 之外,是模块级变量 —— 卡片被系统渲染时这个文件会被独立加载,实例随之创建。@Entry(storageDailyPoem) 把这个实例登记为卡片的默认存储,框架才知道从哪里取注入数据。

九个 @LocalStorageProp(进度卡那边是八个)的属性名必须与 WidgetRefreshService 里那个 interface 的字段名逐字一致。这条对应关系没有任何编译期检查,写错了就是一个永远显示默认值的字段。

两张卡片的文件头都留了同一条注释:

 * 数据接收(关键):动态卡片的数据由 FormBindingData 通过 LocalStorage 注入,
 * 必须使用 @Entry(storage) + @LocalStorageProp 接收(官方动态卡片数据通道),
 * 属性名与 WidgetRefreshService.buildProgressCardData 的输出字段一一对应;
 * 使用 @Local/@ComponentV2 接收不到卡片数据,会导致卡片始终显示默认值。

注释的最后一句给的是一个明确结论:@Local 与 @ComponentV2 都收不到卡片数据。V2 的装饰器体系在这里不适用 —— 卡片运行时的注入通道只认 LocalStorage,而 @LocalStorageProp 是 V1 的装饰器。所以两张卡都写成 @Component(V1),哪怕应用主体全是 @ComponentV2。这是整个项目里唯一两块 V1 代码,原因不是偏好,是通道限制。

@LocalStorageProp 是单向的:LocalStorage 里的值会同步到组件属性,组件改它不会回写。卡片本来也不该改数据 —— 它的交互只有「点击拉起应用」。单向正好。

默认值不只是类型占位,它还承担「首帧」:

  @LocalStorageProp('sentence1') sentence1: string = '每日一诗加载中…';

卡片被加到桌面时,onAddForm 返回的是一个空对象:

  // 必须同步返回,不能声明为 async
  onAddForm(want: Want): formBindingData.FormBindingData {
    return formBindingData.createFormBindingData({});

空数据意味着卡片会先用默认值渲染一遍,几百毫秒后 prepareData() 完成、updateForm 推来真数据,再刷一次。所以「每日一诗加载中…」是用户真的会看到的一帧。默认值写具体文案而不是空串,就是为了这一帧不至于是一片空白。

dateText、streakDays 这类字段默认 ''、0,首帧会短暂显示成空白日期与 0 天。这几个字段的变化频率低、视觉权重小,不值得为它们单独设计占位文案。

alt text

两套数据,两种性格

进度卡的八个字段全是标量:

  buildProgressCardData(): ProgressCardData {
    // 统一使用 StorageService 的词库校验口径,保证卡片与页面数字绝对一致
    const store: StorageService = StorageService.getInstance();
    const u: UserState = store.userState;
    const masteredCount: number = store.getMasteredPoemIds().length;
    const collectedCount: number = store.getCollectedPoemIds().length;
    const levelIdx: number = LevelSystem.getLevelIndex(masteredCount);
    const gap: number = LevelSystem.getNextLevelGap(masteredCount);
    const levelTip: string = gap > 0
      ? `再学会 ${gap} 首升级「${LevelSystem.getNextLevelName(masteredCount)}」`
      : '已达最高等级 · 诗仙本仙';
    const gradeName: string = (u.selectedGrade >= 1 && u.selectedGrade <= 6)
      ? `${u.selectedGrade}年级`
      : '未选年级';
    const data: ProgressCardData = {
      levelIdx: levelIdx,
      levelName: LevelSystem.getLevel(masteredCount).name,
      gradeName: gradeName,
      collectedCount: collectedCount,
      masteredCount: masteredCount,
      streakDays: u.streakDays,
      levelProgress: LevelSystem.getProgress(masteredCount),
      levelTip: levelTip
    };
    return data;
  }

八个字段在卡片上被拆成四种呈现:levelIdx → 本地查表拿徽章资源,levelName / gradeName / levelTip → 直接显示文字,collectedCount / masteredCount / streakDays → 数字,levelProgress → 百分比与进度条宽度。

这个函数是「把业务状态压平成可序列化字段」的边界。它做完之后,卡片侧不需要任何计算 —— levelProgress 已经是 0 到 1 的比值,卡片只做 Math.round(x * 100) 转百分比;levelTip 已经是拼好的整句,卡片只负责 maxLines(1) 加省略号。

每日一诗卡的九个字段里有五个来自同一首诗:

    if (poem !== null) {
      const sentences: string[] = poem.getSentences();
      if (sentences.length > 0) {
        sentence1 = sentences[0];
      }
      if (sentences.length > 1) {
        sentence2 = sentences[1];
      }
      title = poem.title;
      dynasty = poem.dynasty;
      author = poem.author;
      if (poem.translation.length > 0) {
        const firstPart: string = poem.translation.split('。')[0];
        translation = firstPart.length > 24 ? firstPart.substring(0, 24) + '…' : firstPart + '。';
      }
    }

getSentences() 是 Poem 模型上的方法,按标点把正文切成句子。卡片只取前两句 —— 4×4 的卡片高度有限,超过两句会挤掉底部的统计与按钮。

译文处理是两步:先按第一个句号切出第一句,再判断长度。超过 24 字截断加省略号,否则补一个句号。这里有个细节:split('。')[0] 拿到的是不含句号的部分,所以短句要手动补 。,长句截断后不再补(因为它已经被省略号替代了)。两个分支都以「用户看到一句完整的话」为目标,而不是简单地 substring(0, 24)。

卡片侧对应地用条件渲染处理空值:

          if (this.sentence2.length > 0) {
            Text(this.sentence2)
              .fontSize(22)

五言绝句有两句、七言律诗有八句,取前两句对绝大多数诗都成立;但万一某首诗正文只有一句(或者切句失败),sentence2 是空串,那一行 Text 整个不挂载,布局靠 justifyContent(FlexAlign.Center) 自动居中。同理译文那一块也是包在 if (this.translation.length > 0) 里的。

主动推送:300ms 防抖 + 两条枚举通道

卡片自己不会去读数据。应用进程在数据变化后必须主动推:

  /** 应用进程数据变更后防抖刷新全部卡片(短时间内多次变更合并为一次刷新) */
  scheduleRefresh(context: common.Context): void {
    if (this.refreshTimer >= 0) {
      clearTimeout(this.refreshTimer);
    }
    this.refreshTimer = setTimeout(() => {
      this.refreshTimer = -1;
      this.refreshAllForms(context);
    }, REFRESH_DEBOUNCE_MS);
  }

REFRESH_DEBOUNCE_MS 是 300。计时器句柄用 -1 表示「当前没有待执行的刷新」—— 每次调度前先看有没有旧的,有就清掉。回调里第一件事是把句柄置回 -1,这样刷新执行完之后再来一次调度会重新计时,而不是误清一个已经执行过的 id。

防抖针对的是一个具体场景:用户连点收藏按钮。每点一次 toggleCollect 都会 persist 再 notifyWidgetChange,如果每次都立刻 updateForm,三次点击就是三次系统调用。系统对卡片的主动刷新有频次配额,超了会被静默拒绝 —— 表现是「后面几次操作卡片不动了」。300ms 的窗口把连点合并成一次。

refreshAllForms 用两条通道枚举「桌面上到底有哪些卡片」:

  async refreshAllForms(context: common.Context): Promise<void> {
    const refreshed: Set<string> = new Set<string>();
    try {
      // 主通道:系统 API 枚举桌面全部已加桌卡片(含本次修复前添加、formId 未持久化的旧卡片)
      const runningForms: formInfo.RunningFormInfo[] = await formProvider.getPublishedRunningFormInfos();
      for (let i = 0; i < runningForms.length; i++) {
        const info: formInfo.RunningFormInfo = runningForms[i];
        await this.updateForm(info.formId, info.formName);
        refreshed.add(info.formId);
      }
      hilog.info(DOMAIN, TAG, 'refreshAllForms by runningForms, count=%{public}d', runningForms.length);
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'getPublishedRunningFormInfos failed: %{public}s', JSON.stringify(err));
    }
    try {
      // 兜底通道:刷新持久化映射中未被系统 API 覆盖的卡片
      const infos: Record<string, string> = await this.getFormInfoMap(context);
      const formIds: string[] = Object.keys(infos);
      for (let i = 0; i < formIds.length; i++) {
        if (!refreshed.has(formIds[i])) {
          await this.updateForm(formIds[i], infos[formIds[i]]);
        }
      }
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'refreshAllForms fallback failed: %{public}s', JSON.stringify(err));
    }
  }

主通道 getPublishedRunningFormInfos() 是系统 API,一次拿到所有已加桌卡片,连 formName 都带回来,不用查表。它能覆盖「旧版本添加、没写过映射」的卡片 —— 这类卡片的 formId 只存在于系统里。

兜底通道读那张持久化的 widget_forms_store。它存在的理由是「主通道可能整体失败」:getPublishedRunningFormInfos 抛错时,第一个 try 被跳过,如果只有这一条路,所有卡片就都不刷了。两个 try 分开写,前一个失败不影响后一个。

Set 去重保证同一次刷新里每张卡只被推一次。兜底循环里的 if (!refreshed.has(...)) 是关键:没有它,主通道刷过的卡片会被再推一遍,白耗配额。

updateForm 按名字分派:

  /** 按卡片名称推送最新数据到指定卡片 */
  async updateForm(formId: string, formName: string): Promise<void> {
    if (formId.length === 0) {
      return;
    }
    const name: string = formName.length > 0 ? formName : PROGRESS_FORM_NAME;
    try {
      const data: Object = name === DAILY_FORM_NAME
        ? this.buildDailyPoemCardData()
        : this.buildProgressCardData();
      await formProvider.updateForm(formId, formBindingData.createFormBindingData(data));
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'updateForm %{public}s failed: %{public}s', formId, JSON.stringify(err));
    }
  }

「名字为空就按进度卡推」这条兜底在三处出现(saveFormInfo、refreshAllForms、这里),指向同一个常量。之所以允许名字为空,是因为 onAddForm 从 want.parameters 里取 formName,参数缺失时只能拿到空串。

updateForm 的失败被吞掉只记日志。一张卡推送失败不该影响其他卡片的刷新循环。

谁在什么时候触发推送

三个触发点:

触发点时机覆盖场景
SplashPage.aboutToAppear每次冷启动补刷旧卡片,修正上次退出后错过的变更
persistListener每次 persist 成功之后应用内任何写操作
FormAbility.onUpdateForm系统每天 08:00应用未打开时的定时刷新

启动时那次是「补课」:

      // 注册 persist 回调:学习数据每次落盘后,实时刷新桌面服务卡片(收藏/已学会统计)
      StorageService.getInstance().setPersistListener((): void => {
        WidgetRefreshService.getInstance().scheduleRefresh(ctx);
      });
      // 每次启动主动同步一次桌面卡片(覆盖修复前添加、数据已过期的旧卡片)
      WidgetRefreshService.getInstance().scheduleRefresh(ctx);

注册回调之后立刻主动刷一次。回调管的是「本次运行期间的变化」,主动刷管的是「上次运行遗留的差异」。典型场景是用户昨天在应用里学会了三首诗、卡片没刷成就退出了,今天打开应用 —— 这次主动刷新把三首的差额补上。

setPersistListener 是回调注入而不是直接调用,为的是避免 StorageService 反过来 import WidgetRefreshService 形成循环依赖。StorageService 只持有一个 (() => void) | null 字段,谁需要谁注册。

FormAbility.onUpdateForm 走的是另一条路:

  onUpdateForm(formId: string): void {
    const widget: WidgetRefreshService = WidgetRefreshService.getInstance();
    this.prepareData().then(async () => {
      const infos: Record<string, string> = await widget.getFormInfoMap(this.context);
      const formName: string = infos[formId] !== undefined ? infos[formId] : '';
      await widget.updateForm(formId, formName);
    }).catch((e: Object) => {
      hilog.error(DOMAIN, TAG, 'update failed: %{public}s', JSON.stringify(e));
    });
  }

系统只传了一个 formId,没有卡片名。所以要靠持久化映射反查 —— 查不到就传空串,updateForm 里那条「空名字按进度卡推」会接管。对每日一诗卡来说这会导致推错数据,但映射正常情况下一定存在(加卡时就写了),查不到属于异常。

onAddForm 为什么不能是 async

  // 必须同步返回,不能声明为 async
  onAddForm(want: Want): formBindingData.FormBindingData {
    const params: Record<string, Object> = want.parameters as Record<string, Object>;
    let formId: string = '';
    let formName: string = '';
    ...
    const widget: WidgetRefreshService = WidgetRefreshService.getInstance();
    // 持久化 formId → 卡片名,供应用进程主动刷新
    widget.saveFormInfo(this.context, formId, formName);

    // 异步加载词库与学习进度,完成后主动刷新,不阻塞返回
    this.prepareData().then(() => {
      return widget.updateForm(formId, formName);
    }).catch((e: Object) => {
      hilog.error(DOMAIN, TAG, 'prepare failed: %{public}s', JSON.stringify(e));
    });

    return formBindingData.createFormBindingData({});
  }

onAddForm 的返回值就是卡片的初始数据,系统等不了 await。所以它同步返回空对象,把「加载数据 + 推一次」全部甩到 then 里。卡片会先显示默认值,随后被推上真数据。

saveFormInfo 用 void 方式调用(不 await),因为它写的是另一张表,失败也不影响这次渲染。如果 await 它,onAddForm 就变成异步了。

prepareData 做了两层缓存:

  private prepareData(): Promise<void> {
    const repo: PoemRepository = PoemRepository.getInstance();
    if (repo.isLoaded()) {
      return StorageService.getInstance().init(this.context, true);
    }
    if (this.preparationPromise === null) {
      this.preparationPromise = repo.load(this.context)
        .then(() => StorageService.getInstance().init(this.context, true))
        .then(() => {
          this.preparationPromise = null;
        });
    }
    return this.preparationPromise;
  }

词库加载是一次 59518 字节的 rawfile 读取加 JSON.parse,卡片进程里可能被 onAddForm 与 onUpdateForm 连续触发两次。isLoaded() 短路掉「已加载」的情况,preparationPromise 合并并发 —— 两次调用拿到同一个 Promise,只加载一次。

第三个 then 把 preparationPromise 置回 null。这一步让失败之后还能重试:如果 Promise 被 reject,preparationPromise 若留在原地,后续调用会一直复用那个已失败的 Promise,永远不再尝试加载。置回 null 之后,下一次 prepareData 会重新发起。

注意 StorageService.init(this.context, true) 的第二个参数 true —— 卡片进程以只读模式读同一张表。这一层决定了卡片拿到的数据是最新的,同时卡片进程永远不会写回。

点击卡片之后

两张卡片的点击行为完全一致,都是拉起元服务:

      .onClick(() => {
        postCardAction(this, {
          action: 'router',
          abilityName: 'EntryAbility',
          params: {}
        });
      })

params 是空对象,也就是「打开应用,不指定页面」。启动后走正常的启动流程:SplashPage 判断有没有选过年级,选过就进主页面。

没有做到「点进度卡的收藏数字直接进收藏页」这种深链,因为 action: 'router' 拉起的是整个元服务,路由参数需要 EntryAbility.onCreate 里再解析一次 want,多一层不产生价值的转发。卡片的作用是提示与唤起,不是导航。

onFormEvent 是空实现:

  onFormEvent(formId: string, message: string): void {
    // 当前卡片均使用 router 拉起元服务,message 事件预留
  }

它对应的是卡片里 action: 'message' 那种不拉起应用、只给 Ability 发消息的交互。两张卡都没用,函数留着是因为 FormExtensionAbility 的接口里它在那里。

两条通道的分工

把「数据怎么进去」和「什么时候推一次」放在一起对照:

维度数据注入通道主动刷新通道
载体FormBindingData → LocalStorageformProvider.updateForm
接收方式@Entry(storage) + @LocalStorageProp同上,一次推送就是一次重新注入
谁发起卡片渲染时框架应用进程(或卡片进程的 onAddForm)
触发时机加卡、每次 updateForm、每天 08:00数据变更(300ms 防抖)、冷启动、定时
字段契约interface 字段名与属性名逐字对应同左
失败表现字段停在默认值卡片停在上次的数据,等下次触发

两条通道其实是同一条链路的两个方向:updateForm 把 FormBindingData 塞进卡片的 LocalStorage,@LocalStorageProp 再把值同步到组件属性。所以接收侧的可靠性决定了推送侧有没有意义 —— 属性名写错,推得再勤也只显示默认值;推送失效,属性名再对也永远停在首帧。这条链路上只有三个约束是硬的:卡片必须是 @Component 加 @Entry(storage) 加 @LocalStorageProp(V2 装饰器在这条通道上收不到数据),推送必须防抖(配额有限,连点会撞上限被静默拒绝),枚举卡片要两条通道并用(系统 API 覆盖旧卡片,持久化映射在主通道失败时兜底)。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐