分享一个大文件时,业务真正传递的是文件 URI;但为了让系统分享面板看起来更完整,开发者通常还会塞入一张缩略图。麻烦往往就出在这张“附属图”上:原图直接编码后体积过大,分享面板拉起变慢,甚至因为预览数据过重而失败。继续降低 JPEG 质量又可能把预览压成一团,用户仍然看不清内容。

这篇文章不把问题归咎于“Share Kit 不稳定”,而是给预览数据建立一个可观察的预算。示例工程为 ShareBudget,页面为 PreviewBudgetPage,任务编号为 SHARE-0209。演示文件 field_scan_0209.mp4 大小 84.6 MB,源缩略图为 2048×1152,项目自定预算为 96 KB。第一次编码质量 70 得到 184 KB,被本地策略拒绝;第二次质量 45 得到 88 KB,进入 READY。

必须先说明:96 KB 是本文 Demo 的工程预算,不是官方公布的系统硬上限。官方文档只提醒缩略图过大可能无法拉起分享,并建议适当压缩。具体业务应结合目标设备、图片内容和测试结果制定预算,不能把本文数字复制成平台规则。

一、把“分享失败”拆成三件事

看到分享面板没有出现,最直觉的排查是检查文件路径。路径当然重要,但分享任务里至少有三种彼此独立的数据。

第一种是主载荷,也就是视频或文件本身。这里通过 fileUri.getUriFromPath() 把应用沙箱路径转成文件 URI,再放进 SharedData。第二种是元信息,包括 UTD、标题与描述,它决定接收端怎样理解数据。第三种是缩略图,它服务于预览,不应该反过来绑架主任务。

如果三者没有分层,一次失败日志通常只剩“show failed”。分层后可以判断:URI 是否已经生成;UTD 是否与扩展名和业务类型一致;缩略图是否在预算内;当缩略图不可用时,能否省略它并让系统使用默认图标或视频首帧。这样排查顺序就从“重试”变成了“确认哪一层没有满足合同”。

ShareBudget 的状态链是 IDLE → PACKING → BUDGET_CHECK → READY → PANEL_SHOWN。如果所有候选质量都超预算,则走 THUMBNAIL_OMITTED → READY,主文件仍然可以进入分享。这个分支很关键:预览质量下降不应自动升级成业务失败。

二、先生成可比较的候选缩略图

官方分享视频示例使用 Image Kit 创建 ImageSource 和 ImagePacker,再把编码后的 ArrayBuffer 转成 Uint8Array 作为 thumbnail。本文沿用这个能力,但增加了尺寸与质量序列。候选图不是无限尝试,而是按 70、45、30 三档运行;找到预算内结果就停止。

这段代码解决什么问题:把 2048×1152 源图按固定尺寸解码,并依次生成可比较的 JPEG 候选,输出体积和实际质量。

import { image } from '@kit.ImageKit';

export interface ThumbnailCandidate {
  bytes: Uint8Array;
  quality: number;
  sizeKB: number;
}

export async function packThumbnail(
  sourcePath: string,
  budgetKB: number = 96
): Promise<ThumbnailCandidate | undefined> {
  const source = image.createImageSource(sourcePath);
  const pixelMap = await source.createPixelMap({
    desiredSize: { width: 640, height: 360 }
  });
  const packer = image.createImagePacker();

  try {
    for (const quality of [70, 45, 30]) {
      const buffer = await packer.packing(pixelMap, {
        format: 'image/jpeg',
        quality
      });
      const bytes = new Uint8Array(buffer);
      const sizeKB = Math.ceil(bytes.byteLength / 1024);
      console.info(`SHARE-0209 quality=${quality} sizeKB=${sizeKB}`);
      if (sizeKB <= budgetKB) {
        return { bytes, quality, sizeKB };
      }
    }
    return undefined;
  } finally {
    pixelMap.release();
    packer.release();
    source.release();
  }
}

这里先把源图解码到 640×360,再调 JPEG 质量。只降低质量、不降低像素尺寸,往往会在复杂纹理上得到仍然很大的文件;只缩尺寸、不看编码结果,又无法应对高噪点画面。尺寸和质量应该一起进入预算策略。

