【私房菜集 HarmonyOS ArkTS 实战系列 13】分享截图与桌面卡片实战

第 12 篇讲了设置与主题,应用级偏好已经能影响窗口和资源。第 13 篇继续看应用外能力:菜谱内容不只停留在详情页,也可以生成分享卡片保存到图库,还可以通过桌面卡片展示每日推荐,并从卡片跳回菜谱详情。本篇重点拆 ShareRecipePageShareServiceEntryFormAbilityWidgetCardform_config.jsonEntryAbility 的跳转处理。

私房菜集分享页真实截图

一、分享和卡片解决的是应用外触点

菜谱应用的核心浏览链路已经在前几篇完成:列表、搜索、详情、收藏、计时器、偏好和设置。到了分享和桌面卡片,关注点发生变化:

  • 分享页把当前菜谱组织成一张可保存图片。
  • 桌面卡片把推荐菜谱放到 App 外部入口。
  • 点击桌面卡片时,需要重新回到应用内详情页。

这三件事看似独立,但都围绕同一个问题:菜谱内容如何离开当前页面,同时仍然能保持数据和跳转闭环。

二、源码对象总览

源码对象 作用
pages/ShareRecipePage.ets 分享预览页,展示菜谱卡片并保存到图库。
services/ShareService.ets 构建分享文案。
entryformability/EntryFormAbility.ets 桌面卡片扩展能力,提供推荐数据并处理卡片刷新事件。
widget/pages/WidgetCard.ets 桌面卡片 UI,使用 LocalStorageProp 接收数据并触发跳转。
resources/base/profile/form_config.json 声明卡片名称、尺寸、动态更新周期和入口页面。
entryability/EntryAbility.ets 处理卡片传入的 want 参数,并跳转到菜谱详情页。

分享页是 App 内页面,桌面卡片是扩展能力;两者都围绕菜谱内容,但技术边界完全不同。

三、可复现工程上下文

这一篇涉及 App 页面、系统保存控件、图库访问、FormExtensionAbility 和 Widget 页面,文件分布比前几篇更分散:

能力 文件路径
分享预览页 entry/src/main/ets/pages/ShareRecipePage.ets
分享文案服务 entry/src/main/ets/services/ShareService.ets
桌面卡片扩展 entry/src/main/ets/entryformability/EntryFormAbility.ets
卡片 ArkTS 页面 entry/src/main/ets/widget/pages/WidgetCard.ets
卡片配置 entry/src/main/resources/base/profile/form_config.json
卡片回跳处理 entry/src/main/ets/entryability/EntryAbility.ets

本次实测环境如下:

项目 实测值
运行设备 本地 HarmonyOS 模拟器
应用包名 com.lesson.myapplicationsfcj
包配置版本 AppScope/app.json5 -> versionName: 1.0.1, versionCode: 1000000
SDK 版本 targetSdkVersion: 6.1.0(23), compatibleSdkVersion: 6.0.2(22)
工程模型 modelVersion: 6.1.0, apiType: stageMode
卡片尺寸 2*2
卡片更新策略 08:00 定时更新,updateDuration 为 48

从调用链看,它分成两条路径:


详情页 -> 分享页 -> componentSnapshot -> ImagePacker -> PhotoAccessHelper -> 图库

EntryFormAbility -> formBindingData -> WidgetCard -> postCardAction -> EntryAbility -> 详情页

下面这张图把两条路径放到同一张工程图里,便于定位代码边界:

私房菜集分享与桌面卡片调用链

四、分享页入口:详情页右上角跳转

详情页通过 AppPageHeader 的右侧按钮进入分享页:


AppPageHeader({
  title: '菜谱详情',
  onBack: () => this.handleBack(),
  onRight: () => {
    if (this.data.recipe.id.length > 0) {
      router.pushUrl({
        url: AppRoutes.SHARE,
        params: { recipeId: this.data.recipe.id }
      });
    }
  }
})

