1. 前言

桌面卡片上写着“湖边手记”和“山路手记”,点击后如果都只打开应用首页,用户还得自己再找一次。卡片已经知道用户选了什么,就应该把这份选择一起交给应用,而不是只完成一次启动。

这次给“下一站手记”准备两个卡片按钮。它们启动同一个UIAbility,用不同参数指定手记;应用收到参数后更新当前入口来源和目标内容。界面把来源也显示出来,便于核对这次看到的内容究竟来自应用入口还是桌面点击。

2. 桌面入口的参数链路

如果桌面两个按钮都只打开同一张首页,看起来“能跳转”,用户却仍要在应用里重新找内容。这里希望湖边按钮打开湖边手记,山路按钮打开山路手记。因此动作里不仅需要目标Ability,还要携带能够区分两份内容的参数。

整条路径有四段:RouteCard发出router动作,系统把Want交给EntryAbility,Ability把有限的目标映射成应用状态,Index读取状态显示对应手记。卡片本身没有去操作应用页面里的Text,也没有假定入口页此时一定已经创建。

先准备API 24空白工程,保留应用入口,再加入卡片提供方、卡片页面与profile。完整配置在后文给出;添加卡片之前,先确认应用图标可以正常进入Index,随后再测试卡片路由。这样能够分辨是应用入口本来就有问题,还是新增卡片参数路径出了问题。

2.1 source与target约定

两个动作使用相同的router类型和EntryAbility,source都为desktop-card,用来说明来源;target分别为lake与mountain,决定内容。这种写法让“从哪里来”和“要去哪里”分成两个字段,后面增加通知或搜索入口时,来源与目标不必混进一个难以解析的字符串。

Row({ space: 10 }) {
        Button('湖边手记').fontSize(14).backgroundColor('#477E86').onClick(() => {
          postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { source: 'desktop-card', target: 'lake' } });
        })
        Button('山路手记').fontSize(14).backgroundColor('#586D75').onClick(() => {
          postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { source: 'desktop-card', target: 'mountain' } });
        })
      }

abilityName对应模块登记的应用能力,不是卡片提供方EntryFormAbility。后者处理卡片数据,前者负责应用窗口。如果把这两个名字用反,问题发生在入口选择,不会靠修改页面背景或多添加一个Text解决。

这两个target也不是直接交给路由执行的任意页面路径。Ability只识别当前支持的山路值,否则使用湖边作为默认。对于这个两种内容的演示,有限映射比把外部传入字符串原样当作页面名称更容易核对,缺省启动时也有明确可显示的内容。

2.2 RouteCard发出router动作

卡片布局位于 widget/pages/RouteCard.ets。两个按钮使用相同的abilityName,source标明入口,target分别是lake和mountain。显示文字与参数值分开:文字可以调整,应用判断仍按固定的参数值进行。

@Entry
@Component
struct RouteCard {
  build() {
    Column({ space: 14 }) {
      Text('下一站').fontSize(14).fontColor('#B9D8DF').width('100%')
      Text('把风景接回手记').fontSize(25).fontColor('#FFFFFF').fontWeight(FontWeight.Bold).layoutWeight(1)
      Row({ space: 10 }) {
        Button('湖边手记').fontSize(14).backgroundColor('#477E86').onClick(() => {
          postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { source: 'desktop-card', target: 'lake' } });
        })
        Button('山路手记').fontSize(14).backgroundColor('#586D75').onClick(() => {
          postCardAction(this, { action: 'router', abilityName: 'EntryAbility', params: { source: 'desktop-card', target: 'mountain' } });
        })
      }
    }.width('100%').height('100%').padding(20).backgroundColor('#233E4A')
  }
}

postCardAction处理的是卡片到应用的入口动作,不是在卡片里面维护一份普通页面路由栈。后面增加更多手记时,可以扩展目标标识,但应保持参数有限、可识别,避免把任意路径或未经检查的输入直接当成页面地址。

3. EntryAbility接收Want