代码使用 finally 成对释放 PixelMap、ImagePacker 和 ImageSource。正式工程要以目标 SDK 中各对象的实际释放接口为准;如果某个版本采用不同生命周期定义,应跟随对应 API 参考调整。无论具体方法名如何变化,原则都不变:候选图只是短生命周期中间产物,不应在每次点击后继续占用图像资源。

示例日志中的 184 KB 与 88 KB 是固定演示数据,用于文图一致性,不声称是任何图片在所有设备上的必然压缩结果。同样尺寸、同样质量,画面纹理不同,编码体积也会明显不同。因此,不能根据质量参数直接推测大小,必须读取实际 byteLength。

三、预算不是循环压缩到“能塞进去”为止

如果没有底线,算法可以一直把质量降到极低,最后虽然进入预算,预览却失去识别价值。本文只给三次机会:70 用于保留细节,45 是可接受折中,30 是最后候选。三次仍超过 96 KB,就不再继续压缩,而是省略自定义缩略图。

这个选择看起来保守,实际更稳定。分享面板的核心任务是传递文件,不是展示一张完美海报。用户在系统面板里看到默认图标或视频首帧,仍可以确认标题与文件;如果开发者为了预览不断制造大对象,反而把核心动作拖慢。

预算还要包含观测字段。本文记录 sourceSize、desiredSize、quality、sizeKB、budgetKB 和 decision。只有记录这些字段,后续才能判断失败来自图片内容变化、尺寸策略变化,还是预算被误改。日志不记录完整本地路径,避免把用户目录结构或文件名带入诊断平台。

四、主载荷始终走 File URI

在这个 Demo 里,缩略图无论成功还是被省略,视频主载荷都通过 File URI 进入 SharedData。这不是“超预算后才改走 URI”,而是从一开始就把大文件与预览字节分开。标题中的“File URI 回退”指的是预览策略回退到只保留 URI 主载荷,不是把 84.6 MB 文件塞进内存失败后才临时补救。

这段代码解决什么问题:根据预算结果构造 SharedData,缩略图可选,主文件 URI、UTD、标题和描述保持不变。

import { systemShare } from '@kit.ShareKit';
import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { fileUri } from '@kit.CoreFileKit';

export function buildShareData(
  filePath: string,
  candidate?: ThumbnailCandidate
): systemShare.SharedData {
  const record: systemShare.ShareData = {
    utd: utd.UniformDataType.VIDEO,
    uri: fileUri.getUriFromPath(filePath),
    title: '现场巡检视频 0209',
    description: '84.6 MB · SHARE-0209'
  };

  if (candidate) {
    record.thumbnail = candidate.bytes;
    console.info(`SHARE-0209 mode=INLINE_THUMBNAIL sizeKB=${candidate.sizeKB}`);
  } else {
    console.info('SHARE-0209 mode=FILE_URI_ONLY thumbnail=OMITTED');
  }
  return new systemShare.SharedData(record);
}

这段写法最重要的是缩略图字段可选。很多实现会先创建一个空 Uint8Array,再无条件写入 thumbnail。空数组和不传字段的语义未必相同,也没有必要。没有合格预览时就省略字段,让 Share Kit 按默认规则处理。

UTD 也不应该随便写。本文文件是 MP4,因此使用视频类型。更通用的实现可以根据扩展名调用官方提供的类型推断方法,但仍需验证推断结果与业务一致。把视频标成图片、把普通文件标成文本,即使面板能出现,接收端也可能走错处理路径。

项目目录、代码、右侧模拟器与底部 HiLog 在同一张 DevEco Studio 演示图中保持一致:中间代码停在 packThumbnail() 的预算判断;右侧 PreviewBudgetPage 显示 88 KB / 96 KB;日志先拒绝 quality=70 sizeKB=184,再接受 quality=45 sizeKB=88。

五、只在 READY 状态打开面板