分享页只需要一个参数:recipeId。这样分享页不依赖详情页传入完整菜谱对象,而是自己通过服务层重新读取详情数据。

五、ShareRecipePage:读取当前菜谱详情

分享页的状态和详情页类似,使用 RecipeDetailData


@State data: RecipeDetailData = {
  recipe: {
    id: '',
    title: '',
    description: '',
    categoryId: '',
    categoryName: '',
    coverImages: [],
    durationMinutes: 0,
    difficulty: 'simple',
    difficultyText: '',
    serving: '',
    ingredients: [],
    steps: [],
    tips: [],
    viewCount: 0,
    source: 'builtIn',
    tags: [],
    createdAt: 0,
    updatedAt: 0
  },
  isFavorite: false,
  isInTodoList: false,
  note: '',
  avoidedIngredients: []
};

页面出现时读取路由参数:


async aboutToAppear() {
  recipeService.init(getContext(this) as common.UIAbilityContext);
  const params = router.getParams() as ShareParams;
  this.recipeId = params.recipeId ?? '';
  const detail = await recipeService.getRecipeDetail(this.recipeId);
  if (detail === null) {
    promptAction.showToast({ message: 'Recipe not found' });
    router.back();
    return;
  }
  this.data = detail;
}

这段逻辑和详情页保持一致:找不到菜谱就 toast 并返回,避免分享页显示空卡片。

六、分享卡片 UI:可截图区域要有稳定 id

分享页中用于截图保存的区域有一个固定 id:


private readonly shareCardId: string = 'share-card';

渲染时把这个 id 绑定到卡片容器:


Column({ space: 12 }) {
  RecipeImage({
    src: this.data.recipe.coverImages[0] ?? '',
    title: this.data.recipe.title,
    heightValue: 220,
    radius: 14
  })
  Text(this.data.recipe.title)
    .fontSize(24)
    .fontWeight(FontWeight.Bold)
  Text(this.data.recipe.description)
    .fontSize(14)
    .maxLines(3)
  Text(`Ingredients: ${this.data.recipe.ingredients
    .slice(0, 4)
    .map(item => item.name)
    .join(' / ')}`)
  Text('From Private Menu')
}
.id(this.shareCardId)
.width('100%')
.padding(16)
.backgroundColor($r('app.color.card_bg'))
.borderRadius(16)

这个 idcomponentSnapshot.get() 的抓取目标。如果没有稳定 id,就只能截整页或依赖布局层级,后续 UI 调整时很容易失效。

七、保存到图库:组件截图 + ImagePacker + PhotoAccessHelper

保存逻辑在 saveCardToAlbum() 中:


private async saveCardToAlbum() {
  let pixelMap: image.PixelMap | undefined = undefined;
  let packer: image.ImagePacker | undefined = undefined;
  let file: fs.File | undefined = undefined;
  try {
    pixelMap = await componentSnapshot.get(this.shareCardId);
    packer = image.createImagePacker();
    const imageBuffer = await packer.packToData(pixelMap, {
      format: 'image/jpeg',
      quality: 95
    });
    const context = getContext(this) as common.UIAbilityContext;
    const helper = photoAccessHelper.getPhotoAccessHelper(context);
    const uri = await helper.createAssetWithShortTermPermission({
      title: `sfcj_share_${Date.now()}`,
      fileNameExtension: 'jpg',
      photoType: photoAccessHelper.PhotoType.IMAGE
    });
    file = fs.openSync(uri, fs.OpenMode.READ_WRITE);
    fs.writeSync(file.fd, imageBuffer);
    fs.fsyncSync(file.fd);
    promptAction.showToast({ message: 'Saved to gallery' });
  } catch (_) {
    promptAction.showToast({ message: 'Save failed' });
  } finally {
    ...
  }
}

这条链路可以拆成四步:

  • componentSnapshot.get() 把指定组件转成 PixelMap
  • ImagePacker.packToData()PixelMap 编码为 JPEG。
  • PhotoAccessHelper.createAssetWithShortTermPermission() 创建图库资源。
  • fs.writeSync() 写入图片数据。

