别让压图拖垮首帧:系统 Picker + TaskPool + ImagePacker,把 HarmonyOS 图片整理链路做顺

0 阅读9分钟

这篇不聊“会不会调 API”,聊的是一个更像线上项目的问题:
用户选了十几张图,页面不能卡;处理完了,结果要能稳定回存;页面退到后台以后,上传也别说没就没。

image.png

我最近在做图片整理类功能时,重新把这条链路梳了一遍。以前很多写法放在 Demo 里没有问题,一旦到了真机、真用户、真批量场景,问题就全出来了:

  • 一上来就申请大权限,用户犹豫一下,转化率先掉一截。
  • 图片解码、缩放、编码全放主线程,选 8~10 张图时首帧明显发紧。
  • “保存成功”只是前端状态改了,文件其实还没稳定落盘。
  • 上传逻辑绑死在页面生命周期上,页面一退,任务也容易跟着断。

后来我换了一个思路:入口尽量交给系统 Picker,重活交给 TaskPool,编码收口到 ImagePacker,回存尽量走 SaveButton / 授权式保存,后台上传交给标准后台任务机制。

这个改完以后,最大的变化不是“代码更优雅”,而是整条用户路径没那么慌了。


一、先说结论:图片类功能别再把所有活都塞给页面

HarmonyOS 这几年给图片、文件、后台任务这些场景配的系统能力,其实已经很完整了。真正容易踩坑的,不是能力不够,而是链路设计还停留在“能跑就行”

对图片整理、票据扫描、相册工具、内容发布这类应用,我更推荐下面这套拆法:

  1. 入口层:优先系统 Picker / Camera Picker
    先把“拿到资源”这件事做轻,不要一上来就用全量权限思路。

  2. 处理层:解码、缩放、编码、哈希都丢到 TaskPool
    UI 线程只管状态切换、进度回写、结果展示。

  3. 结果层:临时文件先落应用沙箱
    不要一边处理一边直接改外部结果,失败以后很难回滚。

  4. 回存层:保存回相册时优先走 SaveButton / 授权保存
    这一步是“用户确认写入系统资源”,语义更清晰,也更稳。

  5. 后续层:上传 / 同步用标准后台任务机制托管
    页面销毁不等于业务任务应该立刻消失。

这套方案的核心不是“炫 API”,而是四个字:职责拆分


二、为什么这套链路更适合线上项目

image.png

我把它总结成一句话:

系统能力负责边界,业务代码负责规则。

1)资源入口尽量轻

很多图片工具第一步就想着“我要读图库,所以先申请图库权限”。
但从产品体验看,这一步往往太重了。

如果你的场景只是“让用户选择几张图来处理”,那更自然的做法是:

  • 让用户用系统 Picker 选图;
  • 用 Camera Picker 拍照;
  • 你的应用拿到 URI / 文件句柄以后再继续后面的业务。

这样做有两个好处:

第一,权限心智更轻。
第二,系统对资源边界更清晰,后续排查问题也更容易。

2)图片处理一定要和 UI 拆开

这是最关键的一点。

图片整理里最容易“偷懒”的地方,就是把下面这些步骤串着写在一个点击回调里:

  • 读文件
  • 解码成 PixelMap
  • 缩放
  • 编码
  • 写临时文件
  • 刷新列表

代码短期看挺顺,长期看基本就是掉帧制造机。
尤其是一次选多张图时,问题会非常明显。

所以我的建议一直很明确:

  • UI 线程只维护“当前选了什么、处理到哪一步、最终展示什么”。
  • 真正耗时的事全部下放到 TaskPool。

这时候你的页面就只剩三类状态:

  • UI 状态:按钮是否可点、当前显示哪个结果
  • 任务状态:压缩中 / 完成 / 失败 / 可重试
  • 文件状态:是否真的写出临时文件,是否可导出

一旦状态层次清楚了,很多“玄学 Bug”会立刻少一半。

3)结果先写到沙箱,再决定是否导出

很多人一开始会把“处理完成”直接等同于“保存完成”,这在工程上是两件事。

我一般会分两步:

  • 第一步:处理结果落到应用沙箱临时目录;
  • 第二步:用户确认后,再导出到系统相册或指定目录。

这么拆的好处很实际:

  • 失败时不会把外部结果弄脏;
  • 可以做失败重试;
  • 可以先预览结果,再决定要不要落到系统资源里;
  • 上传也可以直接基于沙箱内文件做,不必和“是否回存相册”强绑定。

