SEO 信息

  • SEO 标题:动图魔方技术拆解 17:清除虚拟数据后,如何用真实素材验证 GIF 工具
  • SEO 摘要:基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”,拆解一个 GIF 工具在移除虚拟作品、虚拟素材和演示文案之后,如何通过 TestAssetService.etsMediaService.etsIndex.ets 建立真实可复验的素材验证闭环:内置测试视频 / 图片 / GIF 复制到沙箱、编辑页直接加载真实文件、导出结果回写作品列表、分享与空状态都围绕真实素材路径验收。
  • 关键词:HarmonyOS, ArkTS, TestAssetService, MediaService, GIF 工具, 真实素材, 回归测试, PhotoViewPicker, DocumentViewPicker, 本地验证
  • 文章封面doc/csdn-series/covers/cover-17-test-asset-real-validation.jpg
  • 投稿方向:普通技术拆解 / HarmonyOS 本地工具 App 的真实素材回归链路
  • 项目环境:HarmonyOS SDK 6.1.0(23)、ArkTS、DevEco Studio、GIFRubiksCube
  • 验证时间2026-06-27
  • 验证对象entry/src/main/ets/common/services/TestAssetService.etsentry/src/main/ets/common/services/MediaService.etsentry/src/main/ets/products/main/Index.ets

第 03 篇解决的是“怎么接系统素材入口”,第 16 篇解决的是“编辑页和作品页怎么保持同一套移动端版式”。到了第 17 篇,问题开始变成另一个工程层面的硬要求:如果页面里还残留虚拟作品、默认样图、示意文案,那么 GIF 工具的抽帧、预览、导出、作品列表和分享按钮,都无法证明自己在真实文件路径上真正跑通过。所以这篇不再讲权限,也不再讲布局,而是专门讲“清除演示假数据之后,怎么用真实素材做稳定回归”。

一、真实工程问题背景

很多工具类 App 在开发中后期都会遇到同一个误区:页面已经做出来了,按钮也能点,空状态也有文案,于是继续往前加功能;但到了真正验收时,才发现页面上大半证据都来自“演示数据”。这类现象表面上只是“页面先跑起来”,本质上却会导致验收口径失真、回归判断失真和用户路径判断失真。

“动图魔方”在清理假数据之前,风险主要集中在四类地方。这里我刻意把现象、原因、影响、验证路径都写清楚:

  1. 现象:作品页如果默认塞几条示例导出记录,看起来像“已有闭环”。 原因:页面在没有真实导出结果时仍然允许靠内置数组占位。 影响:这会直接导致用户、测试和截图都无法判断“作品条目”到底来自真实导出,还是默认样例。 验证路径:把 DEFAULT_WORKS 清空,再看作品页是否只能通过真实导出生成条目。
  2. 现象:编辑页如果默认带一张演示图或者虚拟素材 URI,预览区能亮起来。 原因:预览层没有把素材入口和真实文件路径强绑定。 影响:这会导致 MediaServiceTestAssetService 和导出链路之间出现不一致,看上去能预览,实际上不一定能导出。 验证路径:要求测试素材也必须回写 sourceUris,并触发同一条预览刷新逻辑。
  3. 现象:分享按钮如果只挂在 UI 上,即便页面能点,也不代表作品列表里的条目真的对应一个存在的 GIF 文件。 原因:作品元数据和真实文件路径可能脱钩。 影响:这会直接导致用户在“看起来能分享”的页面里点到一个实际已失效的结果,风险会在发布后暴露。 验证路径:导出后必须进入作品页,再验证作品条目、分享按钮和删除入口同时存在。
  4. 现象:空状态如果文案写成“暂无内容”,却没有明确告诉用户必须导入真实素材。 原因:页面没有把“禁止假数据兜底”写进产品口径。 影响:开发、验收和截图都很容易继续沿用演示假数据,导致真实回归长期缺席。 验证路径:把空状态改成“这里不再内置虚拟作品,请导入真实素材后导出”,让回归口径直接外显。

