在这里插入图片描述

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.json5requestPermissions 字段中声明,否则动态申请会直接报错。
  • WRITE_MEDIA 权限在 API 10 之后已经包含了 READ_MEDIA 的能力,但建议两个都声明,避免老版本兼容问题。

2. 获取相册列表和资源

核心接口是 AssetManagerAlbumManager。很多人喜欢先拿所有资源再按相册分类,但这样做性能极差。正确的做法是直接查询相册对象。

// 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 的一个设计问题。AssetManagerAlbumManagerinit() 方法内部会检查权限。如果权限是在 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],这是最稳妥的方式。

最佳实践

  1. 不要在 build() 中创建 Manager 实例。 Manager 的 init() 是异步操作,build() 函数同步执行,会导致 init() 无法完成。推荐在 aboutToAppear() 中统一初始化。

  2. 使用 @Observed@ObjectLink 管理复杂状态。 如果相册列表和图片列表涉及跨组件共享,建议将 MediaManager 设计为单例,并通过 @Observed 装饰状态对象,这样任意地方修改都会自动触发 UI 重建。

  3. 批量删除时,控制并发数。 删除操作本质是异步 IO,如果一次性并发删除 100 张图片,可能会触发系统的 Too Many Requests 错误。推荐使用 for...of 循环串行删除,或者封装一个 batchDelete 方法,每 10 张一组。

  4. 资源查询时,合理设置 FetchOptionsoffsetlimit 默认不做分页,如果相册里有 10000 张图片,前端直接展示会卡死。务必在 getAssets() 时传入 offsetlimit 做分页加载。

FAQ

Q:为什么真机正常,模拟器不生效?
A:模拟器中的媒体库机制与真机不完全一致,特别是在相册创建和删除操作上。建议所有与媒体库相关的功能以真机为准。

Q:为什么页面返回后状态丢失?
A:您的页面没有做状态持久化。Media Library Kit 的查询结果是临时数据,页面销毁后需要重新查询。建议在 aboutToAppear() 中重新加载数据,或使用 @StorageLink 将状态缓存到 AppStorage。

Q:为什么第一次授权成功,第二次失败?
A:可能是用户手动在系统设置中关闭了权限。在入口处增加权限检查,如果权限被撤销,及时引导用户去设置中开启,而不是静默失败。

Logo

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

更多推荐