EntryAbility.ets在onCreate和onNewWant里调用同一个receive方法。这样,应用第一次创建与已存在时收到的新入口,都沿相同规则读取来源和目标,不需要两份略有差别的参数判断。

import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
export default class EntryAbility extends UIAbility {
  private receive(want: Want): void {
    const source = want.parameters?.source as string;
    const target = want.parameters?.target as string;
    AppStorage.setOrCreate('entrySource', source === 'desktop-card' ? '来自桌面卡片' : '来自应用入口');
    AppStorage.setOrCreate('entryTarget', target === 'mountain' ? '山路手记' : '湖边手记');
    hilog.info(0xD001, 'P6CardRoute', 'Received %{public}s %{public}s', source ?? '', target ?? '');
  }
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.receive(want); }
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.receive(want); }
  onWindowStageCreate(stage: window.WindowStage): void { stage.loadContent('pages/Index'); }
}

receive只接受本例约定的目标。mountain对应山路,其他情况回到湖边;来源同样检查固定值,普通图标启动显示应用入口。这是本地示例的简化映射,正式业务可以为无效目标提供更明确的默认页或错误提示,但不要默认每次Want都一定携带完整参数。

入口解析完成后,将结果放进当前进程的AppStorage。页面不必自己猜应用为什么被打开,也不用在每次构建时重新读取Want。Ability负责解释启动意图,页面负责展示已经确定的内容,这样后面再接通知或搜索入口,也有清楚的接入位置。

这一步还可以让入口提示与正文一起核对。source说明请求从哪里来,target说明应当看哪条记录;两者含义不同,不能用“来自桌面”直接代替目标内容。以后增加第三个按钮时,只需给它一个明确目标,再检查页面是否同时更新标题和对应正文。

3.1 onCreate与onNewWant共用处理

只在onCreate取参数,适合首次创建的路径,却可能漏掉应用实例已存在时的新Want。代码把共同处理抽成receive,让onCreate和onNewWant都调用它。这样桌面第二次点另一份手记时,不会只把旧窗口叫到前面,正文仍然停留在上次目标。

private receive(want: Want): void {
    const source = want.parameters?.source as string;
    const target = want.parameters?.target as string;
    AppStorage.setOrCreate('entrySource', source === 'desktop-card' ? '来自桌面卡片' : '来自应用入口');
    AppStorage.setOrCreate('entryTarget', target === 'mountain' ? '山路手记' : '湖边手记');
    hilog.info(0xD001, 'P6CardRoute', 'Received %{public}s %{public}s', source ?? '', target ?? '');
  }
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.receive(want); }
  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.receive(want); }
  onWindowStageCreate(stage: window.WindowStage): void { stage.loadContent('pages/Index'); }

receive先读取参数,再写入entrySource与entryTarget。source不等于desktop-card时显示普通应用入口;target不等于mountain时使用湖边。这个默认行为也解释了从应用图标进入时为什么不会看到一块没有目标内容的空页面。

Index用StorageLink关联这两个键,因此已显示的页面也能接到后续改变。若在页面创建时只把AppStorage值复制到一个普通字符串,第二次Want虽然处理了,页面仍可能没有更新。参数接收与界面关联是两站,必须一起接通。

本轮实录重点覆盖已有应用时的两次桌面入口:先湖边,再山路。代码同时安排首次创建入口,但没有把同一段暖启动演示额外描述成已经独立测完所有冷启动与后台恢复组合。验证范围与实现路径需要分别说明。

4. Index关联目标内容

页面通过StorageLink连接entrySource和entryTarget。目标变化后,标题和正文使用同一份值,避免来源提示更新了,正文却还停在上一条。这里的正文是本地短文,先把参数映射看清楚,不加入异步网络请求。

