HarmonyOS趣味相机实战第30篇:相册PixelMap内存缓存、所有权转移与淘汰释放

摘要

Preferences 可以恢复照片元数据,却不能直接恢复内存中的 PixelMap。为了让刚保存的照片在相册卡片与详情中显示真实图像,趣味相机新增 Map<string, PixelMap> 缓存,并在保存时把拍照预览的 PixelMap 转交给相册服务。这一改动解决了“保存后只剩文字占位”,也带来了新的资源责任:谁释放 PixelMap、相册超过60条时如何淘汰、flush失败是否回滚、页面关闭能否继续使用,以及进程重启后为什么图片消失。

本文基于 PhotoAlbumService.etsIndex.ets 的最新实现,完整拆解 PixelMap 的所有权转移、按 photoId 查找、删除释放、缓存上限和持久化边界,并给出可落地的缓存条目模型、LRU淘汰、故障补偿与测试方法。

源码定位

文件 责任
service/CameraPreviewService.ets 从 PhotoAvailable 产出 PixelMap
pages/Index.ets 预览持有并把资源转交给相册
service/PhotoAlbumService.ets 元数据持久化与 PixelMap Map缓存
model/DecorationModels.ets CapturedPhoto 元数据结构
service/PhotoDocumentService.ets 转文档时复制轻量快照

环境与当前边界

项目 当前实现
图像类型 image.PixelMap
缓存容器 Map<string, image.PixelMap>
Key CapturedPhoto.id
元数据上限 60条
元数据持久化 Preferences JSON
图像持久化 当前没有,仅内存缓存
删除行为 release后从Map移除

HarmonyOS趣味相机PixelMap所有权链路

一、元数据和像素资源必须分开建模

CapturedPhoto 适合序列化:

export interface CapturedPhoto {
  id: string;
  title: string;
  createdAt: string;
  captureSummary: string;
  status: 'preview' | 'saved';
  watermark?: WatermarkSnapshot;
}

PixelMap 是原生图像资源,有显式 release() 生命周期,不能跟随 JSON 写进 Preferences。当前服务分别保存:

private static cachedPhotos: CapturedPhoto[] = [];
private static photoPixelMaps:
  Map<string, image.PixelMap> =
    new Map<string, image.PixelMap>();

数组负责“有哪些照片”,Map负责“当前进程还能展示哪些真实像素”。两者不是同一种持久化承诺。

二、拍照预览是最初所有者

CameraPreviewService 返回真实照片:

interface CameraCaptureState {
  realPhotoReceived: boolean;
  photoPixelMap?: image.PixelMap;
  message: string;
}

页面接管后:

this.previewPhotoPixelMap = captureState.photoPixelMap;
this.previewPhoto = PhotoAlbumService.createPhoto(...);

此时页面是资源所有者。用户重拍或转文档,页面调用 releasePreviewPixelMap();用户保存到相册,则所有权必须转给相册服务,不能再由页面释放。

三、保存时先从页面状态取走资源

最新实现:

private async savePreviewPhoto(): Promise<void> {
  if (!this.previewPhoto) {
    return;
  }
  const savedPixelMap: image.PixelMap | null =
    this.previewPhotoPixelMap;
  this.previewPhotoPixelMap = null;

  this.album = await PhotoAlbumService.persistPhoto(
    this.previewPhoto,
    savedPixelMap === null ? undefined : savedPixelMap
  );
  this.previewPhoto = null;
  this.activeTab = 1;
}

把成员先设为 null 是所有权转移信号:从这一行之后,页面的关闭逻辑不能再释放该对象。局部变量暂时拥有资源,调用 persistPhoto 后由相册服务接管。

四、服务按photoId保存PixelMap