这个问题在 GIF 工具里尤其明显,因为它不是纯展示页,而是一条典型的文件处理链路:

  1. 选择真实视频、图片或 GIF。
  2. 把素材路径写进编辑器状态。
  3. 生成可播放预览。
  4. 触发导出并把结果写回作品列表。
  5. 从作品列表继续编辑、分享或删除。

只要其中任意一步还依赖虚拟数据,前后链路就断了。最终你能证明的,只是“页面搭起来了”,而不是“真实素材闭环跑通了”。

第 17 篇就是为了解决这个问题:让“测试素材”不再是摆拍,而是真正参与编辑、导出、回看和分享的工程输入。

二、目标与边界

这次的目标很明确:

  1. 移除作品页和编辑页对虚拟素材、虚拟作品的依赖。
  2. 保留一条开发期可复现的“内置真实测试素材”路径,避免每次验收都手动到系统相册里找文件。
  3. 让测试素材和系统选择器最终都回到同一个 sourceUris 状态模型。
  4. 确保“素材导入 -> 预览 -> 导出 -> 作品页回看 -> 分享”能基于真实文件路径完成。
  5. 让截图、布局树、源码行号和本地质检脚本能够相互印证。

边界同样要讲清楚:

  1. 本文不重复展开第 03 篇已经写过的权限和 URI 访问原则,只关注“如何拿真实样例做回归”。
  2. 本文不重讲第 11、12 篇的导出线程与进度状态机,只把它们当成第 17 篇验收闭环的下游。
  3. 本文里的“测试素材”不是演示假数据,而是打包进应用 rawfile 的真实视频、图片序列和 GIF 文件。
  4. 本文不把内置测试素材当最终用户能力,而是把它视为开发、截图和回归验证的稳定入口。

三、源码对象与证据文件

本篇实际对应的源码对象和证据文件如下:

  1. entry/src/main/ets/common/services/TestAssetService.ets
  2. entry/src/main/ets/common/services/MediaService.ets
  3. entry/src/main/ets/products/main/Index.ets
  4. entry/src/main/ets/entryability/EntryAbility.ets
  5. entry/src/main/resources/base/profile/main_pages.json
  6. doc/screenshots_current/gifrubik_test_asset_button.jpeg
  7. doc/screenshots_current/gifrubik_test_asset_loaded.jpeg
  8. doc/screenshots_current/gifrubik_test_asset_current.jpeg
  9. doc/screenshots_current/gifrubik_test_asset_exported.jpeg
  10. doc/screenshots_current/layout_test_asset_loaded.json
  11. doc/screenshots_current/layout_test_asset_exported.json

这几组对象分别承担不同职责:

对象 责任 为什么它是第 17 篇主角
TestAssetService.ets 把 rawfile 里的测试视频 / 图片 / GIF 复制到沙箱缓存目录 决定“测试素材”是否真的是可导出的真实文件
MediaService.ets 系统素材入口统一返回 MediaPickResult 决定真实用户路径和测试路径能否收口到同一接口
Index.ets 编辑页、作品页、空状态、测试素材按钮和状态文案 决定真假素材链路是否最终落到同一 UI 状态机
gifrubik_test_asset_*.jpeg 编辑页与作品页的真实截图证据 证明测试素材不是停留在代码层,而是真的进入了页面与作品列表
layout_test_asset_*.json 布局树快照 证明测试素材文案、按钮、作品条目在真实 UI 结构中存在

我这次定位关键实现的位置,实际用了下面这几条命令:

rg -n "const VIDEO_ASSETS|const IMAGE_ASSETS|const GIF_ASSETS|static async prepare|copyAssets\(|copyAsset\(" entry/src/main/ets/common/services/TestAssetService.ets

rg -n "private async pickSource|private async useTestAssets|未选择素材,请重新选择真实素材|测试素材加载失败,请重新构建应用|这里不再内置虚拟作品|EditorActionButton\('测试素材'" entry/src/main/ets/products/main/Index.ets