import { AddFormMenuItem } from '@kit.ArkUI';
@Entry
@Component
struct Index {
  @StorageLink('entrySource') source: string = '来自应用入口';
  @StorageLink('entryTarget') target: string = '湖边手记';
  @State status: string = '先将卡片添加到桌面';
  @Builder
  addMenu() {
    Menu() {
      AddFormMenuItem({ bundleName: 'com.aasd23.p6cardroute', abilityName: 'EntryFormAbility',
        parameters: { 'ohos.extra.param.key.form_dimension': 2, 'ohos.extra.param.key.form_name': 'RouteEntry',
          'ohos.extra.param.key.module_name': 'entry' } }, 'routePreview', {
        style: { options: { content: '添加手记入口' } },
        callback: (error, id) => { this.status = error && error.code ? '添加失败:' + error.code : '卡片已添加'; }
      })
    }
  }
  build() {
    Column({ space: 24 }) {
      Text('下一站手记').fontSize(34).fontWeight(FontWeight.Bold).width('100%')
      Text(this.source + ':' + this.target).id('source').fontSize(20).fontColor('#477E86').width('100%')
      Column({ space: 16 }) {
        Text(this.target).fontSize(30).fontWeight(FontWeight.Bold)
        Text(this.target === '湖边手记' ? '沿着湖走了一圈,风把树影吹成了碎片。' : '山路拐了一个弯,城市的声音留在了身后。').fontSize(20).lineHeight(36)
      }.layoutWeight(1).width('100%').padding(28).backgroundColor('#ECF3F1').borderRadius(18)
      Column({ space: 8 }) {
        Text('下一站').fontColor('#B9D8DF').fontSize(14)
        Text('把风景接回手记').fontColor('#FFFFFF').fontSize(22)
        Text('湖边手记 / 山路手记').fontColor('#B9D8DF').fontSize(14)
      }.id('routePreview').width(280).height(180).justifyContent(FlexAlign.Center).backgroundColor('#233E4A').borderRadius(16)
      Button('添加桌面入口').id('addMenu').backgroundColor('#477E86').bindMenu(this.addMenu)
      Text(this.status).id('status').fontSize(13).fontColor('#687D83')
    }.width('100%').height('100%').padding(26).backgroundColor('#FCF9F0')
  }
}

如果以后正文从网络加载,应在写回之前核对请求对应的目标。用户很快从湖边切到山路,较慢返回的湖边请求不该再覆盖山路页面。入口参数解决的是选中了谁,异步数据仍要遵守同一份身份判断,不能因为页面已经打开就忽略后续时序。

5. RouteEntry卡片注册

提供方返回基础绑定数据,布局本身定义两个跳转入口。本例没有使用message更新卡片内容,因此扩展代码保持简短,主要职责是让系统能取得这张卡片。

import { FormExtensionAbility, formBindingData } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit';
class RouteData { title: string = '下一站,湖边'; }
export default class EntryFormAbility extends FormExtensionAbility {
  onAddForm(want: Want): formBindingData.FormBindingData {
    return formBindingData.createFormBindingData(new RouteData());
  }
}

配置指定RouteEntry、布局文件、默认规格和支持规格;添加菜单传入的名字必须与这里一致。resources/base/profile/form_config.json如下。

{
  "forms": [
    {
      "name": "RouteEntry",
      "displayName": "$string:form_name",
      "description": "$string:form_desc",
      "src": "./ets/widget/pages/RouteCard.ets",
      "uiSyntax": "arkts",
      "window": {
        "designWidth": 360,
        "autoDesignWidth": true
      },
      "colorMode": "auto",
      "isDefault": true,
      "updateEnabled": false,
      "defaultDimension": "2*2",
      "supportDimensions": [
        "2*2",
        "2*4"
      ],
      "isDynamic": true
    }
  ]
}

下面是完整模块配置和对应字符串资源。普通应用入口与form扩展分别声明,不能把扩展误写进普通abilities列表。

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": [
      "phone",
      "2in1"
    ],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          {
            "entities": [
              "entity.system.home"
            ],
            "actions": [
              "ohos.want.action.home"
            ]
          }
        ]
      }
    ],
    "extensionAbilities": [
      {
        "name": "EntryFormAbility",
        "srcEntrance": "./ets/entryformability/EntryFormAbility.ets",
        "type": "form",
        "exported": true,
        "metadata": [
          {
            "name": "ohos.extension.form",
            "resource": "$profile:form_config"
          }
        ]
      }
    ]
  }
}
{
  "string": [
    {
      "name": "form_name",
      "value": "手记入口"
    },
    {
      "name": "form_desc",
      "value": "一张可以从桌面使用的手记卡片"
    }
  ]
}

