目录

  1. 引言:超越“打开相册”——状态与URI的挑战

  2. 鸿蒙相册API与权限模型

  3. 架构设计:AlbumViewModel 的职责与状态

  4. 仓颉的异步之美:async/await 处理选择流

  5. 核心实战(一):单张图片选择与UI绑定

  6. 核心实战(二):多选与Array<T>的状态管理

  7. 【深度实践】URI的“临时陷阱”与持久化策略

  8. 总结:从API调用到健壮的组件


一、引言:超越“打开相册”——状态与URI的挑战

在鸿蒙应用开发中,“相册选择”是一个基础且高频的功能。表面上看,这只是一个“拉起系统界面 -> 获取图片”的简单调用。然而,一个真正健壮的相册功能,背后隐藏着一系列不容忽视的工程挑战:

  1. 异步流:拉起相册是一个异步操作。用户可能在1秒内完成选择,也可能在30秒后才取消。如何管理这个异步流,避免“回调地狱”?

  2. 状态管理:选择图片后,UI如何响应?加载中(Loading)状态、选择结果(Array<String>)、取消(Cancelled)状态、失败(Error)状态,如何与UI解耦?

  3. URI生命周期:相册返回的是一个 content:// URI,它通常只带有一个临时访问权限。如果App重启,这个URI可能就失效了。如何将这个临时资源持久化?

仓颉语言凭借其现代化的并发模型(async/await)、强大的类型系统和声明式的UI状态管理(@State, @Observable),为我们提供了一套优雅的解决方案。本文将从零开始,构建一个解耦、可测试、可复用的相册选择功能,重点展示仓颉在处理异步I/O和状态管理方面的架构优势。

二、鸿蒙相册API与权限模型

在仓颉中调用相册能力,我们首先需要与鸿蒙的底层API交互。

2.1 权限声明

首先2.1 权限声明

首先,必须在 module.json5 中声明读取媒体的权限:

{
    "module": {
        // ...
        "reqPermissions": [
            {
                // 读取媒体权限
                "name": "ohos.permission.READ_MEDIA"
            }
        ]
    }
}

2.2 相册API(假设)

