像素画编辑器里的图片导入导出,看起来像两组文件选择按钮,实际跨过了相册选择器、文件 URI、图片解码、像素格式、缩放、图层合成、临时文件、图库资产和动画编码。只要忽略其中一个环节,就会出现很典型的问题:导入后比例变形、透明背景变黑、JPG 显示脏色、GIF 只有第一帧,或者界面提示保存成功但图库里找不到文件。

bitArt 把输入和输出拆成 ImageImportService.etsEditorExportService.etsEditorImageExportWorkflowService.ets。导入服务负责从系统选择图片并转换为编辑器像素;导出服务负责格式编码和目标写入;工作流服务在导出前结束笔触、强制保存项目并同步动画帧。三层合在一起,才是一条完整的媒体链路。

导入有两种语义:新项目和新图层

从新建入口导入图片,用户期待原图决定画布尺寸,并创建一份独立项目;从已有工作区导入,用户期待当前画布尺寸不变,图片作为新图层加入。这两种路径不能共用同一个返回结果后再由页面猜测。

importImageAsProject 返回画布宽高、图层数组、像素数组、活动图层和图层序号。importImageToCanvas 只返回适配当前画布后的单层像素。页面因此可以明确选择“替换项目上下文”或“追加图层”,不会在导入服务里夹带界面状态。

export interface ImageImportResult {
  layers: LayerItem[];
  layersPixels: string[][];
  activeLayer: string;
  canvasSize: number;
  canvasWidth: number;
  canvasHeight: number;
  layerSerial: number;
}

export interface ImageLayerImportResult {
  pixels: string[];
  width: number;
  height: number;
}

这层区分还影响撤销:导入为图层应作为当前项目的一次可撤销操作;导入为新项目则进入项目管理和持久化流程,不能把旧项目直接覆盖掉。

用系统选择器取得 URI,不自己扫描相册

导入入口使用 PhotoViewPicker,限制 MIME 类型为图片并只选择一项。选择器返回的是 URI,不是传统文件路径。应用不需要枚举用户图库,也不用先取得整个媒体库的读取权限。

static async selectImageUri(): Promise<string> {
  const picker = new photoAccessHelper.PhotoViewPicker();
  const options = new photoAccessHelper.PhotoSelectOptions();
  options.MIMEType =
    photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
  options.maxSelectNumber = 1;
  const result = await picker.select(options);
  return result.photoUris.length > 0 ? result.photoUris[0] : '';
}

取消选择和真正失败要在交互层区分。bitArt 将空 URI 返回为“未选择图片”,异常返回为“导入失败”。两者都不会修改当前项目。若把取消也当成错误,用户每次从选择器返回都会收到不必要的失败提示。

URI 不一定能直接当路径打开

媒体 URI 的提供者和授权方式可能不同。createImageSourceHandle 先尝试用 fileIo.open(uri, READ_ONLY) 得到文件描述符,再用文件描述符创建 ImageSource;若打开失败,再让图片框架直接从 URI 创建数据源。

private static async createImageSourceHandle(
  uri: string): Promise<ImageSourceHandle> {
  try {
    const file = await fileIo.open(uri, fileIo.OpenMode.READ_ONLY);
    return {
      source: image.createImageSource(file.fd),
      file
    };
  } catch (_error) {
    return {
      source: image.createImageSource(uri),
      file: null
    };
  }
}

无论哪条路径成功,finally 中都要释放 PixelMapImageSource 和已打开的文件。图片导入频率不算高,但大图的解码缓冲很可观,资源只等垃圾回收会让连续导入时的内存峰值迅速上升。

保持比例,再决定缩放到哪里

导入为新项目时,目标尺寸以原图比例为基础,最长边不超过编辑器最大画布。导入到已有画布时,比例同样保持,但目标矩形需要同时放进当前宽度和高度,然后居中写入透明画布。

const scale = Math.min(
  safeCanvasWidth / Math.max(1, sourceWidth),
  safeCanvasHeight / Math.max(1, sourceHeight)
);
return {
  width: Math.max(1, Math.min(
    safeCanvasWidth, Math.round(sourceWidth * scale))),
  height: Math.max(1, Math.min(
    safeCanvasHeight, Math.round(sourceHeight * scale)))
};

