Harmony os 技术实战|拼豆制图10:把取消、解析失败和保存失败写成可恢复状态机

用户点击"生成图纸"后,可能发生的事情远不止成功或失败:系统图库被打开、用户主动取消、云端图片尚未就绪、URI 不能直接解码、文件句柄打开失败、PixelMap 创建失败、导出时重复点击、系统保存界面又被取消。

如果这些分支最后都变成一个 status = error.message,页面虽然没有崩溃,用户却不知道下一步该做什么。更糟的是,有些实现会在失败时清空已选图片或当前图纸,迫使用户从头开始。

拼豆制图已经把创建状态与导出状态分开,下一步是把零散字符串升级成显式状态机:取消是正常结果,失败要带错误类型,页面必须保留可恢复数据。

本文重点解决:

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

先把"失败"拆成用户可以理解的结果

从用户视角看,不同结果对应不同下一步:

结果 是否异常 页面应保留 下一步动作
未开始 当前图纸 选择图片
正在选择 当前图纸 等待系统界面
用户取消选择 旧 URI、当前图纸 再次选择
已选择 新 URI、当前图纸 生成图纸
正在转换 URI、旧图纸 等待,禁止重复生成
图片解析失败 URI、旧图纸 换图或重试
正在导出 当前图纸 等待,禁止重复导出
用户取消保存 当前图纸 再次导出
保存失败 当前图纸 重试或检查系统状态

这里最重要的规则是:失败只改变当前流程状态,不删除上一次成功结果。图片转换失败时,用户仍可查看原来的编号图;导出失败时,当前图纸更不能消失。

创建与导出必须是两个状态域

现有页面使用 createStatusexportStatusisExporting,已经避免了一条保存提示覆盖上传流程。进一步可以把字符串收紧为类型:

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 通过错误文本猜类型

当前导出逻辑通过 cancelCancel取消 判断用户是否取消。它能应急,却不是稳定协议:系统语言变化、底层文案调整、第三方错误包装都可能让判断失效。

服务层应该抛结构化错误:

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;
}

页面可以保留两个独立入口:"选择图片"只选图,"生成图纸"在缺少输入时选图并继续。关键是按钮文案与实际完成的动作一致。

生成流程只在成功后提交新图纸

转换开始时,旧 generatedPatternselectedPatternId 不应被清空。只有服务完整返回新 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,因此 PixelMapImageSource 和文件句柄都能在同一个 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、访问范围和官方文档核对。工程原则是:

  1. 用户只选择一张图时,优先使用系统选择界面授予的受控访问。
  2. 保存一张导出图时,优先使用系统创建界面让用户确认。
  3. 只有确实需要批量扫描、后台读取或管理媒体库时,才评估更广能力。
  4. 不要因为看到"相册"两个字就预先添加无关权限。

导出状态要防并发,也要区分取消

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
  1. 打开选择器后取消,确认状态为取消而不是失败。
  2. 已有旧 URI 时再次取消,确认旧图和当前编号图不被清空。
  3. 首次点击生成并选中图片,确认是否按产品定义直接继续转换。
  4. 使用普通 JPG、透明 PNG、超大图片和不可读 URI 分别测试。
  5. URI 直读失败时,确认文件描述符回退路径生效。
  6. 解码失败后再次选择正常图片,状态能够从 failed 回到 success
  7. 导出过程中快速连续点击两个按钮,只启动一个任务。
  8. 在系统保存界面取消,确认页面显示取消且图纸仍可操作。
  9. 模拟临时文件写入失败,确认任务解锁并允许重试。
  10. 连续失败多次后成功,确认没有残留 PixelMap、文件句柄或忙碌状态。
  11. 检查日志,不应出现用户图片 URI、绝对路径或其他敏感内容。

常见故障沿状态迁移定位

现象 优先检查 根因 修复方式
取消选择却提示系统失败 Picker 返回映射 空 URI 被当异常 返回 cancelled 结果
选图后还要再点一次生成 入口流程 选择后立即 return 明确按钮语义并串联转换
错误文案越来越长 服务包装 每层重复加前缀 结构化错误直接透传
失败后旧图纸消失 提交顺序 转换前清空成功状态 成功后一次性提交
同一张图导出两次 并发保护 只禁用一个按钮 状态机入口二次拦截
取消保存被识别为失败 错误分类 依赖字符串匹配 使用稳定错误码
重试一直失败 恢复动作 对失效 URI 原地重试 引导重新选图
多次操作后内存升高 资源生命周期 PixelMap 或 ImageSource 未释放 单一 finally 出口
为单图选择申请广泛权限 能力选型 先假设权限再选 API 从系统受控界面开始
日志泄露图片信息 诊断策略 打印完整 URI 和路径 记录阶段、错误码和任务 ID

排查时按"用户动作 → 状态迁移 → 服务错误码 → 资源释放 → UI 恢复动作"推进。不要先改文案把错误藏起来,也不要在页面 catch 中复制底层文件逻辑。

小结

可恢复异常处理的目标不是让失败消失,而是让每一次失败都有稳定类型、保留数据和明确下一步。选图取消作为正常结果返回,解析服务用结构化错误表达 URI 与解码问题,导出状态机负责并发和系统取消,页面只在成功后提交新图纸。再配合最小能力原则和完整资源释放,工具型应用才能在真实用户操作中保持可靠。

相关推荐
huabuyu1 小时前
SourceMap:从「看不懂线上报错」到「让 AI 定位真根因」
前端·javascript
এ慕ོ冬℘゜1 小时前
前端树形二级列表渲染 + 页面传参完整实战解析(附原生jQuery源码)
前端·javascript·jquery
whn19771 小时前
达梦连接串JDBC测试程序
java·数据库
还是鼠鼠2 小时前
Spring AI ChatClient详解:创建第一个AI客户端
java·springboot·spring ai·chatclient
2501_919749032 小时前
华为鸿蒙美缝剂实用APP—小羊美缝
华为·harmonyos
2501_919749032 小时前
华为鸿蒙积攒年度高光APP—小羊高光
华为·harmonyos
vHelios2 小时前
【电商项目】测试 授权功能 遇到的问题与解决方案
java·spring boot·sql
用户24171401418602 小时前
一个 Tapas 菜单 App,逼我啃下了前端存储和 this 绑定两座大山
javascript
2401_894915532 小时前
GEO 定位优化源码搭建常见报错排查:数据库、伪静态、接口调试
java·数据库·网络协议·tcp/ip·spring·unity