最后的 finally 负责关闭文件并释放 packerpixelMap


if (file !== undefined) {
  fs.closeSync(file);
}
if (packer !== undefined) {
  await packer.release();
}
if (pixelMap !== undefined) {
  await pixelMap.release();
}

图片处理对象不释放,连续保存多次时就可能造成内存压力。

八、SaveButton:把权限入口交给系统组件

页面底部使用系统 SaveButton


SaveButton({
  icon: SaveIconStyle.LINES,
  text: SaveDescription.SAVE_TO_GALLERY,
  buttonType: ButtonType.Capsule
})
  .width('100%')
  .height(46)
  .fontColor($r('app.color.primary_orange'))
  .backgroundColor($r('app.color.primary_orange_light'))
  .onClick((_event: ClickEvent, result: SaveButtonOnClickResult) => {
    if (result === SaveButtonOnClickResult.SUCCESS) {
      this.saveCardToAlbum();
    } else if (result === SaveButtonOnClickResult.TEMPORARY_AUTHORIZATION_FAILED) {
      promptAction.showToast({ message: 'Permission required' });
    }
  })

这里没有手写普通按钮去申请权限,而是使用保存场景专用按钮。用户点击成功后才执行保存动作,临时授权失败时给出提示。

九、ShareService:分享文案保持最小可用

服务层还有一个简单的分享文案构建:


export class ShareService {
  buildShareText(recipeId: string): string {
    const recipe = recipeService.getAllRecipes()
      .find((item: Recipe) => item.id === recipeId);
    if (!recipe) {
      return '来自 私房菜集 的一道家常菜。';
    }
    return `${recipe.title}\\n${recipe.description}\\n来自 私房菜集`;
  }
}

当前分享页主要走“保存图片”路径,ShareService 提供的是文本分享基础能力。后续如果接入系统分享面板,可以直接复用这个方法生成文案。

十、桌面卡片配置:form_config 声明边界

桌面卡片从 form_config.json 开始:


{
  "forms": [
    {
      "name": "widget",
      "displayName": "$string:widget_recipe_daily_name",
      "description": "$string:widget_recipe_daily_desc",
      "src": "./ets/widget/pages/WidgetCard.ets",
      "uiSyntax": "arkts",
      "colorMode": "auto",
      "isDynamic": true,
      "isDefault": true,
      "updateEnabled": true,
      "scheduledUpdateTime": "08:00",
      "updateDuration": 48,
      "defaultDimension": "2*2",
      "supportDimensions": [
        "2*2"
      ]
    }
  ]
}

几个字段值得关注:

  • src 指向 WidgetCard.ets,卡片 UI 和主应用页面分开。
  • colorMode: auto 让卡片跟随系统明暗模式。
  • isDynamic: true 表示卡片数据可动态更新。
  • scheduledUpdateTimeupdateDuration 声明定时更新策略。
  • 当前只支持 2*2,避免多个尺寸带来额外适配成本。

十一、EntryFormAbility:给卡片提供推荐数据

EntryFormAbility 维护了一组推荐候选:


private readonly candidates: RecipeWidgetData[] = [
  {
    recipeId: 'tomato_scrambled_eggs',
    sectionTitle: '今日推荐',
    dishName: '番茄炒蛋',
    meta: '18分钟 · 简单 · 2人份',
    description: '酸甜开胃、简单快手的家常菜。',
    coverImage: 'resource://RAWFILE/dishes/images/tomato_scrambled_eggs-1.jpg'
  },
  ...
];

新增卡片时返回初始数据:


onAddForm(want: Want): formBindingData.FormBindingData {
  hilog.info(DOMAIN, TAG, 'onAddForm: %{public}s', JSON.stringify(want.parameters ?? {}));
  return formBindingData.createFormBindingData(this.getInitialWidgetData());
}