如果把宽和高分别拉满,非方形图片会变形。若只按宽度缩放,竖图又可能超出画布。取两个比例中的较小值,才能保证完整放入。居中时使用 (canvas - image) / 2 计算行列偏移,剩余位置保持透明,而不是填成白色。

像素画导入还要接受一个现实:普通照片缩小到 64×64 或更低时必然丢细节。bitArt 使用中心点近邻采样,优势是颜色边界清晰,不会引入双线性插值产生的半透明过渡;代价是照片可能显得生硬。这符合像素编辑器的用途,但不等同于专业的颜色量化和抖动算法。

RGBA 缓冲要转换为编辑器颜色格式

PixelMap.readPixelsToBuffer 得到 RGBA 字节。编辑器内部使用 #RRGGBB#RRGGBBAA 字符串,因此导入时按目标像素映射源坐标,再把四个通道转成十六进制颜色。

private static rgbaToPixel(red: number, green: number,
  blue: number, alpha: number): string {
  if (alpha < 8) {
    return PixelCanvasService.TRANSPARENT_COLOR;
  }
  if (alpha > 247) {
    return '#' + toHex(red) + toHex(green) + toHex(blue);
  }
  return '#' + toHex(red) + toHex(green) +
    toHex(blue) + toHex(alpha);
}

接近全透明的像素统一为透明色,接近不透明的像素省略 alpha,只有中间范围保留八位颜色。这样可减少大量无意义的 00FF 差异,也与现有画布颜色解析保持一致。

导出前先结束笔触并同步项目

用户在手指刚离开画布时立刻点导出,触摸合并队列可能仍有最后一个移动点待处理。工作流先 endStroke(),再设置忙碌状态、关闭菜单,并调用 saveNow(true)。GIF 还要把活动帧的工作区像素同步回帧数组,然后才构造导出帧。

hooks.endStroke();
target.exportingImage = true;
target.mainMenuOpen = false;
await hooks.saveNow(true);

const exportFrames = target.exportImageFormat === 'gif'
  ? EditorImageExportWorkflowService.createExportFrames(target)
  : [];

忙碌标记阻止重复点击产生两次编码。无论导出成功还是失败,控制器都会返回可显示结果,工作流再清除标记。实际工程中还应留意异常路径是否能保证复位,避免一次失败后按钮永久失效。

PNG 保留透明,JPG 必须铺底

PNG 可以保存 alpha,JPG 没有透明通道。bitArt 创建 RGBA 缓冲时,根据格式决定是否把透明像素铺到绘画区背景色上;PNG 保留透明值,JPG 先扁平化,再通过 ImagePacker 以 JPEG 质量 92 写入。

const rgbaBuffer = EditorExportService.pixelsToRgbaBuffer(
  pixels,
  canvasWidth,
  canvasHeight,
  backgroundColor,
  format === 'jpg',
  normalizedScale
);

await packer.packToFile(pixelMap, file.fd, {
  format: format === 'jpg' ? 'image/jpeg' : 'image/png',
  quality: format === 'jpg' ? 92 : 100
});

若直接把透明像素的 RGB 零值交给 JPEG,常见结果是黑底。铺底颜色必须来自当前项目设置,而不是硬编码白色,否则用户在深色绘画区预览正常,导出 JPG 后却完全换了视觉效果。

放大导出要坚持最近邻

24×24 的像素画直接分享到社交平台,常常会被预览缩得很小。bitArt 支持 1x 到 4x 导出倍率,每个源像素复制到对应的方块,不做平滑插值。

for (let row = 0; row < exportHeight; row++) {
  const sourceRow = Math.floor(row / exportScale);
  for (let col = 0; col < exportWidth; col++) {
    const sourceCol = Math.floor(col / exportScale);
    scaledPixels[row * exportWidth + col] =
      pixels[sourceRow * canvasWidth + sourceCol];
  }
}

这能保持像素边缘锐利。导出倍率不能无限增大,因为内存占用按面积增长,4x 意味着像素数量变为 16 倍。对当前最大画布限制而言,1 到 4 是更稳妥的范围。

