《HarmonyOS技术精讲-Media Library Kit》之实战:构建简易相册应用

HarmonyOS技术精讲-Media Library Kit 之实战:构建简易相册应用
HarmonyOS 开发中,Media Library Kit(媒体文件管理服务)是一个绕不开的核心能力。很多人在刚开始接触时,会被其复杂的权限模型和异步查询机制劝退。官方示例虽然能跑,但一旦涉及到“自己创建相册”、“往相册里添加图片”、“删除图片”这种组合操作,状态同步和生命周期管理的坑就全暴露出来了。
这篇文章的目标很直接:带你手写一个简易相册应用,能看照片、能建相册、能删照片。全程不废话,代码完整,所有踩过的坑我都会标注出来。
它解决什么问题
Media Library Kit 是用来干什么的?一句话:它统一了设备上媒体文件(图片、视频、音频)的访问和管理。开发者不需要关心文件实际存在哪个目录,只需要通过一套标准 API 进行查询、创建、修改和删除。
适合场景:
- 自定义相册/图库应用
- 需要管理大量媒体资源的社交或内容创作应用
- 后台扫描、整理媒体文件的服务类应用
不适合场景:
- 只需要读取少量图片(建议直接用
Image组件加载相对路径) - 不需要文件级别的 CRUD 操作(简单展示用
PhotoAccessHelper就够了)
为什么用 Media Library Kit 而不是直接操作文件系统? 因为 HarmonyOS 对应用自有目录以外的文件访问有严格限制。直接 fs.open() 去读系统相册目录,大概率会失败。Media Library Kit 是官方推荐且唯一稳定的途径。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 / 平板
核心实现
1. 权限声明与申请
这是第一个坑。很多人直接在 module.json5 里声明了权限,但没有动态申请,结果怎么都拿不到数据。
// src/main/ets/entryability/EntryAbility.ts
import { AbilityConstant, UIAbility, Want, Permissions } from '@kit.AbilityKit';
import { abilityAccessCtrl, common } from '@kit.AbilityKit';
import { businessError } from '@kit.BasicServicesKit';
const PERMISSION_LIST: Array<Permissions> = [
'ohos.permission.READ_MEDIA',
'ohos.permission.WRITE_MEDIA'
];
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 这里不能直接申请权限,onCreate阶段UI还没准备好
}
onWindowStageCreate(windowStage): void {
// 入口:请求权限
const context = this.context;
const bundleName = this.context.abilityInfo.applicationInfo.bundleName;
const atManager = abilityAccessCtrl.createAtManager();
try {
atManager.requestPermissionsFromUser(
context,
PERMISSION_LIST
).then((data) => {
console.info('权限授权结果:', JSON.stringify(data.authResults));
// 如果全部授权,才进入应用主界面
}).catch((err: businessError.BusinessError) => {
console.error(`权限请求失败: ${err.message}`);
});
} catch (err) {
console.error(`权限请求异常: ${JSON.stringify(err)}`);
}
}
}
注意事项:
- 权限必须在
module.json5的requestPermissions字段中声明,否则动态申请会直接报错。 WRITE_MEDIA权限在 API 10 之后已经包含了READ_MEDIA的能力,但建议两个都声明,避免老版本兼容问题。
2. 获取相册列表和资源
核心接口是 AssetManager 和 AlbumManager。很多人喜欢先拿所有资源再按相册分类,但这样做性能极差。正确的做法是直接查询相册对象。
// src/main/ets/model/MediaManager.ts
import { assetManager as mediaAssetManager, AssetManager, AlbumManager } from '@kit.MediaLibraryKit';
import { common } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
export class MediaManager {
private static instance: MediaManager;
private assetManager: AssetManager | null = null;
private albumManager: AlbumManager | null = null;
static getInstance(): MediaManager {
if (!MediaManager.instance) {
MediaManager.instance = new MediaManager();
}
return MediaManager.instance;
}
async init(context: common.Context) {
// 获取AssetManager实例
this.assetManager = new AssetManager(context);
this.albumManager = new AlbumManager(context);
// 这一步很多人会忽略:必须先调用release,否则Manager内部状态可能混乱
await this.assetManager?.release();
await this.assetManager?.init();
await this.albumManager?.release();
await this.albumManager?.init();
}
async getAllAlbums(): Promise<Album[]> {
if (!this.albumManager) throw new Error('AlbumManager 未初始化');
// 查询所有相册
const albums = await this.albumManager?.getAlbums();
// 注意:getAlbums返回的是Album对象数组,但每个Album里的资源需要单独查询
return albums ?? [];
}
async getAssetsInAlbum(album: Album): Promise<Asset[]> {
if (!this.assetManager) throw new Error('AssetManager 未初始化');
// 关键:通过Album的URI构建查询条件
const fetchOptions: AssetManager.FetchOptions = {
selections: [],
uri: album.uri // 这里限制只查询该相册下的资源
};
const assets = await this.assetManager?.getAssets(fetchOptions);
return assets ?? [];
}
}
为什么这里要这么写? 很多人会直接用 assetManager.getAssets({ selections: [] }) 获取所有图片,然后前端过滤相册。这在图片数量少的时候没问题,但一旦超过 1000 张,内存占用和性能都会爆炸。通过相册 URI 过滤,后端就能把数据量降下来。
3. 创建新相册
这个 API 比较直观,但有个细节:名称不能为空,且不能与已有相册重名。
// src/main/ets/model/MediaManager.ts
export class MediaManager {
// ... 前面代码略
async createAlbum(name: string): Promise<Album> {
if (!this.albumManager) throw new Error('AlbumManager 未初始化');
// 检查名称有效性
if (!name || name.trim().length === 0) {
throw new Error('相册名称不能为空');
}
try {
const album = await this.albumManager?.createAlbum(name);
console.info(`相册创建成功: ${name}, uri: ${album.uri}`);
return album;
} catch (err) {
console.error(`创建相册失败: ${JSON.stringify(err)}`);
throw err; // 交给上层处理
}
}
async deleteAlbum(album: Album): Promise<void> {
if (!this.albumManager) throw new Error('AlbumManager 未初始化');
// 注意:删除相册不会删除里面的文件,文件会回到根目录
await this.albumManager?.deleteAlbum(album.uri);
console.info(`相册删除成功: ${album.uri}`);
}
}
4. 删除图片
删除图片同样通过 AssetManager 完成。这里有一个常见的坑:删除后需要手动刷新 UI,因为删除操作不是同步的。
// src/main/ets/model/MediaManager.ts
export class MediaManager {
// ... 前面代码略
async deleteAsset(asset: Asset): Promise<void> {
if (!this.assetManager) throw new Error('AssetManager 未初始化');
try {
await this.assetManager?.deleteAsset(asset.uri);
console.info(`删除成功: ${asset.uri}`);
} catch (err) {
console.error(`删除失败: ${JSON.stringify(err)}`);
throw err;
}
}
}
5. UI 组件(核心页面)
这里用 ArkUI 写一个简单的网格相册界面。重点在于状态管理和数据刷新。
// src/main/ets/pages/AlbumListPage.ets
import { MediaManager } from '../model/MediaManager';
import { Album, Asset } from '@kit.MediaLibraryKit';
@Entry
@Component
struct AlbumListPage {
private mediaManager: MediaManager = MediaManager.getInstance();
@State albums: Album[] = [];
@State albumAssets: Map<string, Asset[]> = new Map();
@State selectedAlbum: Album | null = null;
@State isShowingGrid: boolean = false;
aboutToAppear() {
this.loadAlbums();
}
async loadAlbums() {
try {
const context = getContext(this);
await this.mediaManager.init(context as common.Context);
const albumList = await this.mediaManager.getAllAlbums();
this.albums = albumList;
// 预加载每个相册的缩略图(只取前1张)
for (const album of albumList) {
const assets = await this.mediaManager.getAssetsInAlbum(album);
this.albumAssets.set(album.uri, assets.slice(0, 1));
}
} catch (err) {
console.error(`加载相册失败: ${JSON.stringify(err)}`);
}
}
async onDeleteAlbum(index: number) {
const album = this.albums[index];
if (!album) return;
try {
await this.mediaManager.deleteAlbum(album);
// 手动从本地状态中移除
this.albums.splice(index, 1);
this.albumAssets.delete(album.uri);
// 强制刷新
this.albums = [...this.albums];
} catch (err) {
console.error(`删除相册失败: ${JSON.stringify(err)}`);
}
}
build() {
Column() {
if (!this.isShowingGrid) {
// 相册列表模式
List() {
ForEach(this.albums, (album: Album, index: number) => {
ListItem() {
Row() {
// 缩略图占位
Image(this.albumAssets.get(album.uri)?.[0]?.uri ?? '')
.width(60).height(60).borderRadius(8)
Text(album.displayName)
.fontSize(16)
.margin({ left: 12 })
Blank()
Button('删除')
.onClick(() => this.onDeleteAlbum(index))
.backgroundColor(Color.Red)
}
.padding(10)
.onClick(() => {
this.selectedAlbum = album;
this.isShowingGrid = true;
})
}
})
}
} else {
// 图片网格模式
AlbumGridPage({
album: this.selectedAlbum!,
onBack: () => { this.isShowingGrid = false; }
})
}
}
.width('100%')
.height('100%')
}
}
// src/main/ets/pages/AlbumGridPage.ets
@Component
struct AlbumGridPage {
@Prop album: Album;
private mediaManager: MediaManager = MediaManager.getInstance();
@State assets: Asset[] = [];
@State callback: () => void = () => {};
aboutToAppear() {
this.loadAssets();
}
async loadAssets() {
try {
const assets = await this.mediaManager.getAssetsInAlbum(this.album);
this.assets = assets;
} catch (err) {
console.error(`加载相册内资源失败: ${JSON.stringify(err)}`);
}
}
async onDeleteAsset(index: number) {
const asset = this.assets[index];
if (!asset) return;
try {
await this.mediaManager.deleteAsset(asset);
// 手动从本地数组移除并触发刷新
this.assets.splice(index, 1);
this.assets = [...this.assets];
// 通知父页面刷新
if (this.callback) {
this.callback();
}
} catch (err) {
console.error(`删除图片失败: ${JSON.stringify(err)}`);
}
}
build() {
Column() {
Row() {
Button('返回')
.onClick(() => this.callback())
Text(this.album.displayName)
.fontSize(18)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.padding(10)
Grid() {
ForEach(this.assets, (asset: Asset, index: number) => {
GridItem() {
Stack() {
Image(asset.uri)
.width('100%')
.height(100)
.objectFit(ImageFit.Cover)
Button('X')
.width(30).height(30)
.position({ top: 0, right: 0 })
.onClick(() => this.onDeleteAsset(index))
}
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(5)
.rowsGap(5)
}
.width('100%')
.height('100%')
}
}
常见问题 1:权限授权后,API 返回空结果
现象: 明明已经在 module.json5 声明了权限,动态请求也返回了“授权成功”,但调用 getAllAlbums() 时返回空数组。
原因: 这是 HarmonyOS 的一个设计问题。AssetManager 和 AlbumManager 的 init() 方法内部会检查权限。如果权限是在 init() 之后才被授予,或者 init() 时权限尚未完全生效,Manager 内部状态就会进入一个“无权限”的模式,后续所有查询都返回空。
解决方案: 在初始化 Manager 之前,先调用 abilityAccessCtrl.checkAccessToken() 确认权限确实生效。或者采用更稳妥的方式:在 aboutToAppear() 之后再进行一次 init()。
// 更安全的初始化
async safeInit(context: common.Context) {
// 先检查权限
const permissionStatus = await checkPermission(context);
if (!permissionStatus) {
console.warn('权限未完全授予,跳过初始化');
return false;
}
await this.init(context);
return true;
}
常见问题 2:删除图片后,UI 没有更新
现象: 删除了图片,assets 数组也做了 splice 操作,但网格视图还是显示原来的图片。
原因: ArkUI 的 @State 变更检测是基于引用变化的。如果直接修改数组(splice),引用没变,UI 不会认为状态有变化。
解决方案: 修改数组后,一定要创建新的数组引用。推荐用 this.assets = [...this.assets] 或者 this.assets = this.assets.slice() 来触发变更检测。上面的代码已经用了 this.assets = [...this.assets],这是最稳妥的方式。
最佳实践
-
不要在 build() 中创建 Manager 实例。 Manager 的
init()是异步操作,build()函数同步执行,会导致init()无法完成。推荐在aboutToAppear()中统一初始化。 -
使用
@Observed和@ObjectLink管理复杂状态。 如果相册列表和图片列表涉及跨组件共享,建议将 MediaManager 设计为单例,并通过@Observed装饰状态对象,这样任意地方修改都会自动触发 UI 重建。 -
批量删除时,控制并发数。 删除操作本质是异步 IO,如果一次性并发删除 100 张图片,可能会触发系统的
Too Many Requests错误。推荐使用for...of循环串行删除,或者封装一个batchDelete方法,每 10 张一组。 -
资源查询时,合理设置
FetchOptions的offset和limit。 默认不做分页,如果相册里有 10000 张图片,前端直接展示会卡死。务必在getAssets()时传入offset和limit做分页加载。
FAQ
Q:为什么真机正常,模拟器不生效?
A:模拟器中的媒体库机制与真机不完全一致,特别是在相册创建和删除操作上。建议所有与媒体库相关的功能以真机为准。
Q:为什么页面返回后状态丢失?
A:您的页面没有做状态持久化。Media Library Kit 的查询结果是临时数据,页面销毁后需要重新查询。建议在 aboutToAppear() 中重新加载数据,或使用 @StorageLink 将状态缓存到 AppStorage。
Q:为什么第一次授权成功,第二次失败?
A:可能是用户手动在系统设置中关闭了权限。在入口处增加权限检查,如果权限被撤销,及时引导用户去设置中开启,而不是静默失败。
更多推荐

所有评论(0)