图像编码是异步操作。用户点击“准备分享”后,页面先进入 PACKING,不能马上再点一次。候选结果返回后进入 BUDGET_CHECK,构造 SharedData 成功才进入 READY。打开分享面板也要有单独的状态,避免同一任务在短时间内创建多个控制器。

这段代码解决什么问题:用任务代次连接编码、预算判断和 ShareController.show(),拒绝页面销毁或新任务启动后的迟到结果。

import { common } from '@kit.AbilityKit';
import { systemShare } from '@kit.ShareKit';

export class ShareFlow {
  private generation: number = 0;
  private state: string = 'IDLE';

  async start(context: common.UIAbilityContext, filePath: string,
    thumbnailPath: string): Promise<void> {
    const token = ++this.generation;
    this.state = 'PACKING';
    const candidate = await packThumbnail(thumbnailPath, 96);
    if (token !== this.generation) return;

    this.state = 'BUDGET_CHECK';
    const sharedData = buildShareData(filePath, candidate);
    if (token !== this.generation) return;

    this.state = 'READY';
    const controller = new systemShare.ShareController(sharedData);
    await controller.show(context, {
      selectionMode: systemShare.SelectionMode.SINGLE,
      previewMode: systemShare.SharePreviewMode.DETAIL
    });
    if (token === this.generation) {
      this.state = 'PANEL_SHOWN';
      console.info('SHARE-0209 state=PANEL_SHOWN');
    }
  }

  cancel(): void {
    this.generation++;
    this.state = 'CANCELLED';
  }
}

show() 成功表示分享面板成功显示,不等于接收端已经完成文件传输。因此,不能在 Promise 返回后马上删除主文件。本文把文件保留策略单独定义为 retain=24h,由应用自己的清理任务按创建时间和引用状态处理。这个 24 小时同样是 Demo 策略,不是 Share Kit 的系统要求。

如果页面退出,cancel() 只让业务忽略后续异步结果,不应擅自关闭用户正在操作的系统面板。具体控制器生命周期要以当前 API 能力为准。这里的代次门禁解决的是页面状态污染,不是模拟系统没有提供的取消能力。

六、运行页只展示当前决策

PreviewBudgetPage 在 20:24 显示文件 field_scan_0209.mp4、大小 84.6 MB、任务 SHARE-0209、预算 96 KB、当前结果 88 KB、编码质量 45 与状态 READY。主按钮为“打开分享面板”。红色箭头只标注预算命中和 File URI 主载荷,不把整页做成说明书。

运行页没有展示第一次失败的完整细节,因为用户只需要知道当前是否可以继续。调试信息放到详情页,避免业务页面同时承担用户操作和工程诊断。这个区分也让后续替换预算算法时,不必重做主要交互。

当候选全部超预算时,页面会显示 FILE_URI_ONLY,同时保留“打开分享面板”按钮。文案写“将使用系统默认预览”,而不是“压缩失败”。前者告诉用户任务仍可进行,后者容易让用户误以为文件本身不可分享。

七、诊断页记录每一次选择

详情页在同一时间上下文里展示两次编码结果:q70 → 184 KB → REJECTED,q45 → 88 KB → ACCEPTED。随后记录 UTD=VIDEO、uri=READY、mode=INLINE_THUMBNAIL、panel=SHOWN 和 retain=24h。它和运行页内容明显不同,承担的是“为什么采用当前预览”的解释责任。

这组数字可以形成一条清楚的调试链:

20:24:03 SHARE-0209 state=PACKING source=2048x1152

20:24:04 SHARE-0209 quality=70 sizeKB=184 decision=REJECTED

20:24:05 SHARE-0209 quality=45 sizeKB=88 decision=ACCEPTED

20:24:06 SHARE-0209 mode=INLINE_THUMBNAIL uri=READY

20:24:08 SHARE-0209 state=PANEL_SHOWN retain=24h

日志里没有写完整沙箱路径,只写 URI 是否就绪。调试版本可以增加哈希或短文件 ID,但不要把用户文件内容、绝对路径和分享目标写入普通日志。需要跨进程排查时,也应采用脱敏标识,而不是把敏感数据变成“方便检索”的明文。

