在这里插入图片描述

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],这是最稳妥的方式。

最佳实践

  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. 资源查询时,合理设置 FetchOptions 的 offset 和 limit。 默认不做分页,如果相册里有 10000 张图片,前端直接展示会卡死。务必在 getAssets() 时传入 offset 和 limit 做分页加载。

FAQ

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

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

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

Logo

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

更多推荐