GIF 要同时处理时长、调色板和压缩

GIF 不是把多张 PNG 串起来。bitArt 写入 GIF89a 头、逻辑屏幕描述、全局调色板、循环扩展和逐帧图像数据。每帧的毫秒时长转换为百分之一秒,至少保留两个单位,避免过短延时被播放器忽略。

当所有帧的颜色不超过 256 种时,可以建立精确调色板;超过时切换到量化调色板,再把颜色映射为索引。透明像素在当前实现中会按背景色铺底,和 JPG 一样形成稳定的可播放画面。之后使用 LZW 编码并按最多 255 字节拆成数据子块。

const delay = Math.max(2, Math.round(frame.durationMs / 10));
writeGraphicControlExtension(bytes, delay);
writeImageDescriptor(bytes, canvasWidth, canvasHeight);
const indices = pixelsToPaletteIndices(
  frame.pixels, canvasWidth, canvasHeight,
  backgroundColor, palette
);
writeImageData(bytes, indices, 8);

这里的边界要说清楚:当前 GIF 编码适合像素画和有限色动画,不追求照片级量化质量。复杂渐变超过 256 色后会产生色阶压缩,这是格式和简化量化策略共同造成的结果。

写入图库先走资产变更,失败再弹系统确认

导出服务先在 cacheDir/exports 写临时文件,再把路径转换为 URI。随后尝试使用 MediaAssetChangeRequest.createImageAssetRequest(context, sourceUri)applyChanges 导入图库;若这条路径没有取得资产 URI,再调用 showAssetsCreationDialog 让系统完成用户确认。

const request =
  photoAccessHelper.MediaAssetChangeRequest
    .createImageAssetRequest(context, sourceUri);
await helper.applyChanges(request);
const asset = request.getAsset();
return asset.uri;

当前项目的 module.json5 没有声明面向整个图库的写入权限,保存入口配合系统 SaveButton 的临时授权结果处理。这个结论只对应 bitArt 当前的目标系统、API 和安全控件链路,不能直接套到所有图库写入方式。若改成普通按钮、调整目标版本或更换媒体接口,应重新核对华为官方的 [SaveButton 文档](https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/savebutton) 与对应 Media Library Kit API。系统返回临时授权失败时,界面只提示重试,不应伪报成功。

文件导出适合平板和二合一设备

除了写入图库,bitArt 在适合桌面文件交互的布局中提供 DocumentViewPicker.save。用户选择目标 URI和扩展名,服务根据选择器索引或 URI 后缀确定 PNG、JPG、GIF,再把临时文件复制过去。手机界面主要走图库保存,平板和二合一设备则可以把作品放到明确目录。

临时文件不能在复制或图库索引尚未完成时立即删除。当前实现保留在缓存目录,由系统缓存策略处理。若未来主动清理,也应放到目标写入成功之后,并确认图库侧不再依赖源 URI。

怎样验证图片链路

准备四类输入最有效:带透明边缘的 PNG、横向长图、纵向长图、颜色很多的照片。分别测试导入为新项目和导入到 32×64 的现有画布,确认宽高比例、居中位置、透明区域和资源释放。导出时制作三帧不同颜色、不同持续时间的动画,分别保存 PNG、JPG、GIF 和 4x 文件。

检查结果时不要只看成功提示。PNG 要确认透明通道;JPG 要确认背景色与设置一致;GIF 要确认帧数、循环和节奏;图库与文件选择器中都要实际打开一次。取消选择器、拒绝保存、连续点击导出和切后台也应覆盖。只有输入、编码、目标写入和失败反馈都能对上,图片链路才算可靠。

图片导入导出把应用状态与系统媒体能力连在了一起。把 URI 当 URI 处理、把透明与无透明格式分开、导出前同步最后一笔、图库与文件出口分别设计,能避开大多数“代码返回成功但作品不对”的问题。系列最后还要回到整个工作台,看看这些画布、时间轴、面板和系统入口怎样在手机横竖屏、平板和二合一设备上保持可用。

Logo

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

更多推荐