static async persistPhoto(
  photo: CapturedPhoto,
  pixelMap?: image.PixelMap
): Promise<CapturedPhoto[]> {
  await PhotoAlbumService.waitForInit();
  const savedPhoto: CapturedPhoto =
    PhotoAlbumService.savePhoto(photo);

  if (pixelMap !== undefined) {
    PhotoAlbumService.photoPixelMaps.set(
      savedPhoto.id, pixelMap);
  }

  const nextPhotos: CapturedPhoto[] =
    [savedPhoto].concat(PhotoAlbumService.cachedPhotos);
  PhotoAlbumService.cachedPhotos = nextPhotos.slice(0, 60);
  await PhotoAlbumService.flushPhotos();
  return PhotoAlbumService.clonePhotos(
    PhotoAlbumService.cachedPhotos);
}

ID 同时连接元数据和像素资源。页面不把 PixelMap 塞进 CapturedPhoto,避免复制领域对象时误复制原生资源引用。

五、读取缓存只返回借用引用

static getPhotoPixelMap(
  photoId: string
): image.PixelMap | undefined {
  return PhotoAlbumService.photoPixelMaps.get(photoId);
}

页面调用:

private albumPhotoPixelMap(
  photo: CapturedPhoto | null
): image.PixelMap | undefined {
  if (photo === null) {
    return undefined;
  }
  return PhotoAlbumService.getPhotoPixelMap(photo.id);
}

这里返回的是借用引用,不是新 PixelMap。调用者可用于 Image 展示,但不能擅自 release;释放权仍属于 PhotoAlbumService。接口命名或注释应明确这一点:

// Borrowed reference. Caller must not release it.
static borrowPhotoPixelMap(photoId: string): image.PixelMap | undefined

六、删除照片时释放资源

const pixelMap: image.PixelMap | undefined =
  PhotoAlbumService.photoPixelMaps.get(photoId);

if (pixelMap !== undefined) {
  try {
    await pixelMap.release();
  } catch (error) {
    hilog.warn(DOMAIN, TAG,
      'release photo pixelMap failed: %{public}s',
      JSON.stringify(error));
  }
  PhotoAlbumService.photoPixelMaps.delete(photoId);
}

即使 release 失败也从 Map 删除,避免后续页面继续借用状态未知的对象。然后过滤元数据并 flush。

更稳妥的顺序取决于产品语义:若 Preferences 删除失败,当前实现已经释放图像、元数据内存也已删除,但重启后旧元数据可能重新出现。需要让 flushPhotos() 返回成功状态,失败时决定回滚还是标记待删除。

七、保存失败会产生所有权悬空

页面在 await 前已经把 previewPhotoPixelMap 清空。如果 persistPhoto() 在真正接管前抛错,局部 savedPixelMap 离开作用域,资源可能无人释放。

可以定义接管契约:

interface PersistPhotoResult {
  photos: CapturedPhoto[];
  pixelMapAccepted: boolean;
  persisted: boolean;
}

或者页面用 catch 回收:

const transferring = this.previewPhotoPixelMap;
this.previewPhotoPixelMap = null;
try {
  this.album = await PhotoAlbumService.persistPhoto(
    this.previewPhoto,
    transferring ?? undefined
  );
} catch (error) {
  if (transferring !== null) {
    this.previewPhotoPixelMap = transferring;
  }
  throw error;
}

若服务已接管后再抛错,页面恢复同一引用会造成双重所有权。因此更推荐服务保证“传入即接管,无论后续持久化成功与否都负责释放或保留”,并通过接口文档写清。

八、Map覆盖同一ID会泄漏旧资源

photoPixelMaps.set(savedPhoto.id, pixelMap);

若相同 ID 重复保存,新 PixelMap 会覆盖旧值,但 Map 不会自动 release 旧对象。写入前应替换释放:

private static async replacePixelMap(
  photoId: string,
  next: image.PixelMap
): Promise<void> {
  const previous = PhotoAlbumService.photoPixelMaps.get(photoId);
  if (previous !== undefined && previous !== next) {
    try {
      await previous.release();
    } catch (_) {
    }
  }
  PhotoAlbumService.photoPixelMaps.set(photoId, next);
}

即使正常 ID 很少重复,恢复、重试或测试都可能触发覆盖,资源容器应自行防护。

九、slice淘汰元数据但没有淘汰PixelMap

当前代码:

PhotoAlbumService.cachedPhotos =
  nextPhotos.slice(0, 60);