八、从一个数字变成可维护策略

96 KB 如果散落在页面、工具类和测试代码里,很快会出现三套结果。实际工程应把预算、候选尺寸和质量序列集中到配置对象,并给它一个版本,例如 preview-budget-v2。日志带上策略版本,后续调整才能比较前后差异。

测试数据也不能只用一张平滑封面。至少准备纯色、文字截图、高噪点夜景、细密纹理和透明图转 JPEG 五类素材。每类都检查候选数量、最终大小、预览可辨认度、面板拉起结果与内存峰值。对于透明图,如果转 JPEG 会丢失 Alpha,应先决定背景色,不能让编码器自行得到黑底或不一致的合成结果。

还要覆盖页面生命周期:编码中返回;编码完成前再次选择另一个文件;分享面板显示后应用进入后台;保留期结束前文件仍被业务引用;清理任务执行时文件已被删除。预算策略只是入口,真正稳定的实现还需要把文件所有权和清理责任说清楚。

本文没有把 ShareController.show() 的成功当成“分享成功”,也没有把项目预算写成系统限制。前者避免误删文件,后者避免让示例数字变成错误规范。这两条边界比把压缩循环写得更复杂更重要。

九、文件保留不能绑定面板返回

分享面板显示后,文件会在应用进程之外被读取。show() 的 Promise 正常结束,不代表接收端已经复制完成,也不代表用户一定选择了目标应用。于是文件清理出现了一个很现实的所有权问题:页面创建了临时文件,却没有一个可靠的“现在可以立刻删除”信号。

本文没有在面板关闭时删除 field_scan_0209.mp4,而是把它登记到保留账本。账本记录业务文件 ID、创建时间、过期时间、当前引用状态和是否仍被页面展示。清理任务只处理已经过期、没有业务引用并且不处于活动分享任务中的文件。这个策略会多占用一段时间的磁盘,但比用户选择接收端后文件突然失效更可控。

这段代码解决什么问题:给分享文件建立独立保留账本,避免把 ShareController.show() 的完成误判成传输完成并立即删除文件。

interface RetainedShareFile {
  id: string;
  path: string;
  createdAt: number;
  expiresAt: number;
  active: boolean;
}

export class ShareRetentionLedger {
  private items: Map<string, RetainedShareFile> = new Map();
  private readonly retainMs: number = 24 * 60 * 60 * 1000;

  register(id: string, path: string, now: number): void {
    this.items.set(id, {
      id,
      path,
      createdAt: now,
      expiresAt: now + this.retainMs,
      active: true
    });
    console.info(`SHARE-0209 retain=24h fileId=${id}`);
  }

  markPanelClosed(id: string): void {
    const item = this.items.get(id);
    if (item) item.active = false;
  }

  expired(now: number): RetainedShareFile[] {
    return Array.from(this.items.values())
      .filter((item) => !item.active && item.expiresAt <= now);
  }

  removed(id: string): void {
    this.items.delete(id);
  }
}

这段代码故意不执行文件删除,因为删除方式取决于文件来自哪里。应用自己生成的缓存文件可以由缓存管理器清理;用户选择的媒体 URI 不应被当作应用私有文件删除;业务正式文件可能还有其他页面引用。账本只负责给出“满足清理条件的候选”,最终删除动作仍要交给拥有文件的模块。

active=false 也不是“接收端已拿到文件”的证明,它只表示应用不再把当前面板视为活动任务。因此,本文同时要求超过保留期。正式产品可以根据业务风险选择更长或更短时间,也可以在后台任务中检查文件是否仍被其他业务记录引用。无论采用哪个数字,都应把它标成项目策略,而不是系统协议。

十、异常分类要比一条 catch 更细

分享链路里至少有五类错误。第一类是源文件不存在或没有访问能力,应该在编码前失败;第二类是缩略图解码或编码失败,可以降级成无缩略图;第三类是 URI 构造失败,主载荷无法成立,应阻止打开面板;第四类是 SharedData 构造参数不合法,需要检查 UTD 与字段;第五类是面板显示失败,应该记录错误码并允许用户重试。