卡片更新时重新写入数据:


onUpdateForm(formId: string): void {
  hilog.info(DOMAIN, TAG, 'onUpdateForm: %{public}s', formId);
  this.updateForm(formId, this.getInitialWidgetData());
}

卡片事件中随机换一道推荐:


onFormEvent(formId: string, message: string): void {
  hilog.info(DOMAIN, TAG, 'onFormEvent: %{public}s %{public}s', formId, message);
  this.updateForm(formId, this.pickRandomWidgetData());
}

更新实现如下:


private updateForm(formId: string, data: RecipeWidgetData): void {
  const formData = formBindingData.createFormBindingData(data);
  formProvider.updateForm(formId, formData).catch((error: BusinessError) => {
    hilog.error(DOMAIN, TAG, 'updateForm failed: %{public}s', JSON.stringify(error));
  });
}

这说明卡片不直接访问页面状态,而是通过 FormExtensionAbility 提供的 binding data 渲染。

十二、WidgetCard:LocalStorageProp 接收卡片数据

卡片页面使用独立的 LocalStorage


let widgetStorage = new LocalStorage();

@Entry(widgetStorage)
@Component
struct WidgetCard {
  @LocalStorageProp('recipeId') recipeId: string = 'tomato_scrambled_eggs';
  @LocalStorageProp('sectionTitle') sectionTitle: string = '今日推荐';
  @LocalStorageProp('dishName') dishName: string = '番茄炒蛋';
  @LocalStorageProp('meta') meta: string = '18分钟 · 简单 · 2人份';
  @LocalStorageProp('description') description: string = '酸甜开胃、简单快手的家常菜。';
  @LocalStorageProp('coverImage') coverImage: string = '';
}

这些字段和 RecipeWidgetData 对齐。EntryFormAbility 更新绑定数据后,WidgetCard 就能用 LocalStorageProp 读取并渲染。

图片区域也有兜底:


if (this.coverImage.length > 0) {
  Image(this.coverImage)
    .width('64%')
    .height('100%')
    .objectFit(ImageFit.Cover)
} else {
  Column() {
    Text('菜')
      .fontSize(24)
      .fontWeight(FontWeight.Bold)
      .fontColor('#F57C22')
  }
}

桌面卡片最怕数据不完整时显示空白,这里至少保证没有图片时还能显示一个占位区域。

十三、从桌面卡片跳回详情页

卡片点击调用 postCardAction()


private openDetail(): void {
  postCardAction(this, {
    action: 'router',
    abilityName: 'EntryAbility',
    params: {
      targetPage: 'recipeDetail',
      recipeId: this.recipeId,
      source: 'widget'
    }
  });
}

主应用的 EntryAbility 接收 want 参数后解析:


private handleCardRouterWant(want: Want): void {
  const rawParams = want.parameters?.params;
  if (!rawParams) {
    return;
  }
  try {
    const params = JSON.parse(String(rawParams)) as CardRouterParams;
    if (params.source === 'widget' &&
      params.targetPage === 'recipeDetail' &&
      params.recipeId &&
      params.recipeId.length > 0) {
      this.pendingRecipeId = params.recipeId;
    }
  } catch (err) {
    hilog.error(DOMAIN, 'testTag',
      'Failed to parse card params. Cause: %{public}s',
      JSON.stringify(err));
  }
}

真正跳转在内容加载完成后执行:


private openPendingRecipeDetail(): void {
  if (!this.isContentLoaded || this.pendingRecipeId.length === 0) {
    return;
  }
  const recipeId = this.pendingRecipeId;
  this.pendingRecipeId = '';
  router.pushUrl({
    url: 'pages/RecipeDetailPage',
    params: {
      recipeId,
      source: 'widget'
    }
  });
}

这里用 pendingRecipeId 缓存目标,是为了处理启动时机:卡片可能在应用未加载内容时被点击,必须等 loadContent('pages/Index') 完成后再路由到详情页。

十四、发布前验证清单