主页面中的routePreview用于添加时的快照,桌面真正运行的布局仍是RouteCard。添加成功只说明卡片发布路径完成,按钮跳转还需要回桌面实际点击验证,不能把这两件事合并成一个“看起来都好了”的判断。

应用已经在后台时,再点另一个目标尤其值得检查。此时用户想切内容,并不希望重新回到默认首页。代码将后续Want也交给receive,页面继续订阅相同的目标状态,避免启动处理只写在最初创建的一处,后面收到的选择却没人接。

如果某条手记以后被删除,也要安排可理解的去处。卡片可能保留着旧入口,应用收到目标后仍应检查记录是否存在,再给出默认页或提示。卡片上的标题不能代替数据有效性检查,入口越方便,这些失效情况越需要在应用侧统一处理。

6. 湖边与山路的跳转实录

本次在 HarmonyOS 6.1.1、API 24 的二合一模拟器运行。卡片实际添加到系统桌面后,先点湖边手记,应用显示“来自桌面卡片:湖边手记”;再回到桌面点山路手记,页面换成山路正文,来源仍为桌面卡片。

这里不仅看应用是否被拉起,还看目标是否正确变化。两个按钮分别对应两段内容,能帮助发现参数未读取、字段名写错或只处理第一次启动等问题。一个按钮重复点很多次,即使总回到默认内容,也可能暂时看不出哪里遗漏了。

6.1 入口、参数与页面排查

若应用完全没打开,先核对动作类型、Ability名与卡片注册;若打开了却总显示湖边,检查target是否和接收端识别值一致;若日志里目标已换而画面不换,再检查页面是否真的关联同一个AppStorage键。这样从入口、参数到显示逐站查,比一开始就重装应用更容易保留问题现场。

验收时同时看来源提示和正文标题。来源正确只能说明卡片标记传到了应用;标题也切换,才说明业务目标继续走到了页面。两处各承担一个环节,不用单个“启动成功”提示替代整条参数链路的结果。

卡片配置还有RouteEntry定义名、RouteCard源路径和添加时使用的名称,三者要指向同一份卡片。源文件存在不代表已经被系统登记,应用内预览正确也不代表桌面宿主能找到它。完整模块配置、profile和字符串依赖都保留在原文代码中,复现时应一起准备。

如果从手记扩展成商品详情,可以传稳定商品ID,再由应用查询数据,而不是把整段正文塞进卡片动作。这样数据变化后入口仍然有效,也避免卡片维护一份与应用可能不同步的内容副本。当前两篇手记是本地固定文案,没有接入这层查询。

还应明确返回的含义。本例是在同一个Index里切换显示目标,没有为每次卡片点击追加新的详情路由;因此不声称返回键会自动逐一退回所有历史手记。等真正引入多页面导航时,再把目标映射与应用自己的路由栈策略对齐,入口与返回才会形成完整使用路径。

7. 总结

卡片传入的是业务标识,页面最后展示的是业务内容,中间需要一段明确的映射。若目标以后带上记录id,可以先校验类型、存在性与访问条件,再读取数据;不要直接把外部参数拼成任意路由,入口越多,越需要统一处理。

当前AppStorage只在本应用进程共享这些值,不会自动保存浏览历史,也不负责跨设备同步。重新启动、重新打开与同一实例收到新请求,各有自己的生命周期,应该分别确定默认行为。这个小例子通过共用receive方法,先让两种回调保持一致。

最后看用户实际少走的那一步:点的是湖边,打开就看到湖边;点的是山路,正文立即换成山路。桌面卡片的快捷价值就落在这里。关于router与message等动作的范围,可参阅华为postCardAction接口说明。

Logo

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

更多推荐