三、给一个更像项目里的写法

下面这段不是“最短 Demo”,而是更接近业务项目里的组织方式。
我故意没有写成一屏代码,而是按模块拆开。这样后面你要加格式策略、清晰度分级、失败重试,都比较好扩。

说明:下面是 ArkTS 项目化示例,媒体 Picker、SaveButton 以及后台任务相关 import 在不同 SDK 版本中命名可能有细微差异,落地时请以你当前使用的 HarmonyOS SDK 文档为准。

1)定义任务数据结构

export interface CompressJob {
  id: string
  sourceUri: string
  targetFormat: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/heif'
  quality: number
  maxEdge: number
}

export interface CompressResult {
  id: string
  success: boolean
  tempFileUri: string
  width: number
  height: number
  message?: string
}

2)页面只维护状态,不直接做重活

@Entry
@Component
struct BatchImagePage {
  @State jobs: CompressJob[] = []
  @State results: CompressResult[] = []
  @State running: boolean = false
  @State progressText: string = '等待选择图片'

  async pickImages() {
    // 这里用系统媒体 Picker 选图
    // 实际 API 名称请以当前 SDK 为准
    const picker = new photoAccessHelper.PhotoViewPicker()
    const selected = await picker.select({
      maxSelectNumber: 12
    })

    this.jobs = selected.photoUris.map((uri: string, index: number) => ({
      id: `job_${Date.now()}_${index}`,
      sourceUri: uri,
      targetFormat: 'image/jpeg',
      quality: 78,
      maxEdge: 1600
    }))
    this.progressText = `已选择 ${this.jobs.length} 张图片`
  }

  async startCompress() {
    if (this.jobs.length === 0 || this.running) {
      return
    }
    this.running = true
    this.results = []

    for (let i = 0; i < this.jobs.length; i++) {
      const job = this.jobs[i]
      this.progressText = `处理中 ${i + 1}/${this.jobs.length}`

      try {
        const result = await ImagePipelineService.compressInTaskPool(job)
        this.results = [...this.results, result]
      } catch (err) {
        this.results = [
          ...this.results,
          {
            id: job.id,
            success: false,
            tempFileUri: '',
            width: 0,
            height: 0,
            message: JSON.stringify(err)
          }
        ]
      }
    }

    this.progressText = '处理完成'
    this.running = false
  }

  build() {
    Column({ space: 16 }) {
      Text('批量图片整理')
        .fontSize(26)
        .fontWeight(FontWeight.Bold)

      Text(this.progressText)
        .fontSize(14)
        .opacity(0.75)

      Row({ space: 12 }) {
        Button('选择图片').onClick(() => this.pickImages())
        Button(this.running ? '处理中...' : '开始整理')
          .enabled(!this.running && this.jobs.length > 0)
          .onClick(() => this.startCompress())
      }

      List() {
        ForEach(this.results, (item: CompressResult) => {
          ListItem() {
            Row({ space: 12 }) {
              Text(item.id).fontSize(14)
              Text(item.success ? '成功' : '失败')
                .fontColor(item.success ? '#26c281' : '#ff5d73')
              Text(item.message ?? item.tempFileUri)
                .fontSize(12)
                .opacity(0.7)
            }
          }
        })
      }
      .layoutWeight(1)
    }
    .padding(20)
    .width('100%')
    .height('100%')
  }
}

3)把解码、缩放、编码收口到服务层

export class ImagePipelineService {
  static async compressInTaskPool(job: CompressJob): Promise<CompressResult> {
    // 伪代码:把真正耗时的图片处理丢进 TaskPool
    return await taskpool.execute(compressWorker, job)
  }
}

4)Worker / TaskPool 里只做“纯处理”

@Concurrent
async function compressWorker(job: CompressJob): Promise<CompressResult> {
  // 伪代码:不同项目里你可能会把这部分继续拆成
  // read -> decode -> resize -> encode -> writeTempFile

  const sourceImage = image.createImageSource(job.sourceUri)
  const pixelMap = await sourceImage.createPixelMap()

  const size = calcTargetSize(pixelMap.getImageInfoSync(), job.maxEdge)
  const resizedPixelMap = await resizePixelMap(pixelMap, size.width, size.height)

  const packer = image.createImagePacker()
  const packedArrayBuffer = await packer.packing(
    resizedPixelMap,
    {
      format: job.targetFormat,
      quality: job.quality
    }
  )

  const tempFileUri = await writeBufferToCache(job.id, packedArrayBuffer)

  return {
    id: job.id,
    success: true,
    tempFileUri,
    width: size.width,
    height: size.height
  }
}