分享和桌面卡片属于系统能力,验证时要同时覆盖页面展示、系统授权、文件写入、卡片配置和 Ability 回跳。本篇按下面清单完成交付前检查:

检查项 操作 预期结果
分享页数据 从任意详情页进入分享页 标题、描述、封面和食材来自当前 recipeId
截图区域 检查 .id(this.shareCardId) componentSnapshot.get() 能截到分享卡片,而不是整页。
图库保存 点击 SaveButton 授权成功后生成 JPEG 并写入图库。
资源释放 连续保存多次 PixelMapImagePacker 和文件句柄能释放。
卡片配置 检查 form_config.json src 指向 WidgetCard.ets,尺寸为 2*2。
卡片刷新 触发 onFormEvent() 推荐菜谱更新为随机候选。
卡片回跳 点击 WidgetCard EntryAbility 解析参数并跳转详情页。

这张清单能把“能展示”提升到“能交付”。尤其是保存到图库和桌面卡片跳转,涉及系统授权和 Ability 生命周期,必须独立验证。

本次 App 内分享链路的模拟器验证记录如下:

验证点 真实运行结果
当前页面 pages/ShareRecipePage
页面标题 Share
菜谱标题 蒜香椰奶蜜豆甜汤
食材摘要 Ingredients: 椰奶 / 蜜豆 / 姜葱蒜 / 盐
保存入口 保存至图库
截图文件 SFCJ/screenshots/13_share_page_raw.jpeg
UI 树文件 SFCJ/screenshots/13_share_page_layout.json

本次桌面卡片链路的配置侧验证记录如下:

验证点 真实配置
扩展能力 EntryFormAbility
卡片页面 ./ets/widget/pages/WidgetCard.ets
UI 语法 arkts
默认尺寸 2*2
支持尺寸 2*2
数据绑定 RecipeWidgetData -> LocalStorageProp
点击回跳 postCardAction() -> EntryAbility

对应命令如下:


hdc shell bm dump -n com.lesson.myapplicationsfcj
hdc shell uitest dumpLayout
hdc shell snapshot_display -f /data/local/tmp/13_share_page.jpeg

十五、运行验证

本次运行截图来自本地 HarmonyOS 模拟器,验证路径为“详情页右上角分享入口 -> 分享页”。

验证结果如下:

  • 分享页能读取当前菜谱标题、描述、封面图和前四个食材。
  • 分享卡片区域有稳定 share-card id。
  • 页面使用 SaveButton 展示保存到图库入口。
  • 保存逻辑包含截图、编码、图库资源创建、文件写入和资源释放。
  • form_config.json 已声明 2*2 动态桌面卡片。
  • EntryFormAbility 能提供初始推荐、定时更新和事件更新数据。
  • WidgetCard 使用 LocalStorageProp 渲染卡片数据。
  • EntryAbility 保留从卡片跳回详情页的 pending 路由逻辑。

十六、问题复盘

当前分享和桌面卡片链路已经具备完整工程骨架,下面三个点用于后续版本的产品化演进。

第一,分享页标题和按钮可以统一进入资源文件管理,例如 ShareFrom Private Menu 放入 string.json,中文包和英文包分别维护,方便多语言切换。

第二,ShareService.buildShareText() 适合承接“复制文案”或“系统分享”入口,让文本分享和图片分享并存,形成更完整的分享矩阵。

第三,桌面卡片候选数据可以从 RecipeDataSource 统一筛选,让卡片推荐和 App 内列表使用同一份菜谱资产,降低维护成本。

十七、小结

这一篇可以把 App 外能力总结成两条线:


详情页 -> ShareRecipePage -> componentSnapshot -> SaveButton -> 图库图片

EntryFormAbility -> formBindingData -> WidgetCard -> postCardAction -> EntryAbility -> 详情页

下一篇进入系列收官,复盘资源、备份、构建、质量检查和 CSDN 文章交付标准。

Logo

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

更多推荐