《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.tsimport{AbilityConstant,UIAbility,Want,Permissions}from'@kit.AbilityKit';import{abilityAccessCtrl,common}from'@kit.AbilityKit';import{businessError}from'@kit.BasicServicesKit';constPERMISSION_LIST:Array<Permissions>=['ohos.permission.READ_MEDIA','ohos.permission.WRITE_MEDIA'];exportdefaultclassEntryAbilityextendsUIAbility{onCreate(want:Want,launchParam:AbilityConstant.LaunchParam):void{// 这里不能直接申请权限,onCreate阶段UI还没准备好}onWindowStageCreate(windowStage):void{// 入口:请求权限constcontext=this.context;constbundleName=this.context.abilityInfo.applicationInfo.bundleName;constatManager=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.tsimport{assetManagerasmediaAssetManager,AssetManager,AlbumManager}from'@kit.MediaLibraryKit';import{common}from'@kit.AbilityKit';import{image}from'@kit.ImageKit';exportclassMediaManager{privatestaticinstance:MediaManager;privateassetManager:AssetManager|null=null;privatealbumManager:AlbumManager|null=null;staticgetInstance():MediaManager{if(!MediaManager.instance){MediaManager.instance=newMediaManager();}returnMediaManager.instance;}asyncinit(context:common.Context){// 获取AssetManager实例this.assetManager=newAssetManager(context);this.albumManager=newAlbumManager(context);// 这一步很多人会忽略:必须先调用release,否则Manager内部状态可能混乱awaitthis.assetManager?.release();awaitthis.assetManager?.init();awaitthis.albumManager?.release();awaitthis.albumManager?.init();}asyncgetAllAlbums():Promise<Album[]>{if(!this.albumManager)thrownewError('AlbumManager 未初始化');// 查询所有相册constalbums=awaitthis.albumManager?.getAlbums();// 注意:getAlbums返回的是Album对象数组,但每个Album里的资源需要单独查询returnalbums??[];}asyncgetAssetsInAlbum(album:Album):Promise<Asset[]>{if(!this.assetManager)thrownewError('AssetManager 未初始化');// 关键:通过Album的URI构建查询条件constfetchOptions:AssetManager.FetchOptions={selections:[],uri:album.uri// 这里限制只查询该相册下的资源};constassets=awaitthis.assetManager?.getAssets(fetchOptions);returnassets??[];}}为什么这里要这么写?很多人会直接用assetManager.getAssets({ selections: [] })获取所有图片,然后前端过滤相册。这在图片数量少的时候没问题,但一旦超过 1000 张,内存占用和性能都会爆炸。通过相册 URI 过滤,后端就能把数据量降下来。
3. 创建新相册
这个 API 比较直观,但有个细节:名称不能为空,且不能与已有相册重名。
// src/main/ets/model/MediaManager.tsexportclassMediaManager{// ... 前面代码略asynccreateAlbum(name:string):Promise<Album>{if(!this.albumManager)thrownewError('AlbumManager 未初始化');// 检查名称有效性if(!name||name.trim().length===0){thrownewError('相册名称不能为空');}try{constalbum=awaitthis.albumManager?.createAlbum(name);console.info(`相册创建成功:${name}, uri:${album.uri}`);returnalbum;}catch(err){console.error(`创建相册失败:${JSON.stringify(err)}`);throwerr;// 交给上层处理}}asyncdeleteAlbum(album:Album):Promise<void>{if(!this.albumManager)thrownewError('AlbumManager 未初始化');// 注意:删除相册不会删除里面的文件,文件会回到根目录awaitthis.albumManager?.deleteAlbum(album.uri);console.info(`相册删除成功:${album.uri}`);}}4. 删除图片
删除图片同样通过AssetManager完成。这里有一个常见的坑:删除后需要手动刷新 UI,因为删除操作不是同步的。
// src/main/ets/model/MediaManager.tsexportclassMediaManager{// ... 前面代码略asyncdeleteAsset(asset:Asset):Promise<void>{if(!this.assetManager)thrownewError('AssetManager 未初始化');try{awaitthis.assetManager?.deleteAsset(asset.uri);console.info(`删除成功:${asset.uri}`);}catch(err){console.error(`删除失败:${JSON.stringify(err)}`);throwerr;}}}5. UI 组件(核心页面)
这里用 ArkUI 写一个简单的网格相册界面。重点在于状态管理和数据刷新。
// src/main/ets/pages/AlbumListPage.etsimport{MediaManager}from'../model/MediaManager';import{Album,Asset}from'@kit.MediaLibraryKit';@Entry@Componentstruct AlbumListPage{privatemediaManager:MediaManager=MediaManager.getInstance();@Statealbums:Album[]=[];@StatealbumAssets:Map<string,Asset[]>=newMap();@StateselectedAlbum:Album|null=null;@StateisShowingGrid:boolean=false;aboutToAppear(){this.loadAlbums();}asyncloadAlbums(){try{constcontext=getContext(this);awaitthis.mediaManager.init(contextascommon.Context);constalbumList=awaitthis.mediaManager.getAllAlbums();this.albums=albumList;// 预加载每个相册的缩略图(只取前1张)for(constalbumofalbumList){constassets=awaitthis.mediaManager.getAssetsInAlbum(album);this.albumAssets.set(album.uri,assets.slice(0,1));}}catch(err){console.error(`加载相册失败:${JSON.stringify(err)}`);}}asynconDeleteAlbum(index:number){constalbum=this.albums[index];if(!album)return;try{awaitthis.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@Componentstruct AlbumGridPage{@Propalbum:Album;privatemediaManager:MediaManager=MediaManager.getInstance();@Stateassets:Asset[]=[];@Statecallback:()=>void=()=>{};aboutToAppear(){this.loadAssets();}asyncloadAssets(){try{constassets=awaitthis.mediaManager.getAssetsInAlbum(this.album);this.assets=assets;}catch(err){console.error(`加载相册内资源失败:${JSON.stringify(err)}`);}}asynconDeleteAsset(index:number){constasset=this.assets[index];if(!asset)return;try{awaitthis.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()。
// 更安全的初始化asyncsafeInit(context:common.Context){// 先检查权限constpermissionStatus=awaitcheckPermission(context);if(!permissionStatus){console.warn('权限未完全授予,跳过初始化');returnfalse;}awaitthis.init(context);returntrue;}常见问题 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:可能是用户手动在系统设置中关闭了权限。在入口处增加权限检查,如果权限被撤销,及时引导用户去设置中开启,而不是静默失败。
