这篇不聊“会不会调 API”,聊的是一个更像线上项目的问题:
用户选了十几张图,页面不能卡;处理完了,结果要能稳定回存;页面退到后台以后,上传也别说没就没。
我最近在做图片整理类功能时,重新把这条链路梳了一遍。以前很多写法放在 Demo 里没有问题,一旦到了真机、真用户、真批量场景,问题就全出来了:
- 一上来就申请大权限,用户犹豫一下,转化率先掉一截。
- 图片解码、缩放、编码全放主线程,选 8~10 张图时首帧明显发紧。
- “保存成功”只是前端状态改了,文件其实还没稳定落盘。
- 上传逻辑绑死在页面生命周期上,页面一退,任务也容易跟着断。
后来我换了一个思路:入口尽量交给系统 Picker,重活交给 TaskPool,编码收口到 ImagePacker,回存尽量走 SaveButton / 授权式保存,后台上传交给标准后台任务机制。
这个改完以后,最大的变化不是“代码更优雅”,而是整条用户路径没那么慌了。
一、先说结论:图片类功能别再把所有活都塞给页面
HarmonyOS 这几年给图片、文件、后台任务这些场景配的系统能力,其实已经很完整了。真正容易踩坑的,不是能力不够,而是链路设计还停留在“能跑就行”。
对图片整理、票据扫描、相册工具、内容发布这类应用,我更推荐下面这套拆法:
-
入口层:优先系统 Picker / Camera Picker
先把“拿到资源”这件事做轻,不要一上来就用全量权限思路。 -
处理层:解码、缩放、编码、哈希都丢到 TaskPool
UI 线程只管状态切换、进度回写、结果展示。 -
结果层:临时文件先落应用沙箱
不要一边处理一边直接改外部结果,失败以后很难回滚。 -
回存层:保存回相册时优先走 SaveButton / 授权保存
这一步是“用户确认写入系统资源”,语义更清晰,也更稳。 -
后续层:上传 / 同步用标准后台任务机制托管
页面销毁不等于业务任务应该立刻消失。
这套方案的核心不是“炫 API”,而是四个字:职责拆分。
二、为什么这套链路更适合线上项目
我把它总结成一句话:
系统能力负责边界,业务代码负责规则。
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 个细节
细节 1:不要只存“任务成功”,要存“文件可用”
很多代码会写成这样:
- TaskPool 返回成功
- 列表显示“已完成”
- 用户点击导出
- 结果发现文件并不可读
这类问题本质上是:
你把“任务返回成功”和“结果文件可用”混成了一件事。
更稳的做法是分开记:
taskStatusfileStatusexportStatus
状态一旦拆开,排查就快很多。
细节 2:格式策略不要写死
图片场景里,格式不是越统一越好,而是要看业务目标。
我自己一般会这么分:
- 追求通用分享:优先 JPEG
- 需要透明背景:保留 PNG
- 更关注体积:视兼容性考虑 WebP / HEIF
- 要保细节或特殊能力:另走高质量策略
所以我不太建议在项目初期就写死成“所有图都压成 JPEG 80”。
后面你只要接到一次“为什么透明背景没了”的反馈,就会明白这个坑有多真实。
细节 3:页面状态不要和上传状态绑在一起
很多项目会在图片处理页里顺手把上传也一起做掉。
从流程上看没毛病,从生命周期看问题很大。
更推荐的方式是:
- 页面内只负责发起上传任务;
- 上传状态由任务系统维护;
- 页面返回后,再次进入时读取任务状态。
这样你才能真正做到:
- 页面退了,任务还在;
- 网络恢复了,任务还能继续;
- 失败了,可以单独重试,不必重新压图。
细节 4:别把“系统资源访问”当普通本地文件读写
很多 Bug 到最后都不是图像算法问题,而是 URI、授权、资源归属、目录可见性这些边界没想清楚。
尤其是下面几件事,最好一开始就想明白:
- 你拿到的是临时 URI 还是长期可读资源?
- 压缩结果先落哪里?
- 谁来触发最终导出?
- 导出失败后是否还能重试?
- 用户没点保存,但点了上传,这是不是允许?
这些问题想通以后,代码层面的复杂度反而会下降。
五、给一个我自己现在更认可的工程拆法
如果让我现在从 0 到 1 再搭一次图片整理功能,我会直接按下面分层:
1)UI 层
只负责:
- 选择入口
- 任务列表展示
- 进度与错误提示
- 预览结果
- 用户点击导出 / 上传
2)任务编排层
只负责:
- 队列管理
- 串行 / 并行策略
- 失败重试
- 结果状态汇总
3)图像处理层
只负责:
- 解码
- 缩放
- 编码
- 哈希 / 校验
- 临时文件输出
4)资源与系统能力层
只负责:
- Picker 获取资源
- SaveButton / 授权保存
- 后台任务托管
- 文件系统 / URI 管理
这样一来,后面你要加这些功能都很自然:
- 批量水印
- 多规格导出
- 上传前秒传校验
- 压缩失败自动降级
- 低电量 / 弱网场景策略切换
六、最后说一句真话:图片功能拼的不是“压缩算法”,是链路稳定性
很多时候大家讨论图片处理,第一反应都是:
- 质量参数设多少?
- 压缩率能不能更高?
- WebP 和 JPEG 哪个更小?
这些当然重要,但在实际项目里,用户第一时间感知到的并不是“你比别人小了 8%”,而是:
- 点了以后会不会卡
- 处理过程中会不会慌
- 保存会不会莫名失败
- 退到后台以后会不会白跑
所以我现在更在意的排序是:
- 不卡
- 稳定
- 边界清楚
- 最后才是压得漂亮
这也是我为什么越来越倾向于把系统 Picker、TaskPool、ImagePacker、SaveButton、后台任务这些能力串成一条完整链路,而不是各自零散使用。
真正上线以后你会发现,工程上的“顺”,比算法上的“狠”更值钱。