鸿蒙提供了相册选择器(`PhotoViewPicker 或 filepicker)的能力。在仓颉中,我们期望这是一个返回 Promise 的现代化API。

// 假设的鸿蒙相册API封装
import { PhotoViewPicker, PickOptions, PhotoResult } from 'harmony.media.PhotoPicker'

// PhotoResult 可能的结构
struct PhotoResult {
    var uri: String       // 临时内容URI, e.g., "content://..."
    var mimeType: String
}

// 权限服务
import { PermissionService } from 'harmony.security.Permission'

class AlbumAPI {
    // 请求权限
    static func requestReadPermission(): Promise<Bool> {
        return PermissionService.request(["ohos.permission.READ_MEDIA"])
            .then { grants => grants.get("ohos.permission.READ_MEDIA") == PermissionStatus.Granted }
    }
    
    // 拉起单选
    static func pickSingle(): Promise<PhotoResult?> {
        let picker = PhotoViewPicker()
        return picker.pick(PickOptions(maxSelection: 1))
            .then { results => results.first() } // 返回第一个,或None
    }
    
    // 拉起多选
    static func pickMultiple(max: Int32 = 9): Promise<Array<PhotoResult>> {
        let picker = PhotoViewPicker()
        return picker.pick(PickOptions(maxSelection: max))
    }
}

三、架构设计:AlbumViewModel 的职责与状态

为了实现“高内聚、低耦合”,我们坚决反对在@Component(View层)中直接调用API和处理业务逻辑。我们构建一个 AlbumViewModel 来承担所有“脏活累活”。

@Observable
class AlbumViewModel {
    // --- 状态 (State) ---
    
    // 单选结果
    @State var selectedImage: PhotoResult? = None
    
    // 多选结果
    @State var selectedImages: Array<PhotoResult> = []
    
    // 加载状态
    @State var isLoading: Bool = false
    
    // 错误信息
    @State var errorMessage: String? = None
    
    // --- 逻辑 (Logic) ---
    
    // 检查权限
    private func checkPermission(): Promise<Bool> { ... }
    
    // 执行单选
    func selectSinglePhoto() { ... }
    
    // 执行多选
    func selectMultiplePhotos() { ... }
    
    // 清空选择
    func clearSelection() {
        this.selectedImage = None
        this.selectedImages.clear()
    }
}

这个 ViewModel 是一个纯粹的仓颉 class,它不依赖任何UI组件,具有极高的可测试性。`View 层只需要“订阅”它的状态并“调用”它的方法。

四、仓颉的异步之美:async/await 处理选择流

这是仓颉并发模型的核心优势。ViewModel 中的异步逻辑不再需要层层嵌套的 .then 回调,而是使用 async/await 编写“同步风格”的异步代码。

@Observable
class AlbumViewModel {
    // ...
    
    // 使用 Task 启动异步任务
    func selectSinglePhoto() {
        // 1. 立即更新UI状态
        this.isLoading = true
        this.errorMessage = None
        
        Task {
            try {
                // 2. 检查权限
                let hasPermission = await AlbumAPI.requestReadPermission()
                if (!hasPermission) {
                    throw Error("未授予相册读取权限")
                }
                
                // 3. 异步等待用户选择
                // (主线程不会阻塞)
                let result = await AlbumAPI.pickSingle()
                
                // 4. 更新状态
                this.selectedImage = result
                
            } catch (e: CancellationError) {
                // 4.1. 用户取消了操作
                Logger.info("用户取消了选择")
            } catch (e: Error) {
                // 4.2. 发生错误
                this.errorMessage = e.message
            } finally {
                // 5. 无论成功、失败还是取消,都结束加载
                this.isLoading = false
            }
        }
    }
}

try/catch/finally 的组合拳,完美地覆盖了成功失败取消三种异步场景,finally 确保了 isLoading 状态不会被“卡住”。

五、核心实战(一):单张图片选择与UI绑定

View 层,我们只负责声明UI如何响应 ViewModel 的状态。

@Component
struct ProfileAvatarUploader {
    // 1. 持有 ViewModel 实例
    @State var viewModel: AlbumViewModel = AlbumViewModel()
    
    func build() -> View {
        Column(spacing: 16.0) {
            
            // 2. 绑定单选结果
            if (let photo = this.viewModel.selectedImage) {
                Image(src: photo.uri)
                    .size(width: 120.0, height: 120.0)
                    .cornerRadius(60.0)
                    .objectFit(ObjectFit.Cover)
            } else {
                // 默认占位图
                Image(src: "resource:media.default_avatar")
                    .size(width: 120.0, height: 120.0)
            }
            
            // 3. 绑定错误状态
            if (let error = this.viewModel.errorMessage) {
                Text(error).color(Color.Red).fontSize(12.0)
            }
            
            // 4. 触发逻辑并绑定加载状态
            Button("更换头像") {
                this.viewModel.selectSinglePhoto()
            }
            .loading(this.viewModel.isLoading)
        }
    }
}

UI (ProfileAvatarUploader) 完全不知道API、权限和异步的存在。它只负责响应 viewModel 的状态,实现了彻底的职责分离。

六、核心实战(二):多选与Array<T>的状态管理

多选的挑战在于管理一个列表状态。得益于仓颉的响应式系统,当 ViewModel 中的 @State 数组发生变化时,UI会自动更新。

// --- In AlbumViewModel ---
func selectMultiplePhotos() {
    this.isLoading = true
    this.errorMessage = None
    
    Task {
        try {
            let hasPermission = await AlbumAPI.requestReadPermission()
            if (!hasPermission) { throw Error("未授予相册读取权限") }
            
            let results = await AlbumAPI.pickMultiple(9 - this.selectedImages.size())
            
            if (!results.isEmpty) {
                // 仓颉的响应式系统会检测到 Array 的变化
                // 并自动通知UI重渲染
                this.selectedImages.appendAll(results)
            }
        } catch (e: CancellationError) {
            Logger.info("用户取消了选择")
        } catch (e: Error) {
            this.errorMessage = e.message
        } finally {
            this.isLoading = false
        }
    }
}

// --- In View Component ---
@Component
struct PostCreator {
    @State var viewModel: AlbumViewModel = AlbumViewModel()
    
    func build() -> View {
        Column {
            // 1. GridView 自动响应 selectedImages 的变化
            GridView(items: this.viewModel.selectedImages) { photo =>
                Image(src: photo.uri)
                    .aspectRatio(1.0)
                    .objectFit(ObjectFit.Cover)
            }
            
            // 2. 添加按钮
            Button("添加图片 (${this.viewModel.selectedImages.size()} / 9)") {
                this.viewModel.selectMultiplePhotos()
            }
            .loading(this.viewModel.isLoading)
            .disabled(this.viewModel.selectedImages.size() >= 9)
        }
    }
}

七、【深度实践】URI的“临时陷阱”与持久化策略

这是本文最具深度的实践点,也是新手最容易忽略的“陷阱”。

相册返回的 content://... URI 携带的访问权限是临时的。当你的应用进程被系统回收(例如切到后台过久)后,这个URI就会失效。如果你将这个URI字符串保存到数据库或 SharedPreference 中,下次启动应用时会发现图片无法加载。

**正确的做法是:在拿到I后,立即将其复制(转储)到应用自己的私有沙盒目录中,转而存储这个“永久”的内部文件路径。**

// 假设 FileService API
import { FileService } from 'harmony.io.File'
import { AppContext } from 'harmony.app.Context'

@Observable
class AlbumViewModel {
    // 状态应存储持久化路径
    @State var permanentImagePath: String? = None
    
    // ...
    
    func selectAndPersistSinglePhoto() {
        this.isLoading = true
        this.errorMessage = None
        
        Task {
            try {
                let hasPermission = await AlbumAPI.requestReadPermission()
                if (!hasPermission) { throw Error("未授予相册读取权限") }
                
                let result = await AlbumAPI.pickSingle()
                
                if (let photo = result) {
                    // 关键步骤:转储文件
                    let permanentPath = await this.saveToAppSandbox(photo.uri)
                    
                    // 最终更新状态
                    this.permanentImagePath = permanentPath
                }
            } catch (e: Error) {
                this.errorMessage = e.message
            } finally {
                this.isLoading = false
            }
        }
    }
    
    /**
     * 深度实践:将临时URI复制到应用沙盒
     * @param tempUri 临时的 content:// URI
     * @return 永久的内部文件路径
     */
    private func saveToAppSandbox(tempUri: String): Promise<String> {
        // 1. 获取沙盒路径
        let sandboxDir = AppContext.getCacheDir() + "/images/"
        let fileName = "PICKED_${Date.now().timestamp}.jpg"
        let permanentPath = sandboxDir + fileName
        
        // 2. 确保目录存在
        await FileService.makeDir(sandboxDir, { recursive: true })
        
        // 3. 异步执行文件I/O
        // (await 自动将 FileService.copy 的 Promise 解包)
        try {
            await FileService.copy(from: tempUri, to: permanentPath)
            Logger.info("图片已复制到沙盒: ${permanentPath}")
            return permanentPath
        } catch (e: Error) {
            Logger.error("文件复制失败: ${e.message}")
            throw Error("保存图片失败")
        }
    }
}

现在,`permanentmagePath` 是一个指向你应用私有目录的文件路径,它在应用重启后依然有效,可以被安全地保存到数据库或用于上传。

八、总结:从API调用到健壮的组件

本文我们解构了“相册选择”功能。它远不止是 `picker.pick() 一行代码,而是一个涉及权限、异步、状态、I/O的完整工程链路。

通过仓颉的现代特性,我们构建了一个健壮的解决方案:

  1. MVVM架构:使用 @Observable class 将所有逻辑与 @Component 视图彻底解耦。

  2. 异步处理:使用 async/awaitTask,将复杂的回调流转变为清晰的“同步”逻辑,并用 try/catch/finally 完美处理了所有分支。

  3. 状态管理@State 自动将 ViewModel 的状态(如 isLoading, selectedImages)绑定到UI,无需手动操作。

  4. 深度实践:我们解决了临时URI的“陷阱”,通过异步I/O将其持久化到沙盒,确保了功能的长期健壮性。

Logo

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

更多推荐