相册开发速查

明白,下面只保留“手机本地相册 / 已下载相册”相关内容,去掉相机相册、云相册和最近删除。

本地相册开发使用速查

这里的本地相册指手机本地保存的图片和视频,项目主要通过:

UserAlbumUtils      -> 查询本地媒体文件
photoAccessHelper   -> 删除系统媒体
RDBStoreMediaUtil   -> 保存下载记录和查询本地数据库
Repeat              -> 渲染文件列表

1. 本地相册最容易出问题的地方

本地相册列表建议只维护一份源数据:

@Trace mediaFileList: FileViewModel[] = [];
@Trace fileGroupList: FileGroupViewModel[] = [];
@Trace selectedList: FileViewModel[] = [];

分组和非分组都基于同一批 FileViewModel:

private getFlatFileList(): FileViewModel[] {
  const result: FileViewModel[] = [];

  this.fileGroupList.forEach((group) => {
    group.fileList.forEach((file) => {
      result.push(file);
    });
  });

  return result;
}

不要分别创建:

groupedList
normalList

否则切换显示模式后可能出现:

文件选中状态丢失
选中数量不准确
删除后另一个列表还显示旧文件

2. 本地相册权限

读取本地图片和视频前先请求权限:

const permissions: string[] = [
  'ohos.permission.READ_IMAGEVIDEO',
  'ohos.permission.WRITE_IMAGEVIDEO'
];

const granted =
  await PermissionUtil.requestPermissionsEasy(permissions);

if (!granted) {
  ToastUtil.showToast(
    '没有相册权限,请到系统设置开启'
  );
  return;
}

await this.loadMediaFileList();

不要在权限结果返回前查询:

// 不建议
PermissionUtil.requestPermissionsEasy(permissions);
this.loadMediaFileList();

权限含义:

READ_IMAGEVIDEO  -> 读取图片和视频
WRITE_IMAGEVIDEO -> 写入、删除或修改图片和视频

权限失败时不要直接显示“没有文件”,应该区分:

没有权限
权限已授予但相册为空
读取相册失败

3. 查询本地相册

项目当前可以通过工具类查询本地 SJCAM 文件:

async loadMediaFileList(): Promise<void> {
  this.mediaFileList =
    await UserAlbumUtils.getAllAlbumSjcamFile() ?? [];

  this.mediaFileList.sort((a, b) => {
    return b.timeCode - a.timeCode;
  });

  this.processFileGroupList();
}

如果要查询本地数据库中的下载记录:

const files =
  await rdbStoreMediaUtil.queryAllMedias();

this.mediaFileList = files;

本地文件加载后建议统一排序:

this.mediaFileList.sort((a, b) => {
  return b.timeCode - a.timeCode;
});

这样最新文件会显示在前面。

4. 按日期分组

private processFileGroupList(): void {
  const groupMap =
    new Map<number, FileViewModel[]>();

  this.mediaFileList.forEach((file) => {
    const key = file.dateCode;

    if (!groupMap.has(key)) {
      groupMap.set(key, []);
    }

    groupMap.get(key)!.push(file);
  });

  const groups: FileGroupViewModel[] = [];

  groupMap.forEach((files, dateCode) => {
    const group = new FileGroupViewModel();

    group.dateCode = dateCode;
    group.dateStr = files[0]?.dateStr ?? '';
    group.fileList = files;

    groups.push(group);
  });

  groups.sort((a, b) => {
    return b.dateCode - a.dateCode;
  });

  this.fileGroupList = groups;
}

5. 分组和非分组显示

分组显示

Repeat<FileGroupViewModel>(this.fileGroupList)
  .key((group) => group.dateCode.toString())
  .each((group: RepeatItem<FileGroupViewModel>) => {
    ListItemGroup({
      header: this.itemHead(group)
    }) {
      Repeat<FileViewModel>(group.item.fileList)
        .key((file) => {
          return file.fileId ?? file.fileName ?? '';
        })
        .each((item: RepeatItem<FileViewModel>) => {
          this.fileItem(item);
        });
    }
  });

非分组显示

Repeat<FileViewModel>(this.getFlatFileList())
  .key((file) => {
    return file.fileId ?? file.fileName ?? '';
  })
  .each((item: RepeatItem<FileViewModel>) => {
    this.fileItem(item);
  });

外层尽量使用同一个 List 和 Scroller:

