ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Flutter跨平台开发:图片视频保存到手机相册的完整解决方案

2026/8/9 4:03:50 拓冰建站 浏览量
Flutter跨平台开发:图片视频保存到手机相册的完整解决方案 如果你是一名移动端开发者或者正在开发一个需要处理用户多媒体内容的App那么“如何将图片和视频保存到手机相册”这个问题你一定遇到过。这看似是一个简单的功能但背后却是一个典型的“开发陷阱”它横跨了文件系统、权限管理、媒体库API、不同操作系统iOS/Android的差异以及用户体验等多个层面。很多开发者第一次实现时往往会卡在“权限被拒绝”、“文件保存成功但在相册里找不到”、“保存大视频时应用卡死”这些具体问题上。这篇文章要解决的正是这个看似基础但暗藏玄机的功能。我们将以Flutter框架为例因为它能很好地体现跨平台开发中的共性与差异。但本文的核心逻辑和解决方案对于原生AndroidJava/Kotlin、原生iOSSwift以及React Native、UniApp等跨平台框架的开发者都具有直接的参考价值。你将不仅学会“怎么做”更重要的是理解“为什么这么做”以及如何避开那些新手和老手都可能踩的坑。我们将从一个完整的、可运行的Flutter示例项目出发拆解从权限申请、文件获取、到调用原生API保存至相册的每一步。同时我们会深入探讨几个关键问题权限的动态申请与处理逻辑、Android QAPI 29及以上版本作用域存储Scoped Storage带来的根本性变化、iOS相册的“最近项目”与“自定义相簿”的区别以及如何实现后台批量上传而不阻塞UI。最后我们还会讨论生产环境中必须考虑的用户体验优化和错误恢复机制。1. 核心问题为什么“保存到相册”不是简单的文件拷贝在深入代码之前我们必须先建立一个正确的认知将图片/视频保存到手机相册本质上不是一个文件复制File Copy操作而是一个向系统媒体库MediaStore插入一条新记录的操作。这个区别至关重要。在早期的Android系统中应用拥有外部存储的读写权限后确实可以直接将文件复制到DCIM或Pictures等公共目录。但这种方式存在严重问题污染用户存储空间应用随意创建文件夹导致目录混乱。安全与隐私风险应用可以扫描其他应用创建的媒体文件。文件管理混乱即使文件被删除媒体库的索引可能还残留记录导致“幽灵文件”。因此现代操作系统Android 10 的Scoped Storage iOS的Photos Framework都采用了更严格的媒体文件管理模型Android (Scoped Storage): 应用通过MediaStoreAPI在系统分配的特定目录如Pictures/YourAppName/内创建文件或通过系统文件选择器将文件保存到公共目录。应用不能直接访问其他应用创建的文件路径。iOS (Photos Framework): 应用必须通过PHPhotoLibrary请求权限并使用PHAssetChangeRequest来创建或修改相册中的资源。应用无法直接获知文件在设备上的真实物理路径。理解了这一点我们就知道实现上传到相册的功能核心是与系统媒体库API进行交互而不是操作原始文件路径。2. 环境准备与项目依赖我们使用Flutter进行演示因为它需要同时处理Android和iOS两端的原生接口挑战更全面结论也更普适。2.1 开发环境Flutter SDK: 版本 3.0 (推荐使用稳定版)开发工具: Android Studio, VS Code 或 IntelliJ IDEA 均可。目标平台:Android:minSdkVersion 21 (建议23以支持运行时权限)targetSdkVersion 33 (必须适配Scoped Storage)。iOS:Deployment Target 11.0。2.2 添加必要的依赖在项目的pubspec.yaml文件中添加以下依赖。我们选择社区维护良好、功能全面的插件。dependencies: flutter: sdk: flutter # 核心插件用于保存图片/视频到相册并处理权限 image_gallery_saver: ^2.0.2 # 注意此插件主要优势在保存对于iOS相簿管理较弱 # 或使用更全面的 media_kit它集成了更多功能 # media_kit: ^1.0.0 # 权限申请插件 permission_handler: ^11.0.1 # 用于图片拾取从相册选择或拍照 image_picker: ^1.0.4 # 用于显示保存进度或状态可选 fluttertoast: ^8.2.4 # 用于处理可能需要的文件操作如从网络下载后保存 path_provider: ^2.1.0 http: ^1.1.0 # 如果需要从网络下载运行flutter pub get安装依赖。2.3 配置原生平台Android配置 (android/app/src/main/AndroidManifest.xml):manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.yourapp !-- 网络权限如果需要从网络下载图片/视频 -- uses-permission android:nameandroid.permission.INTERNET / !-- 外部存储写入权限Android 9及以下需要 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 / !-- 外部存储读取权限Android 9及以下需要用于读取已保存的文件 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / !-- Android 13 (API 33) 及以上需要单独的媒体权限 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO / !-- Android 14 (API 34) 及以上如果需要所有照片和视频的完全访问权限 -- !-- uses-permission android:nameandroid.permission.READ_MEDIA_VISUAL_USER_SELECTED / -- application android:labelYourApp android:iconmipmap/ic_launcher activity ... ... /activity !-- 可选指定FileProvider用于在应用间共享文件如调用系统图片选择器后 -- provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider /application /manifest重要maxSdkVersion属性是关键。它告诉系统对于Android 10API 29及以上设备应用将使用Scoped Storage不再需要WRITE_EXTERNAL_STORAGE权限。对于Android 13的媒体权限则需要根据应用实际需要声明。创建android/app/src/main/res/xml/file_paths.xml如果不存在?xml version1.0 encodingutf-8? paths external-path nameexternal_files path. / cache-path namecache_files path. / !-- 可以根据需要添加其他路径 -- /pathsiOS配置 (ios/Runner/Info.plist):?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict ... !-- 相册写入权限描述 -- keyNSPhotoLibraryAddUsageDescription/key string我们需要将您选择的图片和视频保存到相册/string !-- 相册读取权限描述如果还需要从相册读取 -- keyNSPhotoLibraryUsageDescription/key string我们需要访问您的相册来选择图片和视频/string !-- 相机权限描述如果需要拍照/录像 -- keyNSCameraUsageDescription/key string我们需要使用相机来拍摄照片和视频/string !-- 麦克风权限描述如果需要录像录音 -- keyNSMicrophoneUsageDescription/key string我们需要使用麦克风来录制视频声音/string ... /dict /plist注意NSPhotoLibraryAddUsageDescription是仅写入权限的描述用户允许后应用只能向相册添加内容不能读取已有内容。如果需要读取必须同时添加NSPhotoLibraryUsageDescription。这些描述字符串会直接显示给用户务必清晰、友好。3. 权限处理动态申请与优雅降级权限是上传功能的第一道关卡。处理不好直接崩溃或功能不可用。3.1 理解分层级的权限模型Android: Android 6.0 (API 23): 安装时授权Install-time在AndroidManifest.xml中声明即获得。 Android 6.0: 运行时权限Runtime。危险权限如存储、相机必须在应用运行时动态向用户申请。 Android 10 (API 29): Scoped Storage。WRITE_EXTERNAL_STORAGE权限对媒体文件的写入行为失效改为使用MediaStoreAPI。 Android 13 (API 33): 媒体权限细分。将READ_EXTERNAL_STORAGE细分为READ_MEDIA_IMAGES,READ_MEDIA_VIDEO,READ_MEDIA_AUDIO。保存到相册通常只需要写入不需要这些读取权限除非你要处理相册中已有的文件。iOS:所有涉及隐私的权限相册、相机、位置等均为运行时申请。相册权限分为读和写(NSPhotoLibraryUsageDescription)和仅添加(NSPhotoLibraryAddUsageDescription)。如果应用只需要保存申请“仅添加”权限通过率更高对用户更友好。3.2 实现统一的权限检查与申请逻辑我们使用permission_handler插件来统一处理跨平台的权限逻辑。创建一个权限工具类permission_utils.dartimport package:permission_handler/permission_handler.dart; class PermissionUtils { /// 检查并申请保存媒体文件到相册所需的权限 /// 返回 true 表示已授权false 表示被拒绝或需要引导用户去设置页 static Futurebool requestMediaSavePermission() async { PermissionStatus status; if (Platform.isAndroid) { // Android 13 使用新的媒体权限。但注意SAVE到相册严格来说不需要READ权限。 // 然而很多插件如image_picker或场景先选择再保存需要READ权限。 // 这里我们根据常见场景申请存储或媒体权限。 final androidVersion await DeviceInfoPlugin().androidInfo.then((info) info.version.sdkInt); if (androidVersion 33) { // Android 13申请媒体权限如果应用需要读取 // 如果仅保存理论上不需要。但为了兼容性可以尝试申请。 status await Permission.photos.request(); if (status.isDenied || status.isPermanentlyDenied) { // 如果用户拒绝我们还可以尝试使用“仅添加”权限吗在Android上没有单独的“仅添加”权限。 // 对于保存功能我们可以直接尝试调用MediaStore API系统可能会弹出系统对话框。 // 更稳妥的做法引导用户去设置页开启“照片和视频”权限。 return false; } } else if (androidVersion 29) { // Android 10-12Scoped StorageWRITE_EXTERNAL_STORAGE权限已废弃maxSdkVersion28。 // 实际上对于保存到MediaStore从Android 10开始不再需要此权限。 // 但一些旧插件或方法可能仍会检查。我们直接返回true让系统API去处理。 return true; } else { // Android 9及以下需要WRITE_EXTERNAL_STORAGE权限 status await Permission.storage.request(); } } else if (Platform.isIOS) { // iOS: 申请“仅添加到相册”权限这对用户更友好。 status await Permission.photosAddOnly.request(); if (status.isDenied || status.isPermanentlyDenied) { // 如果“仅添加”被拒可以尝试申请完整的相册权限读和写 // status await Permission.photos.request(); // 但通常如果用户拒绝了“仅添加”也可能拒绝“读写”。 return false; } } else { // 其他平台如Web可能不需要权限或处理方式不同。 return true; } return status.isGranted || status.isLimited; } /// 引导用户跳转到应用设置页面 static Futurevoid openAppSettings() async { await openAppSettings(); } }关键点分析Android版本分支代码根据SDK版本进行了分支处理这是正确处理Android权限兼容性的关键。iOS的“仅添加”权限优先使用Permission.photosAddOnly这符合“最小权限原则”也更容易获得用户同意。权限状态处理isGranted已授权、isDenied本次拒绝、isPermanentlyDenied永久拒绝需跳转设置。对于永久拒绝必须提供引导用户前往系统设置页的入口。3.3 在UI中集成权限申请在调用保存功能前先进行权限检查。Futurevoid _saveImageToGallery() async { bool hasPermission await PermissionUtils.requestMediaSavePermission(); if (!hasPermission) { // 权限被拒绝提示用户并引导开启 showDialog( context: context, builder: (ctx) AlertDialog( title: Text(权限不足), content: Text(保存图片到相册需要您授予相册访问权限。请前往系统设置中开启权限。), actions: [ TextButton( onPressed: () Navigator.pop(ctx), child: Text(取消), ), TextButton( onPressed: () { Navigator.pop(ctx); PermissionUtils.openAppSettings(); // 跳转设置 }, child: Text(去设置), ), ], ), ); return; } // 权限已获取继续执行保存逻辑... await _performSave(); }4. 核心流程拆解从文件到相册保存媒体文件到相册的完整流程可以拆解为以下几步获取源文件可能是从网络下载的Uint8List、设备拍照/录像产生的File对象、应用内生成的图片数据等。准备文件数据将源数据转换为插件或平台API能接受的格式如Uint8List,File,Asset。调用保存API使用插件如image_gallery_saver或平台通道调用原生代码。处理保存结果接收成功或失败的回调获取保存后的文件信息如Android上的uriiOS上的localIdentifier。更新UI与反馈根据结果给用户提示Toast、SnackBar等。5. 完整示例实现图片与视频保存功能我们将创建一个简单的Flutter页面包含两个按钮一个保存网络图片一个保存本地视频文件。5.1 主页面UI构建 (main.dart或独立页面)import package:flutter/material.dart; import package:image_gallery_saver/image_gallery_saver.dart; import package:permission_handler/permission_handler.dart; import package:http/http.dart as http; import package:path_provider/path_provider.dart; import dart:io; import package:fluttertoast/fluttertoast.dart; class MediaSavePage extends StatefulWidget { override _MediaSavePageState createState() _MediaSavePageState(); } class _MediaSavePageState extends StateMediaSavePage { bool _isSaving false; override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(保存到相册示例)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( onPressed: _isSaving ? null : _saveNetworkImage, child: Text(保存网络图片到相册), ), SizedBox(height: 20), ElevatedButton( onPressed: _isSaving ? null : _saveLocalVideo, child: Text(保存本地视频到相册), ), SizedBox(height: 20), if (_isSaving) CircularProgressIndicator(), ], ), ), ); } Futurevoid _saveNetworkImage() async { setState(() _isSaving true); try { // 1. 检查权限 var status await Permission.photosAddOnly.request(); if (!status.isGranted !status.isLimited) { Fluttertoast.showToast(msg: 权限被拒绝无法保存); return; } // 2. 从网络下载图片 const imageUrl https://example.com/your-sample-image.jpg; // 替换为真实URL final response await http.get(Uri.parse(imageUrl)); if (response.statusCode ! 200) { throw Exception(下载图片失败: ${response.statusCode}); } final imageBytes response.bodyBytes; // 3. 调用插件保存到相册 final result await ImageGallerySaver.saveImage( Uint8List.fromList(imageBytes), quality: 90, // 可选图片质量 name: flutter_saved_image_${DateTime.now().millisecondsSinceEpoch}, // 自定义文件名不含扩展名 ); // 4. 处理结果 if (result[isSuccess] true) { Fluttertoast.showToast(msg: 图片保存成功); print(文件保存路径: ${result[filePath]}); // Android返回路径iOS返回的是相册标识 } else { Fluttertoast.showToast(msg: 图片保存失败: ${result[errorMessage]}); } } catch (e) { Fluttertoast.showToast(msg: 发生错误: $e); print(e); } finally { setState(() _isSaving false); } } Futurevoid _saveLocalVideo() async { setState(() _isSaving true); try { // 1. 检查权限 var status await Permission.photosAddOnly.request(); if (!status.isGranted !status.isLimited) { Fluttertoast.showToast(msg: 权限被拒绝无法保存); return; } // 2. 准备一个本地视频文件这里模拟从assets复制实际可能来自拍摄、下载等 // 假设我们在assets/videos/sample.mp4有一个视频 // 为了演示我们创建一个临时文件来模拟本地视频 final tempDir await getTemporaryDirectory(); final videoFile File(${tempDir.path}/sample_video.mp4); // 注意这里需要你有一个真实的视频文件放在指定路径或者从网络下载一个。 // 下面是一个模拟如果文件不存在则创建一个空文件仅用于演示流程实际需要真实视频数据 if (!await videoFile.exists()) { // 在实际应用中这里应该是从assets加载、从网络下载或从相册选择的文件 // 此处仅为演示占位 Fluttertoast.showToast(msg: 演示视频文件不存在请准备一个.mp4文件); return; } // 3. 调用插件保存视频到相册 final result await ImageGallerySaver.saveFile( videoFile.path, // 可选参数isReturnPathOfIOS (iOS是否返回路径默认false返回的是相册标识符) ); // 4. 处理结果 if (result[isSuccess] true) { Fluttertoast.showToast(msg: 视频保存成功); print(保存结果: $result); } else { Fluttertoast.showToast(msg: 视频保存失败: ${result[errorMessage]}); } } catch (e) { Fluttertoast.showToast(msg: 发生错误: $e); print(e); } finally { setState(() _isSaving false); } } }5.2 代码关键点解析权限检查时机在每次执行保存操作前都检查权限因为用户可能随时在系统设置中关闭权限。ImageGallerySaver的使用saveImage(Uint8List bytes, ...): 用于保存图片字节数据。saveFile(String filePath, ...): 用于保存已存在于设备存储中的文件如图片文件、视频文件。quality: 仅对JPEG图片有效控制压缩质量。name: 自定义文件名不带扩展名系统会自动添加.jpg或.png等扩展名。结果处理result是一个Map。关键字段isSuccess表示是否成功。filePath在Android上通常是文件在存储中的路径但应用可能无法直接访问在iOS上可能是null或相册标识。不要依赖filePath进行后续文件操作。错误处理用try-catch包裹核心逻辑捕获网络异常、IO异常、平台调用异常等并给用户友好的提示。6. 进阶话题处理大文件、后台任务与iOS相簿6.1 大文件保存与进度反馈保存大视频文件如几百MB可能耗时较长会阻塞UI。我们应该在Isolate中执行或使用后台任务。使用flutter_downloader或workmanager进行后台保存概念示例// 这是一个简化概念实际需要配置后台任务插件 Futurevoid _saveLargeVideoInBackground(String videoUrl) async { // 1. 下载文件到临时目录可使用后台下载插件 // 2. 获取文件路径后通过平台通道调用原生代码在后台执行MediaStore插入操作 // 注意iOS后台执行有限制可能需要申请后台处理权限。 }更实用的做法是在前台显示一个进度指示器并在saveFile时注意它是在主Isolate中执行的IO操作。对于超大文件建议提示用户“正在处理请稍候”并确保应用不会因此ANRApplication Not Responding。6.2 iOS保存到自定义相簿Album默认情况下image_gallery_saver会将媒体保存到系统的“最近项目”Camera Roll。如果希望将图片/视频保存到应用自己创建的自定义相簿需要使用更底层的平台通道或使用其他插件如photo_manager。使用photo_manager创建相簿并保存添加依赖photo_manager: ^3.0.0申请相册读写权限Permission.photos。代码示例import package:photo_manager/photo_manager.dart; Futurevoid _saveToCustomAlbum() async { // 获取权限 final permitted await PhotoManager.requestPermissionExtend(); if (!permitted.isAuth) { // 处理权限拒绝 return; } // 查找或创建名为“MyFlutterApp”的相簿 final albums await PhotoManager.getAssetPathList( type: RequestType.image, // 或 RequestType.video, RequestType.common filterOption: FilterOptionGroup(), ); AssetPathEntity? customAlbum; for (var album in albums) { if (album.name MyFlutterApp) { customAlbum album; break; } } if (customAlbum null) { // 创建新相簿 customAlbum await PhotoManager.createAssetPath(MyFlutterApp); } // 假设我们有一个图片文件 File imageFile ...; // 将文件保存到该相簿 final asset await PhotoManager.editor.saveImage( imageFile.readAsBytesSync(), title: My Image, // 文件名 relativePath: MyFlutterApp/, // 相对路径在相簿内 ); // 或者使用 saveImageWithPath 保存到指定相簿 // final asset await customAlbum.saveImage(imageFile.readAsBytesSync(), title: My Image); if (asset ! null) { print(图片已保存到自定义相簿asset id: ${asset.id}); } }注意photo_manager功能强大但API更复杂适合需要精细管理相册内容的场景。7. 常见问题与排查思路问题现象可能原因排查方式解决方案Android保存成功但相册里不显示1. 媒体库未刷新。2. 文件被保存到了应用私有目录未插入MediaStore。3. Android Q文件未放入Pictures或DCIM等媒体集合目录。1. 重启手机或使用MediaScannerConnection扫描文件。2. 检查result[filePath]看路径是否在公共媒体目录下。3. 使用adb shell查看文件是否真实存在。1. 调用MediaScannerConnection.scanFile扫描文件路径Android。2. 确保使用MediaStoreAPI或image_gallery_saver这类正确插入记录的插件。3. 对于Android Q确保使用MediaStore.Images.Media.EXTERNAL_CONTENT_URI进行插入。iOS保存成功返回isSuccess: true但相册里找不到1. 保存到了“最近项目”但用户查看的是其他相簿。2. 应用只有“仅添加”权限保存后需要手动刷新相册App。3. 模拟器有时同步延迟。1. 检查是否在“最近项目”中。2. 完全退出并重新打开“照片”App。3. 在真机上测试。1. 引导用户到“最近项目”查看。2. 如果需指定相簿使用photo_manager。3. 对于“仅添加”权限保存后系统可能不会立即刷新属于正常现象。权限已授权但保存时仍报“权限被拒绝”1. Android:targetSdkVersion 30但未适配Scoped Storage却声明了WRITE_EXTERNAL_STORAGE权限。2. iOS: 申请的是NSPhotoLibraryUsageDescription但实际需要NSPhotoLibraryAddUsageDescription。3. 插件内部实现有bug或版本不兼容。1. 检查AndroidManifest.xml中权限的maxSdkVersion。2. 检查iOS的Info.plist描述字段。3. 查看插件issue和版本日志。1. 确保Android权限声明正确见上文配置。2. 确保iOS描述文件匹配申请的权限类型。3. 升级插件到最新稳定版或尝试其他插件。保存大视频时应用卡死或崩溃1. UI线程被阻塞主Isolate进行大量IO。2. 内存不足OOM。1. 使用性能分析工具查看线程状态。2. 查看Logcat或Xcode控制台的内存警告。1. 将保存操作放入Isolate或使用后台任务。2. 分块处理大文件或提示用户文件过大。3. 优化视频编码或压缩后再保存如果允许。Android上filePath返回null或不可访问从Android 10开始通过MediaStore插入的文件返回的URI可能无法直接映射为file://路径应用可能没有直接文件访问权限。打印完整的resultMap查看返回的URI格式。不要依赖文件路径。如果需要再次使用该媒体文件应通过ContentResolver和返回的uri来打开输入流。保存成功后如果仅用于显示“保存成功”路径信息已足够。8. 最佳实践与工程建议权限申请策略适时申请在用户触发保存操作时再申请权限并提供清晰的解释通过Info.plist或AndroidManifest的权限描述。优雅降级如果用户拒绝权限应提供明确的引导如“保存功能需要相册权限您可以在设置中开启”并禁用相关功能而不是让应用崩溃。遵循最小权限iOS上优先申请NSPhotoLibraryAddUsageDescription。文件处理清理临时文件从网络下载或处理生成的临时文件在保存到相册后应及时删除避免占用用户存储空间。文件名管理为保存的文件生成有意义的、避免重复的文件名如包含时间戳、用户ID等。格式支持明确告知用户支持的图片JPEG, PNG, WebP等和视频MP4, MOV等格式。用户体验提供反馈保存开始、进行中、成功、失败都应有明确的UI反馈加载框、Toast、SnackBar。处理耗时操作对于大文件务必在后台执行防止UI卡顿。可以提供进度指示。错误信息友好化将底层的平台错误代码转换为用户能理解的语言如“存储空间不足”、“网络连接失败”。兼容性与测试多版本测试必须在不同Android版本特别是10和13和iOS版本上进行测试。真机测试模拟器在权限和相册行为上可能与真机有差异。存储空间检查在保存前可以检查设备剩余存储空间避免因空间不足导致失败。安全与隐私用户数据确保只保存用户明确授权保存的媒体文件。敏感信息避免在文件名或媒体元数据中泄露用户隐私信息。外部输入对从网络下载的媒体文件进行安全检查如文件头校验防止恶意文件。实现一个健壮的“上传到相册”功能远不止调用一个API那么简单。它要求开发者深入理解不同移动操作系统的存储和权限模型变迁。本文以Flutter为舞台详细演绎了从权限动态申请、平台差异处理、具体代码实现到疑难问题排查的完整流程。关键在于抓住核心与系统媒体库协作而非直接操作文件系统。对于Android开发者请务必吃透Scoped Storage并正确配置AndroidManifest.xml。对于iOS开发者理解Photos Framework和两种权限描述的区别至关重要。无论使用哪种跨平台框架最终都需回归到原生平台的规范上。建议你将本文中的权限工具类、保存函数封装成独立的服务模块并在项目中统一调用。在发布前务必在尽可能多的真机设备上进行完整的功能和兼容性测试。保存功能虽小却直接影响用户对应用可靠性和专业度的感知值得投入精力将其打磨完善。