第61张照片保存后,最旧元数据从数组消失,但对应 PixelMap 仍留在 Map。这是隐蔽内存泄漏:UI看不到它,也没有 photoId 入口触发删除。

保存前后应计算被淘汰 ID:

const previousIds: Set<string> = new Set(
  PhotoAlbumService.cachedPhotos.map(photo => photo.id)
);
const keptPhotos = nextPhotos.slice(0, 60);
const keptIds: Set<string> = new Set(
  keptPhotos.map(photo => photo.id)
);

for (const id of previousIds) {
  if (!keptIds.has(id)) {
    await PhotoAlbumService.releaseCachedPixelMap(id);
  }
}
PhotoAlbumService.cachedPhotos = keptPhotos;

容量淘汰必须同时作用于元数据和资源缓存。

十、60张原图仍可能占用大量内存

假设每张解码后为 3000×4000 RGBA,理论像素内存约:

3000 × 4000 × 4 = 48,000,000 bytes

60张接近数GB,显然不能全部作为 PixelMap 常驻。元数据上限不能直接当图像缓存上限。

应独立设置缓存预算,例如最多 6 张缩略图或 64MB:

interface PixelMapCacheEntry {
  photoId: string;
  pixelMap: image.PixelMap;
  estimatedBytes: number;
  lastAccessAt: number;
}

缓存按内存预算与访问时间淘汰,而不是和 Preferences 记录数绑定。

十一、相册应缓存缩略图而不是原始大图

Grid 卡片只有约 160×190 vp,没有必要长期持有完整拍照 PixelMap。保存时可生成缩略图:

拍照PixelMap
-> 结果页展示原图
-> 保存媒体文件/资产
-> 生成小尺寸缩略图PixelMap
-> 相册Map缓存缩略图
-> 释放原始PixelMap

详情页需要高清图时再从持久化 URI 解码。这样列表滚动内存更稳定,进程重启后也能重新生成缓存。

十二、当前缓存无法跨进程恢复

Preferences 只保存 CapturedPhotophotoPixelMaps 在进程重启后为空。因此重启应用会看到照片元数据,但没有真实图像。

这是当前方案必须公开的边界:

状态 元数据 PixelMap
保存后同一进程
页面切换
Ability重建但进程未杀 视静态对象而定 可能有
进程重启 从Preferences恢复
清除应用数据

要成为真正本地相册,必须把图像编码到应用文件或媒体资产中,并在元数据中保存 URI。

十三、持久化模型建议

interface CapturedPhotoAsset {
  mediaUri: string;
  thumbnailUri?: string;
  width: number;
  height: number;
  byteSize: number;
  format: 'jpeg' | 'png';
}

interface CapturedPhoto {
  id: string;
  asset?: CapturedPhotoAsset;
  // metadata fields
}

PixelMap 是运行时缓存,URI 才是跨进程事实来源。读取列表时先展示固定占位,再按需加载 thumbnailUri。

十四、实现LRU缓存

访问时更新时间:

static borrowPhotoPixelMap(photoId: string): image.PixelMap | undefined {
  const entry = PhotoAlbumService.cache.get(photoId);
  if (entry === undefined) {
    return undefined;
  }
  entry.lastAccessAt = Date.now();
  return entry.pixelMap;
}

超预算时从最久未使用条目开始释放:

while (totalBytes > MAX_CACHE_BYTES) {
  const victim = findLeastRecentlyUsedEntry();
  if (!victim) break;
  await victim.pixelMap.release();
  cache.delete(victim.photoId);
  totalBytes -= victim.estimatedBytes;
}

正在被页面展示的条目需要 pin 或引用计数,避免 Image 仍在渲染时被后台淘汰。

十五、借用资源需要作用域协议

简单返回 PixelMap 无法知道 UI 何时用完。更严格接口:

interface BorrowedPixelMap {
  pixelMap: image.PixelMap;
  releaseBorrow(): void;
}

缓存条目保存 borrowCount。详情打开时 +1,关闭时 -1;LRU只淘汰 borrowCount===0 的条目。这里的 releaseBorrow() 不调用 PixelMap.release,只释放借用计数,真正资源仍由缓存管理。