private fileScroller: Scroller = new Scroller();

List({ scroller: this.fileScroller }) {
  if (this.isShowGroupHeader) {
    this.buildGroupList();
  } else {
    this.buildNormalList();
  }
}

这样切换分组时更容易保留当前滚动位置。

6. Repeat、ForEach、LazyForEach 怎么选

Repeat

当前本地相册大列表推荐:

Repeat<FileViewModel>(this.mediaFileList)
  .key((file) => {
    return file.fileId ?? file.fileName ?? '';
  })
  .each((item: RepeatItem<FileViewModel>) => {
    this.fileItem(item);
  })
  .virtualScroll({
    reusable: true
  });

适合:

几百张以上图片或视频
四列网格
需要组件复用
需要虚拟滚动

注意:

key 必须稳定唯一
不要在 build 中重新排序
不要滚动时频繁替换整个数组

ForEach

ForEach(
  this.mediaFileList,
  (file: FileViewModel) => {
    this.fileItem(file);
  },
  (file: FileViewModel) => {
    return file.fileId ?? file.fileName ?? '';
  }
);

适合:

少量文件
Tab
设置项
按钮列表

优点是简单,缺点是数据多时可能一次创建全部组件。

LazyForEach

LazyForEach(
  this.fileDataSource,
  (file: FileViewModel) => {
    this.fileItem(file);
  },
  (file: FileViewModel) => {
    return file.fileId ?? file.fileName ?? '';
  }
);

适合:

大数据量
分页加载
需要自己维护数据源通知

简单选择:

小列表       -> ForEach
当前本地相册 -> Repeat + virtualScroll
复杂分页     -> LazyForEach

7. 单独选择和取消选择

点击文件时要同步修改文件对象和选择数组:

private onFileSelectionChange(
  file: FileViewModel,
  selected: boolean
): void {
  file.isSelect = selected;

  const key = file.fileId ?? file.fileName;

  const index = this.selectedList.findIndex((item) => {
    return (item.fileId ?? item.fileName) === key;
  });

  if (selected && index === -1) {
    this.selectedList.push(file);
  }

  if (!selected && index !== -1) {
    this.selectedList.splice(index, 1);
  }

  this.syncSelectionState();
}

不要只修改:

file.isSelect = !file.isSelect;

否则会出现:

界面显示已选中
selectedList 数量没变化
删除或分享时获取不到文件

8. 使用 Set 同步选中状态

错误写法:

this.mediaFileList.forEach((file) => {
  file.isSelect = this.selectedList.find(
    (item) => item.fileId === file.fileId
  ) !== undefined;
});

文件多时会反复遍历 selectedList。

推荐:

private syncSelectionState(): void {
  const selectedIds = new Set<string>();

  this.selectedList.forEach((file) => {
    const key = file.fileId ?? file.fileName;

    if (key) {
      selectedIds.add(key);
    }
  });

  this.fileGroupList.forEach((group) => {
    let selectedCount = 0;

    group.fileList.forEach((file) => {
      const key = file.fileId ?? file.fileName;

      file.isSelect =
        !!key && selectedIds.has(key);

      if (file.isSelect) {
        selectedCount++;
      }
    });

    group.isSelectAll =
      group.fileList.length > 0 &&
      selectedCount === group.fileList.length;
  });
}

复杂度:

find 写法   -> O(n * m)
Set 写法    -> O(n + m)

9. 全选和取消全选

private toggleSelectAll(value: boolean): void {
  this.mediaFileList.forEach((file) => {
    file.isSelect = value;
  });

  this.selectedList = value
    ? this.mediaFileList.slice()
    : [];

  this.processFileGroupList();
}

判断当前是否全部选中:

const isSelectAll =
  this.mediaFileList.length > 0 &&
  this.mediaFileList.every((file) => file.isSelect);

10. 使用 Map 做快速查找和去重

通过文件 ID 查找:

const fileMap =
  new Map<string, FileViewModel>();

this.mediaFileList.forEach((file) => {
  const key = file.fileId ?? file.fileName;

  if (key) {
    fileMap.set(key, file);
  }
});

const targetFile =
  fileMap.get(targetFileId);

分页或多次刷新时去重:

const fileMap =
  new Map<string, FileViewModel>();

this.mediaFileList.forEach((file) => {
  const key = file.fileId ?? file.fileName;

  if (key) {
    fileMap.set(key, file);
  }
});

