Flutter+HarmonyOS跨端实战—第05篇:图片选择器的三端适配实战
·
从零实现一个支持 Android/iOS/HarmonyOS 的图片选择插件
前言
图片选择是移动应用的基础功能,但三个平台的实现方式完全不同。这篇文章我会带你从零实现一个完整的图片选择插件,支持:
- 从相册选择图片
- 拍照获取图片
- 图片压缩和尺寸限制
通过这个实战案例,你会深入理解 Flutter 插件的架构设计和跨平台适配的核心技术。

一、插件架构设计
1.1 分层架构
Flutter 官方插件采用联邦插件(Federated Plugin)架构,分为三层:
image_picker/ # 应用层(用户调用)
└─ 依赖 image_picker_platform_interface
image_picker_platform_interface/ # 接口层(定义规范)
└─ 定义抽象接口
image_picker_android/ # Android 实现层
image_picker_ios/ # iOS 实现层
image_picker_ohos/ # HarmonyOS 实现层
└─ 实现 platform_interface 定义的接口
为什么要分层?
- 解耦:应用层不直接依赖平台实现
- 扩展性:新增平台只需实现接口
- 可测试:可以 mock 接口层进行测试
1.2 目录结构
packages/image_picker/
├── image_picker/ # 应用层
│ ├── lib/
│ │ └── image_picker.dart # 统一 API
│ └── pubspec.yaml
│
├── image_picker_platform_interface/ # 接口层
│ ├── lib/
│ │ ├── image_picker_platform_interface.dart
│ │ └── method_channel_image_picker.dart
│ └── pubspec.yaml
│
├── image_picker_android/ # Android 实现
│ ├── android/
│ │ └── src/main/kotlin/
│ │ └── ImagePickerPlugin.kt
│ ├── lib/
│ │ └── image_picker_android.dart
│ └── pubspec.yaml
│
├── image_picker_ios/ # iOS 实现
│ ├── ios/
│ │ └── Classes/
│ │ └── ImagePickerPlugin.swift
│ ├── lib/
│ │ └── image_picker_ios.dart
│ └── pubspec.yaml
│
└── image_picker_ohos/ # HarmonyOS 实现
├── ohos/
│ └── src/main/ets/
│ └── ImagePickerPlugin.ets
├── lib/
│ └── image_picker_ohos.dart
└── pubspec.yaml
二、接口层设计
2.1 定义抽象接口
// packages/image_picker_platform_interface/lib/image_picker_platform_interface.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
/// 图片来源
enum ImageSource {
/// 相机
camera,
/// 相册
gallery,
}
/// 相机设备
enum CameraDevice {
/// 后置摄像头
rear,
/// 前置摄像头
front,
}
/// 图片选择平台接口
abstract class ImagePickerPlatform extends PlatformInterface {
ImagePickerPlatform() : super(token: _token);
static final Object _token = Object();
static ImagePickerPlatform _instance = MethodChannelImagePicker();
/// 当前平台实现实例
static ImagePickerPlatform get instance => _instance;
/// 设置平台实现(用于测试)
static set instance(ImagePickerPlatform instance) {
PlatformInterface.verifyToken(instance, _token);
_instance = instance;
}
/// 选择单张图片
///
/// [source] 图片来源(相机或相册)
/// [maxWidth] 最大宽度(像素)
/// [maxHeight] 最大高度(像素)
/// [imageQuality] 图片质量(0-100)
/// [preferredCameraDevice] 优先使用的相机
Future<XFile?> pickImage({
required ImageSource source,
double? maxWidth,
double? maxHeight,
int? imageQuality,
CameraDevice preferredCameraDevice = CameraDevice.rear,
}) {
throw UnimplementedError('pickImage() has not been implemented.');
}
/// 选择多张图片(仅相册)
Future<List<XFile>> pickMultiImage({
double? maxWidth,
double? maxHeight,
int? imageQuality,
}) {
throw UnimplementedError('pickMultiImage() has not been implemented.');
}
/// 选择视频
Future<XFile?> pickVideo({
required ImageSource source,
CameraDevice preferredCameraDevice = CameraDevice.rear,
Duration? maxDuration,
}) {
throw UnimplementedError('pickVideo() has not been implemented.');
}
}
/// 跨平台文件类
class XFile {
/// 文件路径
final String path;
/// 文件名
final String? name;
/// MIME 类型
final String? mimeType;
XFile(
this.path, {
this.name,
this.mimeType,
});
/// 读取文件字节
Future<Uint8List> readAsBytes() async {
// 实现文件读取
throw UnimplementedError();
}
/// 获取文件长度
Future<int> length() async {
// 实现获取文件大小
throw UnimplementedError();
}
}
2.2 MethodChannel 默认实现
// packages/image_picker_platform_interface/lib/method_channel_image_picker.dart
import 'package:flutter/services.dart';
import 'image_picker_platform_interface.dart';
/// 基于 MethodChannel 的默认实现
class MethodChannelImagePicker extends ImagePickerPlatform {
final MethodChannel channel = const MethodChannel('plugins.flutter.io/image_picker');
Future<XFile?> pickImage({
required ImageSource source,
double? maxWidth,
double? maxHeight,
int? imageQuality,
CameraDevice preferredCameraDevice = CameraDevice.rear,
}) async {
final String? path = await channel.invokeMethod<String>(
'pickImage',
<String, dynamic>{
'source': source.index,
'maxWidth': maxWidth,
'maxHeight': maxHeight,
'imageQuality': imageQuality,
'cameraDevice': preferredCameraDevice.index,
},
);
return path != null ? XFile(path) : null;
}
Future<List<XFile>> pickMultiImage({
double? maxWidth,
double? maxHeight,
int? imageQuality,
}) async {
final List<dynamic>? paths = await channel.invokeMethod<List<dynamic>>(
'pickMultiImage',
<String, dynamic>{
'maxWidth': maxWidth,
'maxHeight': maxHeight,
'imageQuality': imageQuality,
},
);
if (paths == null) {
return [];
}
return paths.map((dynamic path) => XFile(path as String)).toList();
}
Future<XFile?> pickVideo({
required ImageSource source,
CameraDevice preferredCameraDevice = CameraDevice.rear,
Duration? maxDuration,
}) async {
final String? path = await channel.invokeMethod<String>(
'pickVideo',
<String, dynamic>{
'source': source.index,
'cameraDevice': preferredCameraDevice.index,
'maxDuration': maxDuration?.inSeconds,
},
);
return path != null ? XFile(path) : null;
}
}
三、应用层封装
3.1 用户友好的 API
// packages/image_picker/lib/image_picker.dart
import 'dart:async';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
export 'package:image_picker_platform_interface/image_picker_platform_interface.dart'
show ImageSource, CameraDevice, XFile;
/// 图片选择器
class ImagePicker {
/// 平台接口实例
static ImagePickerPlatform get _platform => ImagePickerPlatform.instance;
/// 选择单张图片
///
/// 示例:
/// ```dart
/// final picker = ImagePicker();
/// final XFile? image = await picker.pickImage(source: ImageSource.gallery);
/// ```
Future<XFile?> pickImage({
required ImageSource source,
double? maxWidth,
double? maxHeight,
int? imageQuality,
CameraDevice preferredCameraDevice = CameraDevice.rear,
}) {
return _platform.pickImage(
source: source,
maxWidth: maxWidth,
maxHeight: maxHeight,
imageQuality: imageQuality,
preferredCameraDevice: preferredCameraDevice,
);
}
/// 选择多张图片(仅相册)
///
/// 示例:
/// ```dart
/// final picker = ImagePicker();
/// final List<XFile> images = await picker.pickMultiImage();
/// ```
Future<List<XFile>> pickMultiImage({
double? maxWidth,
double? maxHeight,
int? imageQuality,
}) {
return _platform.pickMultiImage(
maxWidth: maxWidth,
maxHeight: maxHeight,
imageQuality: imageQuality,
);
}
/// 选择视频
Future<XFile?> pickVideo({
required ImageSource source,
CameraDevice preferredCameraDevice = CameraDevice.rear,
Duration? maxDuration,
}) {
return _platform.pickVideo(
source: source,
preferredCameraDevice: preferredCameraDevice,
maxDuration: maxDuration,
);
}
}
本篇小结(第一部分)
这部分我们完成了:
- ✅ 插件架构设计(联邦插件模式)
- ✅ 接口层设计(抽象接口定义)
- ✅ MethodChannel 默认实现
- ✅ 应用层 API 封装
下一部分我会继续讲解 Android、iOS、HarmonyOS 三端的具体实现。
四、Android 平台实现
4.1 Kotlin 插件代码
// packages/image_picker_android/android/src/main/kotlin/ImagePickerPlugin.kt
package io.flutter.plugins.imagepicker
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.core.content.FileProvider
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import java.io.File
class ImagePickerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware,
PluginRegistry.ActivityResultListener {
private lateinit var channel: MethodChannel
private var activity: Activity? = null
private var pendingResult: Result? = null
private var imageUri: Uri? = null
companion object {
private const val CHANNEL = "plugins.flutter.io/image_picker"
private const val REQUEST_CODE_PICK = 2342
private const val REQUEST_CODE_CAMERA = 2343
}
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, CHANNEL)
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"pickImage" -> {
val source = call.argument<Int>("source") ?: 0
val maxWidth = call.argument<Double>("maxWidth")
val maxHeight = call.argument<Double>("maxHeight")
val imageQuality = call.argument<Int>("imageQuality")
if (source == 0) {
// 相机
pickImageFromCamera(result)
} else {
// 相册
pickImageFromGallery(result)
}
}
"pickMultiImage" -> {
pickMultiImage(result)
}
else -> {
result.notImplemented()
}
}
}
private fun pickImageFromGallery(result: Result) {
if (activity == null) {
result.error("NO_ACTIVITY", "Plugin not attached to an activity", null)
return
}
pendingResult = result
val intent = Intent(Intent.ACTION_PICK).apply {
type = "image/*"
}
activity?.startActivityForResult(intent, REQUEST_CODE_PICK)
}
private fun pickImageFromCamera(result: Result) {
if (activity == null) {
result.error("NO_ACTIVITY", "Plugin not attached to an activity", null)
return
}
pendingResult = result
// 创建临时文件
val imageFile = File(activity?.cacheDir, "image_picker_${System.currentTimeMillis()}.jpg")
imageUri = FileProvider.getUriForFile(
activity!!,
"${activity!!.packageName}.fileprovider",
imageFile
)
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
putExtra(MediaStore.EXTRA_OUTPUT, imageUri)
}
activity?.startActivityForResult(intent, REQUEST_CODE_CAMERA)
}
private fun pickMultiImage(result: Result) {
if (activity == null) {
result.error("NO_ACTIVITY", "Plugin not attached to an activity", null)
return
}
pendingResult = result
val intent = Intent(Intent.ACTION_PICK).apply {
type = "image/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
activity?.startActivityForResult(intent, REQUEST_CODE_PICK)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == REQUEST_CODE_PICK || requestCode == REQUEST_CODE_CAMERA) {
if (resultCode == Activity.RESULT_OK) {
when (requestCode) {
REQUEST_CODE_PICK -> {
// 相册选择
if (data?.clipData != null) {
// 多选
val paths = mutableListOf<String>()
val clipData = data.clipData!!
for (i in 0 until clipData.itemCount) {
val uri = clipData.getItemAt(i).uri
val path = getPathFromUri(uri)
if (path != null) {
paths.add(path)
}
}
pendingResult?.success(paths)
} else if (data?.data != null) {
// 单选
val path = getPathFromUri(data.data!!)
pendingResult?.success(path)
} else {
pendingResult?.success(null)
}
}
REQUEST_CODE_CAMERA -> {
// 相机拍照
val path = imageUri?.let { getPathFromUri(it) }
pendingResult?.success(path)
}
}
} else {
pendingResult?.success(null)
}
pendingResult = null
return true
}
return false
}
private fun getPathFromUri(uri: Uri): String? {
// 将 URI 转换为文件路径
// 简化实现,实际需要处理各种 URI 格式
return uri.path
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
关键点:
- ActivityAware:需要 Activity 上下文才能启动系统选择器
- ActivityResultListener:监听选择结果
- FileProvider:Android 7.0+ 需要使用 FileProvider 共享文件
五、iOS 平台实现
5.1 Swift 插件代码
// packages/image_picker_ios/ios/Classes/ImagePickerPlugin.swift
import Flutter
import UIKit
import Photos
public class ImagePickerPlugin: NSObject, FlutterPlugin {
private var channel: FlutterMethodChannel?
private var viewController: UIViewController?
private var pendingResult: FlutterResult?
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "plugins.flutter.io/image_picker",
binaryMessenger: registrar.messenger()
)
let instance = ImagePickerPlugin()
instance.channel = channel
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let viewController = UIApplication.shared.keyWindow?.rootViewController else {
result(FlutterError(code: "NO_VIEW_CONTROLLER", message: "No view controller", details: nil))
return
}
self.viewController = viewController
self.pendingResult = result
switch call.method {
case "pickImage":
guard let args = call.arguments as? [String: Any],
let source = args["source"] as? Int else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
return
}
if source == 0 {
// 相机
pickImageFromCamera()
} else {
// 相册
pickImageFromGallery()
}
case "pickMultiImage":
pickMultiImage()
default:
result(FlutterMethodNotImplemented)
}
}
private func pickImageFromGallery() {
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.delegate = self
viewController?.present(picker, animated: true)
}
private func pickImageFromCamera() {
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
pendingResult?(FlutterError(code: "CAMERA_NOT_AVAILABLE", message: "Camera not available", details: nil))
pendingResult = nil
return
}
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = self
viewController?.present(picker, animated: true)
}
private func pickMultiImage() {
if #available(iOS 14, *) {
var configuration = PHPickerConfiguration()
configuration.selectionLimit = 0 // 0 表示无限制
configuration.filter = .images
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
viewController?.present(picker, animated: true)
} else {
// iOS 14 以下不支持多选
pendingResult?(FlutterError(code: "NOT_SUPPORTED", message: "Multi-image picker requires iOS 14+", details: nil))
pendingResult = nil
}
}
}
// MARK: - UIImagePickerControllerDelegate
extension ImagePickerPlugin: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
public func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]
) {
picker.dismiss(animated: true)
guard let image = info[.originalImage] as? UIImage else {
pendingResult?(nil)
pendingResult = nil
return
}
// 保存到临时目录
let tempDir = NSTemporaryDirectory()
let fileName = "image_picker_\(Date().timeIntervalSince1970).jpg"
let filePath = (tempDir as NSString).appendingPathComponent(fileName)
if let data = image.jpegData(compressionQuality: 0.8) {
try? data.write(to: URL(fileURLWithPath: filePath))
pendingResult?(filePath)
} else {
pendingResult?(nil)
}
pendingResult = nil
}
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
pendingResult?(nil)
pendingResult = nil
}
}
// MARK: - PHPickerViewControllerDelegate
@available(iOS 14, *)
extension ImagePickerPlugin: PHPickerViewControllerDelegate {
public func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else {
pendingResult?(nil)
pendingResult = nil
return
}
var paths: [String] = []
let group = DispatchGroup()
for result in results {
group.enter()
result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (object, error) in
defer { group.leave() }
guard let image = object as? UIImage else { return }
let tempDir = NSTemporaryDirectory()
let fileName = "image_picker_\(Date().timeIntervalSince1970)_\(paths.count).jpg"
let filePath = (tempDir as NSString).appendingPathComponent(fileName)
if let data = image.jpegData(compressionQuality: 0.8) {
try? data.write(to: URL(fileURLWithPath: filePath))
paths.append(filePath)
}
}
}
group.notify(queue: .main) { [weak self] in
self?.pendingResult?(paths)
self?.pendingResult = nil
}
}
}
关键点:
- UIImagePickerController:iOS 系统图片选择器
- PHPickerViewController:iOS 14+ 的新 API,支持多选
- Delegate 模式:通过 delegate 回调获取结果
六、HarmonyOS 平台实现
6.1 ArkTS 插件代码
// packages/image_picker_ohos/ohos/src/main/ets/ImagePickerPlugin.ets
import { FlutterPlugin, MethodCall, MethodChannel, MethodResult } from '@ohos/flutter_ohos';
import picker from '@ohos.file.picker';
import fs from '@ohos.file.fs';
import { BusinessError } from '@ohos.base';
export class ImagePickerPlugin implements FlutterPlugin {
private channel: MethodChannel | null = null;
getUniqueClassName(): string {
return "ImagePickerPlugin";
}
onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding): void {
this.channel = new MethodChannel(
binding.getBinaryMessenger(),
'plugins.flutter.io/image_picker'
);
this.channel.setMethodCallHandler(this.onMethodCall.bind(this));
}
onMethodCall(call: MethodCall, result: MethodResult): void {
const method = call.method;
if (method === 'pickImage') {
this.pickImage(call, result);
} else if (method === 'pickMultiImage') {
this.pickMultiImage(call, result);
} else {
result.notImplemented();
}
}
private pickImage(call: MethodCall, result: MethodResult): void {
try {
const args = call.args as Record<string, any>;
const source = args['source'] as number;
if (source === 0) {
// 相机
result.error('NOT_IMPLEMENTED', '相机功能暂未实现', null);
} else {
// 相册
this.pickImageFromGallery(result);
}
} catch (err) {
result.error('ERROR', (err as Error).message, null);
}
}
private pickImageFromGallery(result: MethodResult): void {
try {
// 创建图片选择器配置
const photoSelectOptions = new picker.PhotoSelectOptions();
photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
photoSelectOptions.maxSelectNumber = 1;
// 创建图片选择器
const photoPicker = new picker.PhotoViewPicker();
// 启动选择器
photoPicker.select(photoSelectOptions).then((selectResult: picker.PhotoSelectResult) => {
if (selectResult.photoUris.length > 0) {
const uri = selectResult.photoUris[0];
// 将 URI 转换为路径
const path = this.convertUriToPath(uri);
result.success(path);
} else {
result.success(null);
}
}).catch((err: BusinessError) => {
console.error(`[ImagePicker] 选择图片失败: ${err.message}`);
result.error('PICK_ERROR', err.message, null);
});
} catch (err) {
result.error('ERROR', (err as Error).message, null);
}
}
private pickMultiImage(call: MethodCall, result: MethodResult): void {
try {
const args = call.args as Record<string, any>;
const maxWidth = args['maxWidth'] as number;
const maxHeight = args['maxHeight'] as number;
// 创建图片选择器配置
const photoSelectOptions = new picker.PhotoSelectOptions();
photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
photoSelectOptions.maxSelectNumber = 9; // 最多选择 9 张
// 创建图片选择器
const photoPicker = new picker.PhotoViewPicker();
// 启动选择器
photoPicker.select(photoSelectOptions).then((selectResult: picker.PhotoSelectResult) => {
const paths: string[] = [];
for (const uri of selectResult.photoUris) {
const path = this.convertUriToPath(uri);
paths.push(path);
}
result.success(paths);
}).catch((err: BusinessError) => {
console.error(`[ImagePicker] 选择多张图片失败: ${err.message}`);
result.error('PICK_ERROR', err.message, null);
});
} catch (err) {
result.error('ERROR', (err as Error).message, null);
}
}
private convertUriToPath(uri: string): string {
// HarmonyOS 的 Picker 返回的是 URI 格式
// 例如:file://media/Photo/1/IMG_1234.jpg
if (uri.startsWith('file://')) {
return uri.substring(7); // 移除 "file://" 前缀
}
return uri;
}
onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding): void {
if (this.channel !== null) {
this.channel.setMethodCallHandler(null);
}
}
}
关键点:
- Picker API:使用
@ohos.file.picker模块 - Promise 异步:所有操作都是异步的
- URI 转换:Picker 返回 URI,需要转换为路径
- 错误处理:使用 try-catch 和 Promise.catch
本篇完整小结
这篇文章我们完成了:
- ✅ 插件架构设计(联邦插件模式)
- ✅ 接口层设计(抽象接口 + MethodChannel)
- ✅ 应用层 API 封装
- ✅ Android 平台实现(Kotlin)
- ✅ iOS 平台实现(Swift)
- ✅ HarmonyOS 平台实现(ArkTS)
关键要点:
- 采用联邦插件架构,分离接口和实现
- 三端使用各自的系统 API
- 统一返回文件路径,而非文件内容
- 做好错误处理和边界情况
思考题
- 为什么要返回文件路径而不是文件内容?
- 如何实现图片压缩和尺寸限制?
- 多选图片时如何优化性能?
下一篇预告:第06篇 - 本地存储的跨平台实现
更多推荐



所有评论(0)