十六、页面退出与全量清理

服务应提供:

static async clearPixelMapCache(): Promise<void> {
  const entries = Array.from(
    PhotoAlbumService.photoPixelMaps.values());
  PhotoAlbumService.photoPixelMaps.clear();
  for (const pixelMap of entries) {
    try {
      await pixelMap.release();
    } catch (error) {
      // log sanitized error
    }
  }
}

何时调用取决于产品:进程常驻时可保留小缓存;Ability 后台可降低缓存预算;应用退出或内存压力时全量释放。不要在普通 Tab 切换时清空,否则相册来回切换会频繁解码。

十七、并发删除与展示

若详情正在显示某 PixelMap,用户同时删除照片,服务立即 release 可能让 Image 使用失效对象。页面删除前应先关闭详情,或缓存使用借用计数延迟释放:

标记pendingDelete
-> UI关闭详情并releaseBorrow
-> borrowCount归零
-> PixelMap.release
-> 删除Map条目和元数据

异步操作期间按钮应禁用,避免重复删除同一资源。

十八、错误处理与日志

释放失败日志只记录错误类型与 photoId 的安全短标识,不打印图片、文件URI或水印内容:

hilog.warn(DOMAIN, TAG,
  'release cached image failed id=%{public}s error=%{public}s',
  shortId(photoId),
  JSON.stringify(error));

缓存未命中是正常状态,不应每次都输出错误。只有解码失败、资源状态异常和预算无法收敛才需要告警。

十九、测试矩阵

场景 期望
保存带PixelMap照片 Map按ID新增一条
保存不带PixelMap照片 元数据存在,Map无条目
页面保存后关闭预览 不释放已转交资源
用户重拍 页面释放预览资源
删除照片 PixelMap只释放一次
重复删除 不重复release、不崩溃
相同ID替换 旧PixelMap先释放
保存第61张 被淘汰条目资源同步释放
超内存预算 LRU释放未借用条目
详情正在展示 不被LRU提前释放
进程重启 缓存为空,从URI按需恢复
flush失败 所有权不会悬空或双重释放

可以用 FakePixelMap 记录 releaseCount,对每个对象断言最终恰好一次。

二十、常见问题排查

现象 原因 修复方向
保存后相册仍是占位 PixelMap未转交或ID不一致 persistPhoto入参与Map Key
删除后内存不降 只删元数据未release deletePhoto资源分支
第61张后内存持续涨 slice未同步淘汰Map 计算evicted IDs
重启后照片图消失 仅内存缓存无URI 持久化媒体资产
详情偶发空白 展示期间被LRU释放 pin/borrowCount
保存失败后泄漏 转移中异常无接管协议 明确传入即接管

二十一、发布前验收清单

  • CapturedPhoto只保存可序列化元数据与资产URI。
  • 页面保存前把PixelMap所有权明确转给服务。
  • 借用接口声明调用者不得直接release。
  • 删除、替换、淘汰和全量清理都释放资源。
  • 元数据条数上限与图像内存预算分开。
  • 相册优先缓存缩略图,不常驻大量原图。
  • 第61条淘汰时同步清除资源条目。
  • 保存失败不会造成无人拥有或双重拥有。
  • 展示中的PixelMap通过pin/借用计数保护。
  • 进程重启可从媒体URI恢复缩略图。
  • 测试断言每个PixelMap最终只release一次。

总结

把 PixelMap 放进 Map 只解决了“同一进程内按 ID 找到图像”,并没有自动形成可靠缓存。真正的工程闭环需要明确所有权:拍照预览最初拥有资源,保存时转交相册服务,UI只借用,删除、替换和淘汰统一由缓存释放。尤其要修复 slice(0,60) 只淘汰元数据、不淘汰 PixelMap 的隐蔽泄漏。

进一步把原图保存为媒体资产、Map只缓存缩略图,并用内存预算、LRU与借用计数管理运行时资源,才能同时解决列表显示、内存峰值、页面并发和进程重启问题。PixelMap不是普通对象,所有权协议比缓存容器本身更重要。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