newFiles.forEach((file) => {
  const key = file.fileId ?? file.fileName;

  if (key) {
    fileMap.set(key, file);
  }
});

this.mediaFileList =
  Array.from(fileMap.values());

11. 数据量大时的优化

不要一次加载不需要的文件

如果页面只显示图片:

const photoList =
  this.mediaFileList.filter((file) => {
    return file.type === MediaType.PHOTO;
  });

如果页面只显示视频:

const videoList =
  this.mediaFileList.filter((file) => {
    return file.type === MediaType.VIDEO;
  });

不要在 build 中重复计算

不建议:

Repeat(this.getFlatFileList())

同时在多个地方重复调用:

this.getFlatFileList().length
this.getFlatFileList()
this.getFlatFileList().filter(...)

可以缓存:

@Trace flatFileList: FileViewModel[] = [];

private rebuildFlatFileList(): void {
  const result: FileViewModel[] = [];

  this.fileGroupList.forEach((group) => {
    result.push(...group.fileList);
  });

  this.flatFileList = result;
}

数据更新后调用:

this.processFileGroupList();
this.rebuildFlatFileList();

本地数据很多时分页处理

private currentIndex: number = 0;
private readonly pageSize: number = 100;

loadNextPage(): FileViewModel[] {
  const nextList = this.mediaFileList.slice(
    this.currentIndex,
    this.currentIndex + this.pageSize
  );

  this.currentIndex += nextList.length;
  return nextList;
}

本地文件数量特别大时,不要一次性创建所有缩略图组件。

12. 删除本地媒体文件

系统媒体库删除:

const uriList = this.selectedList
  .map((file) => file.albumUri)
  .filter((uri): uri is string => !!uri);

if (uriList.length === 0) {
  ToastUtil.showToast('没有可删除的文件');
  return;
}

await photoAccessHelper.MediaAssetChangeRequest.deleteAssets(
  AppUtil.getContext(),
  uriList
);

删除成功后更新界面:

const selectedIds = new Set(
  this.selectedList.map((file) => file.fileId)
);

this.mediaFileList =
  this.mediaFileList.filter((file) => {
    return !selectedIds.has(file.fileId);
  });

this.selectedList = [];

this.processFileGroupList();

如果文件同时有数据库记录:

this.selectedList.forEach((file) => {
  if (file.fileId) {
    rdbStoreMediaUtil.deleteMedia(file.fileId);
  }
});

删除顺序建议:

1. 先请求系统删除
2. 删除成功后更新数据库
3. 更新页面列表
4. 清空 selectedList
5. 重新生成分组

失败时不要先从界面删除:

服务端或系统删除失败
  -> 保留原文件
  -> 提示删除失败

13. 保存下载文件到数据库

下载完成后:

await rdbStoreMediaUtil.insertMedia(file);

更新本地文件信息:

await rdbStoreMediaUtil.updateMedia(
  file.remoteUrl!,
  file.localUri!,
  file.dateCode,
  file.dateStr!,
  file.timeCode,
  file.type!,
  file.localThUri!
);

查询数据库:

const files =
  await rdbStoreMediaUtil.queryAllMedias();

删除数据库记录:

rdbStoreMediaUtil.deleteMedia(
  file.fileId!
);

数据库记录中常见字段:

FILE_ID
REMOTE_URL
LOCAL_URI
ALBUM_URI
DATE_CODE
DATE_STR
TIME_CODE
FILE_NAME
DURATION
TYPE
THUMBNAIL_URL

14. 图片加载失败重试

图片失败后,如果一直使用同一个加载参数,可能会一直显示失败占位图。

重试时修改 signature:

private retryThumbLoad(
  source: string
): void {
  if (
    this.thumbRetryCount >= 2 ||
    !source
  ) {
    return;
  }

  this.thumbRetryCount++;

  const option =
    this.buildThumbOption();

  option.signature =
    `thumb_retry_${this.thumbRetryCount}_${Date.now()}`;

  this.imageKnifeOption = option;
}

失败回调先判断当前文件:

onLoadFailed: () => {
  if (this.getThumbSource() !== source) {
    return;
  }

  this.retryThumbLoad(source);
}

列表复用时清理重试状态:

aboutToReuse(): void {
  this.clearThumbRetryTimer();
  this.thumbRetryCount = 0;
  this.initThumbOption();
}

15. 视频没有总时长时禁止拖动

