
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

🎯 一、组件概述与应用场景
📋 1.1 image_cropper 简介
在 Flutter for OpenHarmony 应用开发中,image_cropper 是一个非常实用的插件,用于裁剪图片。它支持自由裁剪、固定比例裁剪、圆形裁剪等多种方式,为开发者提供了灵活的图片处理能力。
核心特性:
| 特性 | 说明 |
|---|---|
| 🌐 跨平台支持 | 支持 Android、iOS、Web、OpenHarmony |
| ✂️ 自由裁剪 | 支持用户自由拖动裁剪区域 |
| 📐 固定比例 | 支持设置固定的裁剪比例(如 1:1、16:9 等) |
| ⭕ 圆形裁剪 | 支持圆形裁剪模式 |
| 🔍 图片缩放 | 支持手势缩放查看图片细节 |
| 🔄 图片旋转 | 支持双指旋转图片 |
| 🎨 质量控制 | 支持设置输出图片的质量 |
| 📄 格式选择 | 支持 JPG 和 PNG 输出格式 |
💡 1.2 实际应用场景
用户头像裁剪:社交应用中用户上传头像时,需要裁剪成特定比例。
证件照制作:需要将照片裁剪成标准的证件照尺寸。
图片编辑:图片编辑应用中的裁剪功能。
社交媒体图片处理:不同平台对图片尺寸有不同要求。
电商商品图片:商品展示图片需要统一尺寸。
🏗️ 1.3 系统架构设计
┌─────────────────────────────────────────────────────────┐
│ UI 展示层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 图片选择器 │ │ 裁剪界面组件 │ │ 结果展示页面 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 业务逻辑层 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ ImageCropperService 裁剪服务 │ │
│ │ • sampleImage() • cropImage() • recover() │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 平台适配层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Android │ │ iOS │ │ OpenHarmony │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
📦 二、项目配置与依赖安装
🔧 2.1 添加依赖配置
打开项目根目录下的 pubspec.yaml 文件,添加以下配置:
yaml
dependencies:
flutter:
sdk: flutter
# image_cropper - 图片裁剪插件
image_cropper:
git:
url: https://atomgit.com/openharmony-sig/fluttertpc_image_cropper.git
path: ./image_cropper
ref: master
# image_picker - 图片选择插件
image_picker:
git:
url: "https://atomgit.com/openharmony-sig/fluttertpc_image_picker.git"
dev_dependencies:
# image_cropper 鸿蒙平台支持
imagecropper_ohos:
git:
url: https://atomgit.com/openharmony-sig/fluttertpc_image_cropper.git
path: ./image_cropper/ohos
ref: master
配置说明:
- 使用 git 方式引用开源鸿蒙适配的仓库
imagecropper_ohos:鸿蒙平台的原生实现- 本项目基于
image_cropper@2.0.0开发 - 适配 Flutter 3.7.12-ohos-1.0.6,SDK 5.0.0(12)
⚠️ 重要提示:对于 OpenHarmony 平台,必须使用 git 方式引用适配版本,不能直接使用 pub.dev 的版本号。
📥 2.2 下载依赖
配置完成后,在项目根目录执行以下命令:
bash
flutter pub get
🔐 2.3 权限配置
ohos/entry/src/main/module.json5:
json
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:network_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
ohos/entry/src/main/resources/base/element/string.json:
json
{
"string": [
{
"name": "network_reason",
"value": "使用网络"
}
]
}
📱 2.4 支持的功能
| 功能 | 说明 | OpenHarmony 支持 |
|---|---|---|
| sampleImage() | 图片采样加载 | ✅ yes |
| cropImage() | 裁剪图片 | ✅ yes |
| recoverImage() | 恢复图片 | ✅ yes |
| Crop Widget | 裁剪界面组件 | ✅ yes |
| 矩形裁剪 | CropStyle.rectangle | ✅ yes |
| 圆形裁剪 | CropStyle.circle | ✅ yes |
| 自由比例 | CropAspectRatioPreset.original | ✅ yes |
| 正方形比例 | CropAspectRatioPreset.square | ✅ yes |
| 16:9 比例 | CropAspectRatioPreset.ratio16x9 | ✅ yes |
🔧 三、核心功能详解
🎯 3.1 加载图片样本
在裁剪之前,需要先加载图片样本。sampleImage 方法用于将图片缩放到适合显示的大小:
dart
import 'dart:io';
import 'package:imagecropper_ohos/imagecropper_ohos.dart';
import 'package:imagecropper_ohos/page/crop.dart';
final imageCropper = ImagecropperOhos();
Future<File?> loadSampleImage(String path, int maxSize) async {
final sample = await imageCropper.sampleImage(
path: path,
maximumSize: maxSize,
);
return sample;
}
✂️ 3.2 执行裁剪操作
通过 CropState 获取裁剪区域和角度,然后调用 cropImage 方法执行裁剪:
dart
Future<File?> cropImage({
required File file,
required Rect area,
double? angle,
double? scale,
double cx = 0,
double cy = 0,
}) async {
final croppedFile = await imageCropper.cropImage(
file: file,
area: area,
angle: angle,
cx: cx,
cy: cy,
);
return croppedFile;
}
🔄 3.3 Crop Widget 使用
Crop Widget 是一个交互式的裁剪组件,支持手势操作:
dart
class CropPage extends StatefulWidget {
final String filePath;
const CropPage({super.key, required this.filePath});
@override
State<CropPage> createState() => _CropPageState();
}
class _CropPageState extends State<CropPage> {
final imageCropper = ImagecropperOhos();
final cropKey = GlobalKey<CropState>();
File? _sample;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Column(
children: [
Expanded(
child: Crop.file(_sample!, key: cropKey),
),
Padding(
padding: const EdgeInsets.all(20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton(
child: const Text('取消', style: TextStyle(color: Colors.white)),
onPressed: () => Navigator.pop(context),
),
TextButton(
child: const Text('确认', style: TextStyle(color: Colors.white)),
onPressed: _performCrop,
),
],
),
),
],
),
);
}
Future<void> _performCrop() async {
final scale = cropKey.currentState?.scale;
final area = cropKey.currentState?.area;
final angle = cropKey.currentState?.angle;
if (area == null) return;
final croppedFile = await imageCropper.cropImage(
file: _sample!,
area: area,
angle: angle,
);
Navigator.pop(context, croppedFile.path);
}
}
📝 四、完整示例代码
下面是一个完整的智能图片裁剪系统示例:
dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:imagecropper_ohos/imagecropper_ohos.dart';
import 'package:imagecropper_ohos/page/crop.dart';
void main() {
runApp(const ImageCropperApp());
}
class ImageCropperApp extends StatelessWidget {
const ImageCropperApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '智能图片裁剪系统',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
useMaterial3: true,
),
home: const MainPage(),
);
}
}
class MainPage extends StatefulWidget {
const MainPage({super.key});
@override
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
int _currentIndex = 0;
final List<Widget> _pages = [
const QuickCropPage(),
const AvatarCropPage(),
const BatchCropPage(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_currentIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() => _currentIndex = index);
},
destinations: const [
NavigationDestination(icon: Icon(Icons.crop), label: '快速裁剪'),
NavigationDestination(icon: Icon(Icons.account_circle), label: '头像裁剪'),
NavigationDestination(icon: Icon(Icons.photo_library), label: '批量处理'),
],
),
);
}
}
// ============ 快速裁剪页面 ============
class QuickCropPage extends StatefulWidget {
const QuickCropPage({super.key});
@override
State<QuickCropPage> createState() => _QuickCropPageState();
}
class _QuickCropPageState extends State<QuickCropPage> {
final _picker = ImagePicker();
final _imageCropper = ImagecropperOhos();
File? _originalImage;
File? _croppedImage;
bool _isLoading = false;
Future<void> _pickImage() async {
final pickedFile = await _picker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
setState(() {
_originalImage = File(pickedFile.path);
_croppedImage = null;
});
}
}
Future<void> _cropImage() async {
if (_originalImage == null) return;
setState(() => _isLoading = true);
final result = await Navigator.push<String?>(
context,
MaterialPageRoute(
builder: (context) => CropScreen(filePath: _originalImage!.path),
),
);
if (result != null) {
setState(() {
_croppedImage = File(result);
});
}
setState(() => _isLoading = false);
}
void _clear() {
if (_croppedImage != null) {
_croppedImage!.delete();
}
setState(() {
_originalImage = null;
_croppedImage = null;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('快速裁剪'),
centerTitle: true,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _buildContent(),
);
}
Widget _buildContent() {
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildImagePreview(),
const SizedBox(height: 24),
_buildActionButtons(),
if (_croppedImage != null) ...[
const SizedBox(height: 24),
_buildResultInfo(),
],
],
),
);
}
Widget _buildImagePreview() {
return Container(
height: 300,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: _croppedImage != null
? ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(_croppedImage!, fit: BoxFit.cover),
)
: _originalImage != null
? ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(_originalImage!, fit: BoxFit.cover),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 8),
Text('点击下方按钮选择图片', style: TextStyle(color: Colors.grey.shade500)),
],
),
),
);
}
Widget _buildActionButtons() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.photo_library),
label: const Text('选择图片'),
onPressed: _pickImage,
),
if (_originalImage != null && _croppedImage == null)
ElevatedButton.icon(
icon: const Icon(Icons.crop),
label: const Text('裁剪'),
onPressed: _cropImage,
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),
),
if (_croppedImage != null)
ElevatedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('重新选择'),
onPressed: _clear,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey),
),
],
);
}
Widget _buildResultInfo() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('裁剪结果', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.check_circle, color: Colors.green, size: 20),
const SizedBox(width: 8),
const Text('图片裁剪成功'),
],
),
],
),
),
);
}
}
// ============ 头像裁剪页面 ============
class AvatarCropPage extends StatefulWidget {
const AvatarCropPage({super.key});
@override
State<AvatarCropPage> createState() => _AvatarCropPageState();
}
class _AvatarCropPageState extends State<AvatarCropPage> {
final _picker = ImagePicker();
File? _avatarImage;
Future<void> _pickAndCropAvatar() async {
final pickedFile = await _picker.pickImage(source: ImageSource.gallery);
if (pickedFile == null) return;
final result = await Navigator.push<String?>(
context,
MaterialPageRoute(
builder: (context) => CropScreen(
filePath: pickedFile.path,
isCircleCrop: true,
),
),
);
if (result != null) {
setState(() {
_avatarImage = File(result);
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('头像裁剪'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildAvatarPreview(),
const SizedBox(height: 24),
_buildAvatarTips(),
],
),
),
);
}
Widget _buildAvatarPreview() {
return Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.orange.shade300, Colors.orange.shade500],
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
GestureDetector(
onTap: _pickAndCropAvatar,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withOpacity(0.3),
border: Border.all(color: Colors.white, width: 3),
image: _avatarImage != null
? DecorationImage(
image: FileImage(_avatarImage!),
fit: BoxFit.cover,
)
: null,
),
child: _avatarImage == null
? const Icon(Icons.person, size: 64, color: Colors.white)
: null,
),
),
const SizedBox(height: 16),
Text(
_avatarImage != null ? '点击更换头像' : '点击选择头像',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
);
}
Widget _buildAvatarTips() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('头像裁剪说明', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildTipItem('自动裁剪为圆形'),
_buildTipItem('建议选择正面清晰的照片'),
_buildTipItem('支持手势缩放和旋转'),
],
),
),
);
}
Widget _buildTipItem(String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(Icons.check, color: Colors.orange.shade400, size: 16),
const SizedBox(width: 8),
Text(text, style: const TextStyle(fontSize: 14)),
],
),
);
}
}
// ============ 批量裁剪页面 ============
class BatchCropPage extends StatefulWidget {
const BatchCropPage({super.key});
@override
State<BatchCropPage> createState() => _BatchCropPageState();
}
class _BatchCropPageState extends State<BatchCropPage> {
final _picker = ImagePicker();
final List<File> _selectedImages = [];
final List<File> _croppedImages = [];
int _currentIndex = 0;
bool _isProcessing = false;
Future<void> _pickMultipleImages() async {
final pickedFiles = await _picker.pickMultiImage();
if (pickedFiles.isNotEmpty) {
setState(() {
_selectedImages.clear();
_croppedImages.clear();
_selectedImages.addAll(pickedFiles.map((f) => File(f.path)));
_currentIndex = 0;
});
}
}
Future<void> _processNextImage() async {
if (_currentIndex >= _selectedImages.length) return;
setState(() => _isProcessing = true);
final result = await Navigator.push<String?>(
context,
MaterialPageRoute(
builder: (context) => CropScreen(
filePath: _selectedImages[_currentIndex].path,
title: '裁剪图片 ${_currentIndex + 1}/${_selectedImages.length}',
),
),
);
if (result != null) {
setState(() {
_croppedImages.add(File(result));
_currentIndex++;
});
}
setState(() => _isProcessing = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('批量处理'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_buildProgressCard(),
const SizedBox(height: 24),
_buildActionButtons(),
const SizedBox(height: 24),
_buildImageGrid(),
],
),
),
);
}
Widget _buildProgressCard() {
final total = _selectedImages.length;
final completed = _croppedImages.length;
final progress = total > 0 ? completed / total : 0.0;
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue.shade400, Colors.blue.shade600],
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('处理进度', style: TextStyle(color: Colors.white70)),
Text(
'$completed / $total',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white.withOpacity(0.3),
valueColor: const AlwaysStoppedAnimation(Colors.white),
minHeight: 8,
),
),
],
),
);
}
Widget _buildActionButtons() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.photo_library),
label: const Text('选择多张'),
onPressed: _pickMultipleImages,
),
if (_currentIndex < _selectedImages.length && !_isProcessing)
ElevatedButton.icon(
icon: const Icon(Icons.crop),
label: Text('裁剪第 ${_currentIndex + 1} 张'),
onPressed: _processNextImage,
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
),
],
);
}
Widget _buildImageGrid() {
if (_croppedImages.isEmpty && _selectedImages.isEmpty) {
return Container(
height: 200,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.photo_library_outlined, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 8),
Text('选择多张图片进行批量裁剪', style: TextStyle(color: Colors.grey.shade500)),
],
),
),
);
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _croppedImages.length,
itemBuilder: (context, index) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(_croppedImages[index], fit: BoxFit.cover),
);
},
);
}
}
// ============ 裁剪界面 ============
class CropScreen extends StatefulWidget {
final String filePath;
final String? title;
final bool isCircleCrop;
const CropScreen({
super.key,
required this.filePath,
this.title,
this.isCircleCrop = false,
});
@override
State<CropScreen> createState() => _CropScreenState();
}
class _CropScreenState extends State<CropScreen> {
final _imageCropper = ImagecropperOhos();
final _cropKey = GlobalKey<CropState>();
File? _sample;
File? _originalFile;
bool _isLoading = true;
@override
void initState() {
super.initState();
_originalFile = File(widget.filePath);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadSample());
}
Future<void> _loadSample() async {
final sample = await _imageCropper.sampleImage(
path: widget.filePath,
maximumSize: MediaQuery.of(context).size.longestSide.ceil(),
);
setState(() {
_sample = sample;
_isLoading = false;
});
}
Future<void> _performCrop() async {
final scale = _cropKey.currentState?.scale;
final area = _cropKey.currentState?.area;
final angle = _cropKey.currentState?.angle;
final cx = _cropKey.currentState?.cx ?? 0;
final cy = _cropKey.currentState?.cy ?? 0;
if (area == null) return;
setState(() => _isLoading = true);
final sample = await _imageCropper.sampleImage(
path: _originalFile!.path,
maximumSize: (2000 / scale!).round(),
);
final croppedFile = await _imageCropper.cropImage(
file: sample!,
area: area,
angle: angle,
cx: cx,
cy: cy,
);
sample.delete();
if (mounted) {
Navigator.pop(context, croppedFile.path);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
title: Text(widget.title ?? '裁剪图片'),
backgroundColor: Colors.black,
foregroundColor: Colors.white,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Expanded(
child: Crop.file(_sample!, key: _cropKey),
),
Container(
padding: const EdgeInsets.all(20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton.icon(
icon: const Icon(Icons.close, color: Colors.white),
label: const Text('取消', style: TextStyle(color: Colors.white)),
onPressed: () => Navigator.pop(context),
),
ElevatedButton.icon(
icon: const Icon(Icons.check),
label: const Text('确认'),
onPressed: _performCrop,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
),
],
),
),
],
),
);
}
@override
void dispose() {
_sample?.delete();
super.dispose();
}
}
🏆 五、最佳实践与注意事项
⚠️ 5.1 图片处理流程
推荐流程:
- 选择图片 :使用
image_picker选择图片 - 预览裁剪 :使用
sampleImage加载预览图 - 用户裁剪 :使用
CropWidget 进行交互式裁剪 - 高质量输出:使用原始图片进行高质量裁剪
- 清理资源:删除临时文件释放内存
🔍 5.2 性能优化
内存管理:及时删除临时文件避免内存泄漏。
图片压缩 :使用适当的 maximumSize 参数控制图片大小。
高质量输出:裁剪时使用原始图片以保证输出质量。
📱 5.3 常见问题处理
图片加载失败 :使用 sampleImage 方法先对图片进行采样压缩。
裁剪质量不高:在裁剪时使用更高的分辨率。
内存泄漏:及时删除临时文件。
📌 六、总结
本文通过一个完整的智能图片裁剪系统案例,深入讲解了 image_cropper 插件的使用方法与最佳实践:
基础裁剪:掌握图片裁剪的基本方法。
头像裁剪:实现圆形头像裁剪功能。
批量处理:构建批量图片裁剪流程。
手势操作:支持缩放、旋转等手势交互。
掌握这些技巧,你就能构建出专业级的图片裁剪功能,满足各种业务场景需求。