如果所有异常都在最外层 catch 里变成“分享失败”,页面无法区分是否还可以降级。例如缩略图编码失败时,主文件仍然可能有效;直接终止会把一个可恢复问题变成完整失败。相反,URI 构造失败时不能继续展示“准备完成”,否则用户点击后只会得到另一个模糊错误。

状态机因此要允许 PACKING_FAILED → FILE_URI_ONLY → READY,但不允许 URI_FAILED → READY。这不是为了追求更多枚举,而是把恢复能力写进规则。测试用例也要针对每一类故障注入:删除缩略图、给出损坏图片、移除主文件、传入不匹配的 UTD、模拟面板错误。只有故障注入能证明降级分支不是“代码里看起来存在”。

页面提示也要匹配异常级别。预览被省略时提示“将使用系统默认预览”;主文件不可访问时提示“文件已失效,请重新选择”;面板临时失败时提示“暂时无法打开分享面板,可稍后重试”。不要把内部错误码直接显示给用户,但要在脱敏日志里保留 SHARE-0209、阶段和错误码,方便将同一次问题串起来。

十一、性能验收不能只看最终 KB

候选缩略图是串行编码的。如果第一档经常超预算、第二档才命中,用户每次都要承担两次编码成本。上线前应统计各类素材在每一档命中的比例。如果绝大多数高纹理图片都在 45 命中,那么可以把候选顺序调整为 55、40、30,或者进一步降低目标尺寸,减少无效尝试。

内存峰值同样重要。源图 2048×1152 解码后占用的内存远大于最终 88 KB JPEG。若同时处理多个分享任务,压缩包很小并不能说明过程轻量。页面应限制并发为一,选择新文件时取消旧任务代次,并尽快释放旧 PixelMap。对于更高分辨率素材,还要考虑先用合适采样尺寸解码,而不是总把原图完整展开后再缩小。

性能记录建议包含准备总耗时、解码耗时、每档编码耗时、峰值内存、候选次数和最终模式。用户真正感知的是从点击到面板出现的时间,而不是单个编码函数有多快。ShareBudget 的演示图没有伪造设备性能数字,因此只展示体积与状态;正式测试应在目标机型上采集,不用模拟器截图代替实机基准。

另一个容易忽略的指标是取消成本。用户在 PACKING 期间离开页面,旧任务可能仍在底层完成编码。代次门禁可以阻止它提交 UI,却不一定停止底层计算。若当前 API 或任务框架支持可取消执行,应在资源层同时接入取消;如果不支持,就要把编码任务做小、限制并发,并确保结果返回后立即释放。

十二、结语

大文件分享的核心不是把所有内容都变小,而是区分主载荷与预览。ShareBudget 始终用 File URI 承载 84.6 MB 视频,只让缩略图进入内存预算。70 质量得到 184 KB 时明确拒绝,45 质量得到 88 KB 时接受;如果没有候选满足 96 KB 项目预算,就省略自定义缩略图,主任务继续。

这套做法带来的不是一个“万能阈值”,而是一条可观察的决策链:编码了什么、得到多大、为什么拒绝、最终用了哪种模式、面板是否显示、文件何时清理。把这些问题答清楚,分享功能才不会在“偶尔拉不起”的模糊描述里反复试错。

落到团队协作里,最好把预算配置、候选质量序列、回退规则和清理责任放在同一份设计说明中,并让测试用例直接引用策略版本。以后即使更换预览尺寸或新增文件类型,开发、测试和产品看到的仍是同一条规则,而不是各自记住一个经验数字。这样才能让一次 Demo 调整演变成可持续维护的工程能力。

十三、参考资料

  • 华为开发者:分享视频,包含 ImagePacker.packing、SharedData、File URI 与 ShareController 示例
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/share-utd-video-V5
  • 华为开发者:分享文本,包含 SharedData.addRecord 与分享面板调用示例
    https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/share-utd-text
  • 华为开发者:标准化数据类型(UDMF/UTD)
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/uniform-data-type-descriptors-c
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