Slider({
  value: currentTime,
  min: 0,
  max: totalDuration
})
.enabled(totalDuration > 0);

不要使用:

.enabled(currentTime > 0);

因为视频还没开始播放时,currentTime 可能是 0,但 totalDuration 已经有效。

16. 本地相册中的文件路径

localUri   -> 本地原视频或原图片路径
localThUri -> 本地缩略图路径
albumUri   -> 系统媒体库 URI
fileId     -> 数据库或媒体库文件 ID

加载本地图片:

Image(file.localThUri);

删除系统媒体:

file.albumUri;

删除数据库记录:

file.fileId;

不要混用:

// 不要拿 HTTP 地址转成本地 URI
fileUri.getUriFromPath(file.remoteUrl);

如果是 HTTP 地址:

Image(file.remoteUrl);

如果是本地路径:

Image(fileUri.getUriFromPath(file.localThUri));

17. 生命周期清理

aboutToDisappear(): void {
  if (this.loadTimer !== -1) {
    clearTimeout(this.loadTimer);
    this.loadTimer = -1;
  }

  if (this.retryTimer !== -1) {
    clearTimeout(this.retryTimer);
    this.retryTimer = -1;
  }

  this.selectedList = [];
}

不清理可能导致:

返回页面后重复加载
图片重复重试
旧页面继续更新数据
重复弹出提示

18. 本地相册问题排查顺序

1. 相册权限是否成功
2. 权限失败是否被当成空列表
3. 查询的是媒体库还是本地数据库
4. 分组和非分组是否同一批 FileViewModel
5. 单选时 selectedList 是否同步
6. fileId/key 是否唯一
7. 是否在 build 中重复分组和展平
8. 删除是否使用了正确的 albumUri
9. 删除后数据库和界面是否同步
10. ImageKnife 是否清理了旧文件的重试状态
11. 页面退出后定时器是否清理

19. 脱敏后的完整本地相册案例

@ObservedV2
export class LocalAlbumViewModel {
  @Trace mediaFileList: FileViewModel[] = [];
  @Trace selectedList: FileViewModel[] = [];
  @Trace fileGroupList: FileGroupViewModel[] = [];

  async loadAlbum(): Promise<void> {
    const permissions: string[] = [
      'ohos.permission.READ_IMAGEVIDEO',
      'ohos.permission.WRITE_IMAGEVIDEO'
    ];

    const granted =
      await PermissionUtil.requestPermissionsEasy(
        permissions
      );

    if (!granted) {
      ToastUtil.showToast(
        '没有相册权限,请到系统设置开启'
      );
      return;
    }

    this.mediaFileList =
      await UserAlbumUtils.getAllAlbumSjcamFile() ?? [];

    this.mediaFileList.sort((a, b) => {
      return b.timeCode - a.timeCode;
    });

    this.processFileGroupList();
  }

  toggleFile(
    file: FileViewModel,
    selected: boolean
  ): void {
    file.isSelect = selected;

    const key =
      file.fileId ?? file.fileName;

    const index =
      this.selectedList.findIndex((item) => {
        return (
          (item.fileId ?? item.fileName) === key
        );
      });

    if (selected && index === -1) {
      this.selectedList.push(file);
    }

    if (!selected && index !== -1) {
      this.selectedList.splice(index, 1);
    }
  }

  async deleteSelected(): Promise<void> {
    const uriList = this.selectedList
      .map((file) => file.albumUri)
      .filter((uri): uri is string => !!uri);

    if (uriList.length === 0) {
      ToastUtil.showToast('请选择文件');
      return;
    }

    await photoAccessHelper.MediaAssetChangeRequest
      .deleteAssets(
        AppUtil.getContext(),
        uriList
      );

    const selectedIds = new Set(
      this.selectedList.map((file) => file.fileId)
    );

    this.mediaFileList =
      this.mediaFileList.filter((file) => {
        return !selectedIds.has(file.fileId);
      });

    this.selectedList = [];
    this.processFileGroupList();
  }
}

隐私字段统一使用:

CURRENT_USER_ID
CAMERA_SSID
CAMERA_IP
CAMERA_SERIAL
LOCAL_FILE_PATH
LOCAL_THUMBNAIL_PATH
ALBUM_URI
posted @ 2026-09-01 17:29  带头大哥d小弟  阅读(10)  评论(0)    收藏  举报