Get-ChildItem doc/screenshots_current -File |
  Where-Object { $_.Name -match "test_asset|picker_open|share_button" } |
  Select-Object Name, Length

命中的关键结果如下:

entry/src/main/ets/common/services/TestAssetService.ets:15:const VIDEO_ASSETS: RawAssetEntry[] = [
entry/src/main/ets/common/services/TestAssetService.ets:20:const IMAGE_ASSETS: RawAssetEntry[] = [
entry/src/main/ets/common/services/TestAssetService.ets:26:const GIF_ASSETS: RawAssetEntry[] = [
entry/src/main/ets/common/services/TestAssetService.ets:31:  static async prepare(context: common.UIAbilityContext): Promise<TestAssetBundle> {
entry/src/main/ets/products/main/Index.ets:445:  private async pickSource(): Promise<void> {
entry/src/main/ets/products/main/Index.ets:466:  private async useTestAssets(type: string): Promise<void> {
entry/src/main/ets/products/main/Index.ets:931:            this.EditorActionButton('测试素材', '#23A6D5', true, () => this.useTestAssets(this.editorType))
entry/src/main/ets/products/main/Index.ets:1205:            this.EmptyState('作品列表为空', '这里不再内置虚拟作品,请导入真实素材后导出', '开始创作', 'video')

这些行号说明第 17 篇不是泛泛在聊“测试”,而是可以明确落到:素材定义、测试素材准备、真实素材入口、按钮接入和空状态收口五个可复核点。

和这篇实现直接相关的官方依据也建议一起对照看:

  1. HarmonyOS 媒体库选择器与安全访问能力说明
  2. HarmonyOS 文件选择器能力说明
  3. HarmonyOS rawfile 资源访问说明

四、先把“测试素材”变成真实文件,而不是数组占位

第 17 篇最关键的一步,是把“测试素材”从概念改成真实文件。

TestAssetService.ets 先明确列出三类 rawfile 素材:

const VIDEO_ASSETS: RawAssetEntry[] = [
  { rawPath: 'test_assets/flower_cc0.mp4', fileName: 'flower_cc0.mp4' },
  { rawPath: 'test_assets/flower_cc0.webm', fileName: 'flower_cc0.webm' }
];

const IMAGE_ASSETS: RawAssetEntry[] = [
  { rawPath: 'test_assets/image_sequence_01.jpg', fileName: 'image_sequence_01.jpg' },
  { rawPath: 'test_assets/image_sequence_02.jpg', fileName: 'image_sequence_02.jpg' },
  { rawPath: 'test_assets/image_sequence_03.jpg', fileName: 'image_sequence_03.jpg' }
];

const GIF_ASSETS: RawAssetEntry[] = [
  { rawPath: 'test_assets/rotating_earth.gif', fileName: 'rotating_earth.gif' }
];

这一步的价值不在于“文件名写在常量里”,而在于三件事:

  1. 测试视频、图片序列和 GIF 被明确拆成三条验证分支,而不是一个笼统的“示例素材包”。
  2. 每一类素材都能映射到真实编辑器类型:videoimagegif
  3. 当截图、日志或作品列表出现问题时,可以直接回溯到哪一类素材没有真正准备好。

真正让它们成为“真实素材”的,是 prepare()copyAsset() 这两层:

static async prepare(context: common.UIAbilityContext): Promise<TestAssetBundle> {
  const baseDir = `${context.cacheDir}/test_assets`;
  try {
    fs.mkdirSync(baseDir, true);
  } catch (err) {
  }

  return {
    videoUris: await TestAssetService.copyAssets(context, baseDir, VIDEO_ASSETS),
    imageUris: await TestAssetService.copyAssets(context, baseDir, IMAGE_ASSETS),
    gifUris: await TestAssetService.copyAssets(context, baseDir, GIF_ASSETS)
  };
}
private static async copyAsset(context: common.UIAbilityContext, baseDir: string, asset: RawAssetEntry): Promise<string> {
  const outputPath = `${baseDir}/${asset.fileName}`;
  const content = await context.resourceManager.getRawFileContent(asset.rawPath);
  const start = content.byteOffset;
  const end = content.byteOffset + content.byteLength;
  const buffer = content.buffer.slice(start, end);
  const file = fs.openSync(outputPath, fs.OpenMode.CREATE | fs.OpenMode.TRUNC | fs.OpenMode.READ_WRITE);
  fs.writeSync(file.fd, buffer);
  fs.closeSync(file);
  return outputPath;
}

这里的工程意义非常明确:

  1. rawfile 里的测试资源不会直接拿来当页面展示占位,而是先复制到 cacheDir/test_assets
  2. 页面后续拿到的是可读、可导出、可分享的真实沙箱文件路径。
  3. 这意味着测试素材和系统选择器最终都会落到“文件路径 / URI”这一层,而不是停留在某个演示对象。

如果没有这一步,你最多只能证明“按钮能把一张内置图显示出来”;有了这一步,才能证明内置素材与真实导出链路是同一种输入。

五、测试路径和用户真实路径必须回到同一个状态模型

光有 TestAssetService 还不够。如果内置测试素材走一套状态逻辑、系统选择器走另一套逻辑,那它依然只是“开发专用分支”,无法证明主链路稳定。

MediaService.ets 的作用,就是把用户真实素材入口统一成 MediaPickResult

static async pickVideo(): Promise<MediaPickResult> {
  const pickerView = new photoAccessHelper.PhotoViewPicker();
  const options = new photoAccessHelper.PhotoSelectOptions();
  options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE;
  options.maxSelectNumber = 1;
  const result = await pickerView.select(options);
  return {
    uris: result.photoUris,
    message: result.photoUris.length > 0 ? '已选择视频素材' : '未选择视频'
  };
}

static async pickDocument(context: common.UIAbilityContext): Promise<MediaPickResult> {
  const documentPicker = new picker.DocumentViewPicker(context);
  const options = new picker.DocumentSelectOptions();
  const uris = await documentPicker.select(options);
  return {
    uris: uris,
    message: uris.length > 0 ? `已选择 ${uris.length} 个文件` : '未选择文件'
  };
}

页面层接用户真实路径时,落点是 pickSource()

private async pickSource(): Promise<void> {
  try {
    this.clearLivePreview();
    let result: MediaPickResult;
    if (this.editorType === 'video') {
      result = await MediaService.pickVideo();
    } else if (this.editorType === 'image' || this.editorType === 'depth' || this.editorType === 'threeD') {
      result = await MediaService.pickImages();
    } else {
      result = await MediaService.pickDocument(this.ctx());
    }
    this.sourceUris = result.uris.slice();
    this.statusText = result.message;
    this.schedulePreviewRefresh();
  } catch (err) {
    this.sourceUris = [];
    this.clearLivePreview();
    this.statusText = '未选择素材,请重新选择真实素材';
  }
}

而测试路径接入时,落点是 useTestAssets()

private async useTestAssets(type: string): Promise<void> {
  try {
    const assets = await TestAssetService.prepare(this.ctx());
    this.clearLivePreview();
    this.editorType = type;
    this.page = 'editor';
    if (type === 'image' || type === 'depth' || type === 'threeD') {
      this.sourceUris = assets.imageUris.slice();
      this.statusText = `已载入 ${this.sourceUris.length} 张内置测试图片`;
    } else if (type === 'gif') {
      this.sourceUris = assets.gifUris.slice();
      this.statusText = '已载入内置测试 GIF';
    } else {
      this.sourceUris = assets.videoUris.slice(0, 1);
      this.statusText = '已载入内置测试视频';
    }
    this.schedulePreviewRefresh();
  } catch (err) {
    this.sourceUris = [];
    this.clearLivePreview();
    this.statusText = '测试素材加载失败,请重新构建应用';
  }
}

这两条路径最重要的共同点是:

  1. 最终都写回 this.sourceUris
  2. 最终都会刷新预览。
  3. 失败时都会清空状态,避免伪成功。

也就是说,第 17 篇真正建立的是一个判断标准:测试路径不是特权路径,而是主链路的另一个稳定入口

六、清掉虚拟作品,才能让作品页证明导出真的发生过

如果作品页里默认放着几条演示数据,任何“导出成功”的截图都不够可信。因为你无法证明当前看到的条目,到底是刚导出的,还是默认内置的。

这次代码里最关键的收口之一,是把默认作品清成空数组:

const DEFAULT_WORKS: WorkEntry[] = [];

然后在作品页直接把产品口径写死:

if (this.works.length === 0) {
  this.EmptyState('作品列表为空', '这里不再内置虚拟作品,请导入真实素材后导出', '开始创作', 'video')
}

这句文案看起来只是 UI 提示,实际上非常重要:

  1. 它明确告诉开发和验收者,这个页面不再接受“默认演示作品”作为完成态。
  2. 它让所有后续截图都必须基于一次真实导出。
  3. 它为第 17 篇建立了一个可复验前提:作品页里一旦出现条目,就代表至少发生过一次真实导出。

这也是为什么第 17 篇能和第 03 篇区分开。第 03 篇证明“能拿到素材”;第 17 篇证明“没有虚拟数据兜底后,真实素材闭环依然成立”。

七、截图和布局树要证明“测试素材”真的进入了 UI 与作品闭环

第 17 篇最不能接受的写法,就是只贴源码,不贴真实界面。因为这里讨论的是“真实素材是否真的穿过页面和导出链路”。所以截图这一节必须显式回答两件事:需要证明的规则是什么,以及它的对应源码位置在哪里。

截图 需要证明的规则 对应源码位置
gifrubik_test_asset_button.jpeg 编辑页存在显式测试素材入口,不靠隐藏调试命令 Index.ets:931
gifrubik_test_asset_loaded.jpeg 测试素材载入后状态文案切换到真实文件语义,并保留导出入口 Index.ets:466-486
gifrubik_test_asset_current.jpeg 测试素材最终写回 sourceUris 并可重新选择素材 Index.ets:445-462466-486
gifrubik_test_asset_exported.jpeg 测试素材导出后会进入作品页,作品条目不再依赖虚拟默认值 Index.ets:1205 + 导出链路回写作品列表

7.1 编辑页上必须能看到“测试素材”入口

编辑页测试素材按钮

这张图对应 Index.ets:931 的按钮接入:

this.EditorActionButton('测试素材', '#23A6D5', true, () => this.useTestAssets(this.editorType))

它证明“测试素材”不是后台接口或隐藏调试命令,而是一个面向真实回归流程的显式入口。

7.2 测试素材载入后,状态文案必须切到真实文件语义

测试素材载入后的编辑页状态

这张图能验证两件事:

  1. 顶部状态文案已经变成“已载入内置测试视频”,说明不是默认预览图。
  2. 编辑页下方仍然保留真实导出入口,证明测试素材不是死图展示,而是接着往下走导出。

布局树快照也给出了同样的证据:

layout_test_asset_loaded.json
- Text: 视频转GIF
- Text: 已载入内置测试视频
- Button: 导出
- Button: 测试素材

7.3 当前素材状态必须能回到“真实 URI 已进入编辑器”

当前素材状态与重新选择入口

这张图对应的布局树中,有一条很关键的文本:

Text: 已选择 1 个素材 URI
Button: 重新选择素材
Button: 测试素材

这说明测试素材路径并不是单独塞进预览区,而是已经进入和用户真实选择素材相同的状态模型:sourceUris

7.4 作品页出现条目后,才算真正跑通

测试素材导出后进入作品页

这张图是第 17 篇最关键的闭环证据,因为它证明:

  1. 测试素材不是只用于预览。
  2. 导出结果已经变成作品页可见条目。
  3. 作品条目上继续存在“编辑 / 分享 / 删除”操作。

对应布局树里也能看到:

Text: 视频转GIF_1
Text: 视频转GIF · 16:9 · 5s · 15fps · 高清
Text: 已导出
Text: 分享

这组证据说明作品页已经不再是演示容器,而是导出结果的真实回看页。

八、回归命令、风险点与本地质检

第 17 篇不能只靠截图,还要有可重复的检查口径。我这次的回归命令主要有四类。

第一类,定位测试素材服务和编辑页接入点:

rg -n "TestAssetService|useTestAssets|测试素材|这里不再内置虚拟作品" entry/src/main/ets -S

第二类,确认截图证据已经齐全:

Get-ChildItem doc/screenshots_current -File |
  Where-Object { $_.Name -match "test_asset|picker_open|share_button" } |
  Select-Object Name, Length

第三类,检查作品页与编辑页布局树里是否真的出现关键文本:

Get-Content doc/screenshots_current/layout_test_asset_loaded.json -TotalCount 120
Get-Content doc/screenshots_current/layout_test_asset_exported.json -TotalCount 120

第四类,用本地文章质检脚本做发布前结构预检:

node tools/check_csdn_article_quality.js 17

这一篇的高风险回归点主要有五类:

  1. 测试素材仍然停留在内存数据,没有复制成真实沙箱文件。
  2. useTestAssets() 单独维护一套预览逻辑,没有回写 sourceUris
  3. 作品页默认又被塞回虚拟条目,导致导出证据失真。
  4. 分享按钮只保留在 UI 上,但作品文件实际不存在。
  5. 空状态文案回退成模糊描述,导致验收者看不出是否仍在用假数据。

九、工程验收清单

验收项 结果 证据
内置测试视频、图片序列、GIF 已拆成三类真实 rawfile 素材 通过 TestAssetService.ets:15-28
测试素材会先复制到 cacheDir/test_assets,不是直接拿占位对象预览 通过 TestAssetService.ets:31-4153-61
用户真实素材入口与测试素材入口最终都回写 sourceUris 通过 Index.ets:445-462466-486
编辑页存在显式“测试素材”按钮 通过 Index.ets:931 + gifrubik_test_asset_button.jpeg
测试素材载入后状态文案切换为真实文件语义 通过 gifrubik_test_asset_loaded.jpeg + layout_test_asset_loaded.json
作品页不再内置虚拟作品 通过 Index.ets:1205
导出后的测试素材结果能进入作品页 通过 gifrubik_test_asset_exported.jpeg + layout_test_asset_exported.json
作品条目继续具备编辑 / 分享 / 删除操作 通过 layout_test_asset_exported.json分享 / 删除 文本
本篇与第 03 篇不重复,主问题转向“清除假数据后的真实回归闭环” 通过 本文结构与源码对象已区分

十、小结

第 17 篇真正解决的,不是“怎么给 App 加一个测试素材按钮”,而是如何把工具类 App 从演示态拉回工程态

对于 GIF 工具来说,真实素材验证至少要满足三件事:

  1. 素材本身是真实文件,不是内存占位或虚拟数组。
  2. 测试路径和用户路径最终落在同一套编辑器状态模型上。
  3. 导出结果必须能回到作品页,继续接受分享、删除和二次编辑的真实回归。

一旦做到这三点,后面的抽帧、编码、保存、分享、主题、布局和 3D 扩展才有稳定地基。否则再多的页面 polish,也只是给假数据做包装。

十一、下一篇衔接

第 18 篇继续拆 《动图魔方技术拆解 18:ArkGraphics3D 预览、能力检测与 3DGS 降级路线》,重点会转向:

  1. 为什么 GIF 工具里的“3D 转动图”不能直接等同于真实 3D 重建。
  2. CapabilityServiceModel3DView 和能力文案如何区分已可用、实验中和预留路线。
  3. 当设备不支持目标能力时,如何把 3DGS / 渲染 / 单图浅 3D 三条路线做成可验收的降级方案。
Logo

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

更多推荐