5)保存不要和“处理完成”强绑死

@Component
struct ExportPanel {
  @Prop fileUri: string

  build() {
    Column({ space: 12 }) {
      Text('处理完成后,可以先预览,再决定是否导出到系统相册。')

      // 实际项目里优先考虑安全组件 / SaveButton 的方案
      SaveButton({
        text: '保存到相册'
      })
      .onClick(async () => {
        await exportResultToGallery(this.fileUri)
      })
    }
  }
}

四、这套写法最容易忽略的 4 个细节

image.png

细节 1:不要只存“任务成功”,要存“文件可用”

很多代码会写成这样:

  • TaskPool 返回成功
  • 列表显示“已完成”
  • 用户点击导出
  • 结果发现文件并不可读

这类问题本质上是:
你把“任务返回成功”和“结果文件可用”混成了一件事。

更稳的做法是分开记:

  • taskStatus
  • fileStatus
  • exportStatus

状态一旦拆开,排查就快很多。

细节 2:格式策略不要写死

图片场景里,格式不是越统一越好,而是要看业务目标

我自己一般会这么分:

  • 追求通用分享:优先 JPEG
  • 需要透明背景:保留 PNG
  • 更关注体积:视兼容性考虑 WebP / HEIF
  • 要保细节或特殊能力:另走高质量策略

所以我不太建议在项目初期就写死成“所有图都压成 JPEG 80”。
后面你只要接到一次“为什么透明背景没了”的反馈,就会明白这个坑有多真实。

细节 3:页面状态不要和上传状态绑在一起

很多项目会在图片处理页里顺手把上传也一起做掉。
从流程上看没毛病,从生命周期看问题很大。

更推荐的方式是:

  • 页面内只负责发起上传任务;
  • 上传状态由任务系统维护;
  • 页面返回后,再次进入时读取任务状态。

这样你才能真正做到:

  • 页面退了,任务还在;
  • 网络恢复了,任务还能继续;
  • 失败了,可以单独重试,不必重新压图。

细节 4:别把“系统资源访问”当普通本地文件读写

很多 Bug 到最后都不是图像算法问题,而是 URI、授权、资源归属、目录可见性这些边界没想清楚。

尤其是下面几件事,最好一开始就想明白:

  • 你拿到的是临时 URI 还是长期可读资源?
  • 压缩结果先落哪里?
  • 谁来触发最终导出?
  • 导出失败后是否还能重试?
  • 用户没点保存,但点了上传,这是不是允许?

这些问题想通以后,代码层面的复杂度反而会下降。


五、给一个我自己现在更认可的工程拆法

image.png

如果让我现在从 0 到 1 再搭一次图片整理功能,我会直接按下面分层:

1)UI 层

只负责:

  • 选择入口
  • 任务列表展示
  • 进度与错误提示
  • 预览结果
  • 用户点击导出 / 上传

2)任务编排层

只负责:

  • 队列管理
  • 串行 / 并行策略
  • 失败重试
  • 结果状态汇总

3)图像处理层

只负责:

  • 解码
  • 缩放
  • 编码
  • 哈希 / 校验
  • 临时文件输出

4)资源与系统能力层

只负责:

  • Picker 获取资源
  • SaveButton / 授权保存
  • 后台任务托管
  • 文件系统 / URI 管理

这样一来,后面你要加这些功能都很自然:

  • 批量水印
  • 多规格导出
  • 上传前秒传校验
  • 压缩失败自动降级
  • 低电量 / 弱网场景策略切换

六、最后说一句真话:图片功能拼的不是“压缩算法”,是链路稳定性

很多时候大家讨论图片处理,第一反应都是:

  • 质量参数设多少?
  • 压缩率能不能更高?
  • WebP 和 JPEG 哪个更小?

这些当然重要,但在实际项目里,用户第一时间感知到的并不是“你比别人小了 8%”,而是:

  • 点了以后会不会卡
  • 处理过程中会不会慌
  • 保存会不会莫名失败
  • 退到后台以后会不会白跑

所以我现在更在意的排序是:

  1. 不卡
  2. 稳定
  3. 边界清楚
  4. 最后才是压得漂亮

这也是我为什么越来越倾向于把系统 Picker、TaskPool、ImagePacker、SaveButton、后台任务这些能力串成一条完整链路,而不是各自零散使用。

真正上线以后你会发现,工程上的“顺”,比算法上的“狠”更值钱。