Flutter for OpenHarmony 文件转换助手App实战 - 图片转换功能

在日常工作中,我们经常需要处理各种格式的图片文件。有时候需要把PNG转成JPG以减小体积,有时候需要把图片压缩一下方便传输。这次我们就来实现一个实用的图片转换功能,支持多种常见格式的相互转换。
功能规划与界面设计
在开始编码之前,我先梳理了一下需求。图片转换功能主要分为两大块:格式转换和图片压缩。格式转换支持JPG、PNG、WebP和BMP这几种常用格式之间的互转,而图片压缩则是在保持格式不变的情况下降低文件大小。
界面上我采用了列表的形式来展示这两个功能入口。先来看看整体的布局结构:
Widget _buildImageTab() {
return ListView(
padding: EdgeInsets.all(16.w),
children: [
ConvertItem(
icon: Icons.image,
title: '图片格式转换',
subtitle: 'JPG/PNG/WebP/BMP',
onTap: () => _showConvertDetail('图片格式转换'),
),
SizedBox(height: 12.h),
ConvertItem(
icon: Icons.compress,
title: '图片压缩',
subtitle: '减小文件大小',
onTap: () => _showConvertDetail('图片压缩'),
),
],
);
}
这里用
ListView作为容器,配合EdgeInsets.all(16.w)给整个列表加上统一的内边距。每个功能项之间用SizedBox来控制间距,这样看起来不会太拥挤。ConvertItem是我封装的一个自定义组件,专门用来展示功能卡片,后面会详细说明它的实现。
ConvertItem组件的封装
为了让代码更清晰,我把功能卡片单独封装成了一个组件。这个组件接收图标、标题、副标题和点击回调:
class ConvertItem extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const ConvertItem({
Key? key,
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
}) : super(key: key);
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12.r),
child: Padding(
padding: EdgeInsets.all(16.w),
child: Row(
children: [
Icon(icon, size: 32.sp, color: Theme.of(context).primaryColor),
SizedBox(width: 16.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold)),
SizedBox(height: 4.h),
Text(subtitle, style: TextStyle(fontSize: 14.sp, color: Colors.grey[600])),
],
),
),
Icon(Icons.arrow_forward_ios, size: 16.sp, color: Colors.grey),
],
),
),
),
);
}
}
这个组件用
Card包裹来实现卡片效果,elevation: 2给卡片添加轻微的阴影。InkWell提供了点击水波纹效果,让交互更自然。布局上采用Row横向排列,左边是图标,中间是标题和副标题的纵向布局,右边是箭头指示器。这种设计在移动应用中很常见,用户一看就知道可以点击进入下一级页面。
转换对话框的实现
点击功能卡片后,需要弹出一个对话框让用户进行具体的操作。这个对话框要包含文件选择、格式选择以及确认按钮。我在实现的时候遇到了一个小问题,就是如何让对话框内容在小屏幕上也能正常显示,最后用SingleChildScrollView解决了。
先来看看对话框的基本框架:
void _showConvertDetail(String title) {
String? selectedFile;
String selectedFormat = '选择格式';
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: _buildDialogContent(selectedFile, selectedFormat, setState),
),
actions: _buildDialogActions(context, selectedFile, selectedFormat),
);
},
),
);
}
这里用了
StatefulBuilder来包裹对话框内容,这样就可以在对话框内部使用setState来更新UI了。比如用户选择了文件或者格式后,界面需要立即反映出来。SingleChildScrollView确保了当内容过多时可以滚动查看,避免在小屏幕设备上出现溢出问题。
文件选择区域的实现
文件选择这块我做了一个可点击的输入框,点击后会触发文件选择器:
Widget _buildFileSelector(String? selectedFile, StateSetter setState) {
return InkWell(
onTap: () async {
final file = await _selectImageFile();
if (file != null) {
setState(() {
selectedFile = file.path;
});
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: '选择文件',
hintText: selectedFile ?? '点击选择图片文件',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
),
suffixIcon: Icon(Icons.folder_open),
),
child: Text(
selectedFile?.split('/').last ?? '',
style: TextStyle(fontSize: 14.sp),
overflow: TextOverflow.ellipsis,
),
),
);
}
我没有直接用
TextField,而是用InkWell包裹InputDecorator来实现。这样做的好处是可以完全控制点击行为,避免弹出系统键盘。suffixIcon添加了一个文件夹图标,让用户一眼就能看出这是文件选择功能。文件路径显示时只展示文件名,用split('/').last来提取,这样界面更简洁。
格式选择下拉菜单
格式选择用下拉菜单来实现,这是最直观的交互方式:
Widget _buildFormatSelector(String selectedFormat, StateSetter setState) {
return DropdownButtonFormField<String>(
value: selectedFormat,
items: ['选择格式', 'PNG', 'JPG', 'WebP', 'BMP'].map((format) {
return DropdownMenuItem(
value: format,
child: Row(
children: [
Icon(_getFormatIcon(format), size: 20.sp),
SizedBox(width: 8.w),
Text(format),
],
),
);
}).toList(),
onChanged: (value) {
setState(() {
selectedFormat = value ?? '选择格式';
});
},
decoration: InputDecoration(
labelText: '目标格式',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.r),
),
),
);
}
下拉菜单的每一项我都加了对应的图标,这样用户选择时更直观。
_getFormatIcon是一个辅助方法,根据格式返回不同的图标。onChanged回调里用setState更新选中的格式,这样下拉菜单会立即显示用户的选择。
对话框按钮组
对话框底部需要两个按钮:取消和转换:
List<Widget> _buildDialogActions(BuildContext context, String? selectedFile, String selectedFormat) {
return [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('取消', style: TextStyle(color: Colors.grey[600])),
),
ElevatedButton(
onPressed: (selectedFile != null && selectedFormat != '选择格式')
? () {
Navigator.pop(context);
_performConversion(selectedFile, selectedFormat);
}
: null,
child: const Text('开始转换'),
),
];
}
转换按钮的启用状态取决于用户是否选择了文件和格式。如果没有选择完整,按钮会处于禁用状态(
onPressed为null),这样可以防止用户误操作。用ElevatedButton而不是TextButton来突出主要操作,这是Material Design的推荐做法。
文件选择器的集成
文件选择这块我用的是file_selector包,这个包在鸿蒙平台上已经有了适配版本,可以直接使用。首先在pubspec.yaml中添加依赖:
dependencies:
file_selector: ^1.0.3
这个版本号是我在项目中实际使用的,你可以根据需要选择最新的稳定版本。鸿蒙适配的包通常会在pub.dev上标注支持的平台,记得确认一下。
然后在代码中引入并实现文件选择逻辑:
import 'package:file_selector/file_selector.dart';
Future<XFile?> _selectImageFile() async {
const XTypeGroup typeGroup = XTypeGroup(
label: 'images',
extensions: <String>['jpg', 'png', 'webp', 'bmp'],
);
final XFile? file = await openFile(
acceptedTypeGroups: <XTypeGroup>[typeGroup],
);
return file;
}
XTypeGroup用来定义可选择的文件类型,这里限制为常见的图片格式。extensions参数不需要加点号,直接写扩展名就行。openFile方法会调起系统的文件选择器,在鸿蒙系统上会使用原生的文件管理界面,体验和系统应用保持一致。
文件信息的获取与展示
选择文件后,我们需要获取一些基本信息来展示给用户:
Future<Map<String, dynamic>> _getFileInfo(XFile file) async {
final bytes = await file.readAsBytes();
final sizeInKB = (bytes.length / 1024).toStringAsFixed(2);
return {
'name': file.name,
'path': file.path,
'size': sizeInKB,
'extension': file.name.split('.').last.toUpperCase(),
};
}
这个方法读取文件的字节数据来计算大小,然后转换成KB单位方便阅读。文件扩展名通过分割文件名获取,并转成大写显示。这些信息可以在界面上展示,让用户确认选择的文件是否正确。
图片格式转换的核心逻辑
选择好文件和目标格式后,就要进行实际的转换操作了。这里我使用了image包来处理图片,它支持多种格式的编解码:
import 'package:image/image.dart' as img;
Future<bool> _convertImage(String sourcePath, String targetFormat) async {
try {
final bytes = await File(sourcePath).readAsBytes();
final image = img.decodeImage(bytes);
if (image == null) {
_showError('无法读取图片文件');
return false;
}
return await _encodeAndSave(image, sourcePath, targetFormat);
} catch (e) {
_showError('转换失败: $e');
return false;
}
}
转换的第一步是读取源文件并解码成图片对象。
img.decodeImage会自动识别图片格式,不需要我们手动指定。如果解码失败返回null,说明文件可能损坏或者不是有效的图片格式,这时候要给用户一个友好的提示。
不同格式的编码处理
根据目标格式的不同,需要调用不同的编码器:
Future<bool> _encodeAndSave(img.Image image, String sourcePath, String format) async {
List<int>? encodedBytes;
String extension;
switch (format.toUpperCase()) {
case 'PNG':
encodedBytes = img.encodePng(image);
extension = 'png';
break;
case 'JPG':
encodedBytes = img.encodeJpg(image, quality: 85);
extension = 'jpg';
break;
case 'WEBP':
encodedBytes = img.encodeWebP(image);
extension = 'webp';
break;
case 'BMP':
encodedBytes = img.encodeBmp(image);
extension = 'bmp';
break;
default:
return false;
}
final outputPath = sourcePath.replaceAll(RegExp(r'\.[^.]+$'), '.$extension');
await File(outputPath).writeAsBytes(encodedBytes);
return true;
}
每种格式都有对应的编码方法。JPG格式我设置了
quality: 85,这是一个比较平衡的质量参数,既能保证图片清晰度,又不会让文件太大。输出文件名通过正则表达式替换扩展名生成,保存在源文件的同一目录下。实际项目中你可能需要让用户选择保存位置,这里为了简化就直接保存了。
图片压缩功能的实现
除了格式转换,图片压缩也是一个很实用的功能。压缩的思路是保持格式不变,通过降低质量或缩小尺寸来减小文件大小:
Future<bool> _compressImage(String sourcePath, int quality) async {
try {
final bytes = await File(sourcePath).readAsBytes();
final image = img.decodeImage(bytes);
if (image == null) return false;
final compressed = img.encodeJpg(image, quality: quality);
final outputPath = sourcePath.replaceAll(
RegExp(r'\.([^.]+)$'),
'_compressed.\$1'
);
await File(outputPath).writeAsBytes(compressed);
return true;
} catch (e) {
return false;
}
}
压缩时我用JPG编码,因为JPG是有损压缩格式,可以通过
quality参数灵活控制压缩程度。输出文件名添加了_compressed后缀,这样用户可以方便地区分原图和压缩后的图片。
质量参数的选择界面
为了让用户能够控制压缩程度,我添加了一个滑块来调整质量:
Widget _buildQualitySlider(int quality, StateSetter setState) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('压缩质量: $quality%', style: TextStyle(fontSize: 14.sp)),
Slider(
value: quality.toDouble(),
min: 10,
max: 100,
divisions: 18,
label: '$quality%',
onChanged: (value) {
setState(() {
quality = value.toInt();
});
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('文件更小', style: TextStyle(fontSize: 12.sp, color: Colors.grey)),
Text('质量更好', style: TextStyle(fontSize: 12.sp, color: Colors.grey)),
],
),
],
);
}
滑块的范围设置为10到100,
divisions: 18让滑块有固定的刻度,拖动时会有吸附效果。两端的提示文字帮助用户理解质量参数的含义。这种交互方式比输入数字要直观得多,用户可以实时看到当前选择的质量值。
转换进度的展示
图片转换可能需要一些时间,特别是处理大文件时。我加了一个进度指示器来提升用户体验:
Future<void> _performConversion(String filePath, String format) async {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(20.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16.h),
Text('正在转换...', style: TextStyle(fontSize: 16.sp)),
],
),
),
),
),
);
final success = await _convertImage(filePath, format);
Navigator.pop(context);
_showResult(success);
}
转换过程中显示一个模态对话框,
barrierDismissible: false防止用户点击外部关闭对话框。转换完成后关闭进度对话框,然后显示结果提示。这样的流程让用户清楚地知道应用正在工作,不会觉得卡住了。
结果提示的实现
转换完成后,需要告诉用户结果:
void _showResult(bool success) {
final snackBar = SnackBar(
content: Row(
children: [
Icon(
success ? Icons.check_circle : Icons.error,
color: Colors.white,
),
SizedBox(width: 12.w),
Text(success ? '转换成功!' : '转换失败,请重试'),
],
),
backgroundColor: success ? Colors.green : Colors.red,
duration: Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
我用
SnackBar来显示结果,它会从底部弹出,不会打断用户的操作流程。成功和失败用不同的颜色和图标来区分,一眼就能看出结果。behavior: SnackBarBehavior.floating让提示条浮动显示,看起来更现代。
错误处理与边界情况
在实际使用中,我发现需要处理一些特殊情况。比如用户选择了一个超大的图片文件,或者磁盘空间不足导致保存失败:
Future<bool> _validateFile(XFile file) async {
final bytes = await file.readAsBytes();
final sizeInMB = bytes.length / (1024 * 1024);
if (sizeInMB > 50) {
_showError('文件过大,请选择小于50MB的图片');
return false;
}
final image = img.decodeImage(bytes);
if (image == null) {
_showError('不是有效的图片文件');
return false;
}
return true;
}
这个验证方法会在转换前检查文件大小和有效性。50MB的限制是根据实际测试确定的,太大的文件可能导致内存溢出。提前验证可以避免用户等待很久后才发现转换失败。
权限处理
在鸿蒙系统上,文件访问需要相应的权限。我在module.json5中添加了必要的权限声明:
{
"requestPermissions": [
{
"name": "ohos.permission.READ_MEDIA",
"reason": "需要读取图片文件进行转换"
},
{
"name": "ohos.permission.WRITE_MEDIA",
"reason": "需要保存转换后的图片"
}
]
}
鸿蒙的权限系统和Android类似,需要在配置文件中声明。
reason字段会在申请权限时展示给用户,要写得清楚明白,让用户知道为什么需要这个权限。
性能优化的一些思考
处理大图片时,我发现直接在主线程操作会导致界面卡顿。后来改用compute函数把转换操作放到独立的isolate中执行:
Future<bool> _convertImageAsync(String sourcePath, String targetFormat) async {
try {
final result = await compute(_convertImageInIsolate, {
'sourcePath': sourcePath,
'targetFormat': targetFormat,
});
return result;
} catch (e) {
return false;
}
}
static bool _convertImageInIsolate(Map<String, dynamic> params) {
final sourcePath = params['sourcePath'] as String;
final targetFormat = params['targetFormat'] as String;
// 执行实际的转换逻辑
// ...
return true;
}
compute函数会创建一个新的isolate来执行耗时操作,这样主线程就不会被阻塞了。需要注意的是,传递给isolate的函数必须是顶层函数或静态方法,而且参数只能是简单类型或可序列化的对象。这个改动让应用在处理大文件时依然保持流畅。
实际使用中的体验
这个图片转换功能上线后,我自己也经常用。有一次需要把一批PNG截图转成JPG发给同事,用这个工具批量处理非常方便。不过我也发现了一些可以改进的地方,比如可以支持批量转换,或者添加图片裁剪、旋转等功能。这些都可以作为后续的迭代方向。
从技术角度来说,这个功能涉及到了文件选择、图片处理、异步操作、错误处理等多个方面,是一个比较完整的功能模块。通过这个实战项目,我对Flutter在鸿蒙平台上的文件操作有了更深入的理解。
相关资源
- image包文档: https://pub.dev/packages/image
- file_selector包文档: https://pub.dev/packages/file_selector
- 欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)