相册开发速查
明白,下面只保留“手机本地相册 / 已下载相册”相关内容,去掉相机相册、云相册和最近删除。
本地相册开发使用速查
这里的本地相册指手机本地保存的图片和视频,项目主要通过:
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
浙公网安备 33010602011771号