用户点击"生成图纸"后,可能发生的事情远不止成功或失败:系统图库被打开、用户主动取消、云端图片尚未就绪、URI 不能直接解码、文件句柄打开失败、PixelMap 创建失败、导出时重复点击、系统保存界面又被取消。
如果这些分支最后都变成一个 status = error.message,页面虽然没有崩溃,用户却不知道下一步该做什么。更糟的是,有些实现会在失败时清空已选图片或当前图纸,迫使用户从头开始。
拼豆制图已经把创建状态与导出状态分开,下一步是把零散字符串升级成显式状态机:取消是正常结果,失败要带错误类型,页面必须保留可恢复数据。

本文重点解决:
- 如何把选图、转换和导出拆成互不覆盖的状态域。
- 为什么用户取消不能进入异常分支。
- 如何为服务层错误建立稳定类型,而不是匹配中文或英文文本。
- URI 直读失败后如何回退文件句柄,并保证资源释放。
- 导出任务如何防并发、保留当前图纸并提供重试动作。
- 为什么使用系统选择与创建界面时,不应先假设需要广泛媒体权限。


先把"失败"拆成用户可以理解的结果
从用户视角看,不同结果对应不同下一步:
| 结果 | 是否异常 | 页面应保留 | 下一步动作 |
|---|---|---|---|
| 未开始 | 否 | 当前图纸 | 选择图片 |
| 正在选择 | 否 | 当前图纸 | 等待系统界面 |
| 用户取消选择 | 否 | 旧 URI、当前图纸 | 再次选择 |
| 已选择 | 否 | 新 URI、当前图纸 | 生成图纸 |
| 正在转换 | 否 | URI、旧图纸 | 等待,禁止重复生成 |
| 图片解析失败 | 是 | URI、旧图纸 | 换图或重试 |
| 正在导出 | 否 | 当前图纸 | 等待,禁止重复导出 |
| 用户取消保存 | 否 | 当前图纸 | 再次导出 |
| 保存失败 | 是 | 当前图纸 | 重试或检查系统状态 |
这里最重要的规则是:失败只改变当前流程状态,不删除上一次成功结果。图片转换失败时,用户仍可查看原来的编号图;导出失败时,当前图纸更不能消失。
创建与导出必须是两个状态域
现有页面使用 createStatus、exportStatus 和 isExporting,已经避免了一条保存提示覆盖上传流程。进一步可以把字符串收紧为类型:
typescript
type CreatePhase =
'idle' |
'picking' |
'ready' |
'converting' |
'success' |
'cancelled' |
'failed';
type ExportPhase =
'idle' |
'rendering' |
'waiting-system' |
'success' |
'cancelled' |
'failed';
interface FlowState<T> {
phase: T;
message: string;
retryable: boolean;
}
页面状态随之变成:
typescript
@State pickedImageUri: string = '';
@State createState: FlowState<CreatePhase> = {
phase: 'idle',
message: '请选择一张喜欢的图片开始转换',
retryable: false
};
@State exportState: FlowState<ExportPhase> = {
phase: 'idle',
message: '',
retryable: false
};
两个状态域可以同时存在:创建流程已经成功,导出流程可能失败。页面不需要清除创建成功文案来显示导出错误。
不要让 UI 通过错误文本猜类型
当前导出逻辑通过 cancel、Cancel、取消 判断用户是否取消。它能应急,却不是稳定协议:系统语言变化、底层文案调整、第三方错误包装都可能让判断失效。
服务层应该抛结构化错误:
typescript
export enum PatternFlowErrorCode {
USER_CANCELLED = 'USER_CANCELLED',
PICKER_FAILED = 'PICKER_FAILED',
URI_UNREADABLE = 'URI_UNREADABLE',
DECODE_FAILED = 'DECODE_FAILED',
RENDER_FAILED = 'RENDER_FAILED',
FILE_WRITE_FAILED = 'FILE_WRITE_FAILED',
GALLERY_SAVE_FAILED = 'GALLERY_SAVE_FAILED'
}
export class PatternFlowError extends Error {
readonly code: PatternFlowErrorCode;
readonly causeMessage: string;
constructor(
code: PatternFlowErrorCode,
message: string,
causeMessage: string = ''
) {
super(message);
this.code = code;
this.causeMessage = causeMessage;
}
}
页面只根据 code 选择文案和按钮;底层原始消息可用于安全日志,但不直接成为用户界面协议。
选图取消应作为返回值,而不是异常
系统选择器返回空 URI,表示没有新图片被选中。这是正常交互,不是"选择失败"。可以用判别联合类型表达:
typescript
export type PickImageResult =
| { kind: 'selected'; uri: string }
| { kind: 'cancelled' };
export class ImagePickerService {
static async pickSingleImage(): Promise<PickImageResult> {
const options = new photoAccessHelper.PhotoSelectOptions();
options.MIMEType =
photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = 1;
const picker = new photoAccessHelper.PhotoViewPicker();
try {
const result = await picker.select(options);
if (result.photoUris.length === 0) {
return { kind: 'cancelled' };
}
return { kind: 'selected', uri: result.photoUris[0] };
} catch (error) {
const cause = error as BusinessError;
throw new PatternFlowError(
PatternFlowErrorCode.PICKER_FAILED,
'无法打开系统图库',
cause.message
);
}
}
}
这里把"空结果"和"选择器抛错"彻底分开。用户取消时页面可以保持旧 URI;只有明确选中新图片后,才替换当前输入。
首次点击生成不应要求用户再点第二次
现有 generateNumberedPattern() 在 URI 为空时调用 pickImageFromAlbum() 后立即返回。用户第一次点击完成选图,只会看到"图片已选择",还要第二次点击才能生成。
如果按钮文案是"生成图纸",更连贯的交互是选中后继续转换:
typescript
private async resolveInputUri(): Promise<string | null> {
if (this.pickedImageUri.length > 0) {
return this.pickedImageUri;
}
this.createState = {
phase: 'picking',
message: '正在打开系统图库...',
retryable: false
};
const result = await ImagePickerService.pickSingleImage();
if (result.kind === 'cancelled') {
this.createState = {
phase: 'cancelled',
message: '未选择新图片,可以再次尝试',
retryable: true
};
return null;
}
this.pickedImageUri = result.uri;
return result.uri;
}
页面可以保留两个独立入口:"选择图片"只选图,"生成图纸"在缺少输入时选图并继续。关键是按钮文案与实际完成的动作一致。
生成流程只在成功后提交新图纸
转换开始时,旧 generatedPattern 和 selectedPatternId 不应被清空。只有服务完整返回新 Pattern,才一次性提交状态:
typescript
private async generateNumberedPattern(): Promise<void> {
try {
const uri = await this.resolveInputUri();
if (uri === null) {
return;
}
this.createState = {
phase: 'converting',
message: '正在读取图片并生成 70×70 编号图...',
retryable: false
};
const result = await ImageConvertService.convertFromPickedImage(
uri,
this.getSelectedPattern()
);
this.generatedPattern = result.pattern;
this.selectedPatternId = result.pattern.id;
this.savePatternRecord(result.pattern);
this.createState = {
phase: 'success',
message: result.message,
retryable: false
};
this.activeTab = 'numbered';
} catch (error) {
this.createState = this.createFailureState(error);
}
}
这种写法相当于一个小事务:先计算,后提交。中间任何一步失败,页面仍然拥有上一次成功图纸。
URI 解码要有两条路径和一个释放出口
相册返回的 URI 不一定能被 image.createImageSource(uri) 直接接受。项目先尝试 URI,失败后通过 fileIo.openSync() 获得文件描述符:
typescript
private static async readImageBuffer(
uri: string,
size: number
): Promise<ArrayBuffer> {
let source: image.ImageSource | undefined = undefined;
let pixelMap: image.PixelMap | undefined = undefined;
let openedFile: fileIo.File | undefined = undefined;
try {
try {
source = image.createImageSource(uri);
} catch (_) {
openedFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
source = image.createImageSource(openedFile.fd);
}
if (source === undefined) {
throw new PatternFlowError(
PatternFlowErrorCode.URI_UNREADABLE,
'无法读取所选图片'
);
}
pixelMap = await source.createPixelMap({
desiredSize: { width: size, height: size },
desiredPixelFormat: image.PixelMapFormat.RGBA_8888
});
const buffer = new ArrayBuffer(pixelMap.getPixelBytesNumber());
await pixelMap.readPixelsToBuffer(buffer);
return buffer;
} catch (error) {
throw new PatternFlowError(
PatternFlowErrorCode.DECODE_FAILED,
'图片无法解析,请换一张本地图片重试',
(error as Error).message
);
} finally {
if (pixelMap !== undefined) {
await pixelMap.release();
}
if (source !== undefined) {
await source.release();
}
if (openedFile !== undefined) {
fileIo.closeSync(openedFile.fd);
}
}
}
函数内部完成像素读取并返回普通 ArrayBuffer,因此 PixelMap、ImageSource 和文件句柄都能在同一个 finally 中释放。资源所有权必须显式,不能让两个层级都以为对方会清理。
转换服务要保留原始错误类型
现有实现会把所有异常包装成 图片解析失败:${message}。这样页面无法区分 URI 不可读、解码失败和内存不足。
推荐只包装未知错误,已经结构化的错误直接透传:
typescript
static async convertFromPickedImage(
uri: string,
source: Pattern
): Promise<ImageConvertResult> {
try {
return await ImageConvertService.convertInternal(uri, source);
} catch (error) {
if (error instanceof PatternFlowError) {
throw error;
}
throw new PatternFlowError(
PatternFlowErrorCode.DECODE_FAILED,
'图片解析失败,请重新选择图片',
(error as Error).message
);
}
}
错误每经过一层都加一段中文前缀,最后会变成"生成失败:图片解析失败:读取失败......",既难看也不利于分类。
权限设计从最小能力开始
当前 module.json5 没有声明媒体读取或写入权限,交互依赖系统图片选择器和系统资产创建界面。这个设计避免了应用一启动就向用户申请大范围相册访问。
json5
{
"module": {
"name": "entry",
"type": "entry",
"deviceTypes": ["phone", "tablet", "2in1"],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets"
}
]
}
}
这里不能得出"所有媒体操作永远都不需要权限"的结论。是否需要额外声明,要根据目标 SDK、具体 API、访问范围和官方文档核对。工程原则是:
- 用户只选择一张图时,优先使用系统选择界面授予的受控访问。
- 保存一张导出图时,优先使用系统创建界面让用户确认。
- 只有确实需要批量扫描、后台读取或管理媒体库时,才评估更广能力。
- 不要因为看到"相册"两个字就预先添加无关权限。
导出状态要防并发,也要区分取消
PNG 导出包含位图渲染、临时文件写入和系统创建界面。两个按钮同时点击可能启动两个重任务,因此页面要用阶段状态做互斥:
typescript
private isExportBusy(): boolean {
return this.exportState.phase === 'rendering' ||
this.exportState.phase === 'waiting-system';
}
private async exportPatternToGallery(pattern: Pattern): Promise<void> {
if (this.isExportBusy()) {
return;
}
this.exportState = {
phase: 'rendering',
message: '正在生成高清编号图...',
retryable: false
};
try {
const context = getContext(this) as common.UIAbilityContext;
await PatternExportService.savePatternToGallery(context, pattern);
this.exportState = {
phase: 'success',
message: '已保存到系统相册',
retryable: false
};
} catch (error) {
this.exportState = this.exportFailureState(error);
}
}
按钮的 .enabled(!this.isExportBusy()) 和任务入口的二次保护都要保留。只禁用 UI 不够,其他入口或快速事件仍可能调用方法。
页面文案要告诉用户下一步
结构化错误最终要翻译成动作,而不是展示技术栈:
typescript
private createFailureState(error: Object): FlowState<CreatePhase> {
if (error instanceof PatternFlowError) {
if (error.code === PatternFlowErrorCode.USER_CANCELLED) {
return {
phase: 'cancelled',
message: '已取消选择,可以重新打开图库',
retryable: true
};
}
if (error.code === PatternFlowErrorCode.DECODE_FAILED) {
return {
phase: 'failed',
message: '这张图片无法解析,请换一张本地图片',
retryable: true
};
}
}
return {
phase: 'failed',
message: '生成失败,请稍后重试',
retryable: true
};
}
用户文案不应包含文件描述符、缓存路径或完整 URI。日志也要避免记录用户图片 URI 和文件名;保留错误码、阶段、耗时和任务 ID,通常已经足够定位问题。
重试动作必须回到正确阶段
不同失败对应不同重试入口:
| 错误类型 | 保留数据 | 主按钮 | 次按钮 |
|---|---|---|---|
| 取消选图 | 旧 URI、旧图纸 | 重新选择 | 返回 |
| URI 不可读 | 失败 URI、旧图纸 | 换一张图 | 重试读取 |
| 解码失败 | 失败 URI、旧图纸 | 换一张图 | 查看支持说明 |
| 导出取消 | 当前图纸 | 再次导出 | 继续编辑 |
| 文件写入失败 | 当前图纸 | 重试 | 检查存储空间 |
| 系统保存失败 | 当前图纸、临时任务已清理 | 再次导出 | 稍后处理 |
不要给所有错误都显示"重试"。如果 URI 本身已经失效,重复执行同一解析只会再次失败;此时主动作应该是"重新选择图片"。
验证要主动制造异常
成功路径走一遍远远不够。建议建立如下验证矩阵:
powershell
hvigor assembleHap --no-daemon
- 打开选择器后取消,确认状态为取消而不是失败。
- 已有旧 URI 时再次取消,确认旧图和当前编号图不被清空。
- 首次点击生成并选中图片,确认是否按产品定义直接继续转换。
- 使用普通 JPG、透明 PNG、超大图片和不可读 URI 分别测试。
- URI 直读失败时,确认文件描述符回退路径生效。
- 解码失败后再次选择正常图片,状态能够从
failed回到success。 - 导出过程中快速连续点击两个按钮,只启动一个任务。
- 在系统保存界面取消,确认页面显示取消且图纸仍可操作。
- 模拟临时文件写入失败,确认任务解锁并允许重试。
- 连续失败多次后成功,确认没有残留
PixelMap、文件句柄或忙碌状态。 - 检查日志,不应出现用户图片 URI、绝对路径或其他敏感内容。
常见故障沿状态迁移定位
| 现象 | 优先检查 | 根因 | 修复方式 |
|---|---|---|---|
| 取消选择却提示系统失败 | Picker 返回映射 | 空 URI 被当异常 | 返回 cancelled 结果 |
| 选图后还要再点一次生成 | 入口流程 | 选择后立即 return |
明确按钮语义并串联转换 |
| 错误文案越来越长 | 服务包装 | 每层重复加前缀 | 结构化错误直接透传 |
| 失败后旧图纸消失 | 提交顺序 | 转换前清空成功状态 | 成功后一次性提交 |
| 同一张图导出两次 | 并发保护 | 只禁用一个按钮 | 状态机入口二次拦截 |
| 取消保存被识别为失败 | 错误分类 | 依赖字符串匹配 | 使用稳定错误码 |
| 重试一直失败 | 恢复动作 | 对失效 URI 原地重试 | 引导重新选图 |
| 多次操作后内存升高 | 资源生命周期 | PixelMap 或 ImageSource 未释放 | 单一 finally 出口 |
| 为单图选择申请广泛权限 | 能力选型 | 先假设权限再选 API | 从系统受控界面开始 |
| 日志泄露图片信息 | 诊断策略 | 打印完整 URI 和路径 | 记录阶段、错误码和任务 ID |
排查时按"用户动作 → 状态迁移 → 服务错误码 → 资源释放 → UI 恢复动作"推进。不要先改文案把错误藏起来,也不要在页面 catch 中复制底层文件逻辑。
小结
可恢复异常处理的目标不是让失败消失,而是让每一次失败都有稳定类型、保留数据和明确下一步。选图取消作为正常结果返回,解析服务用结构化错误表达 URI 与解码问题,导出状态机负责并发和系统取消,页面只在成功后提交新图纸。再配合最小能力原则和完整资源释放,工具型应用才能在真实用户操作中保持可靠。