声明:本文部分内容使用AI辅助生成,经人工编辑、审核和补充个人经验。
更新说明:本文最后更新于 2025-05-22。
Flutter跨平台开发踩坑记录
去年公司决定用Flutter重构原有的Android和iOS双端项目,我被指派为技术负责人。说实话,之前只写过原生Android,对Flutter完全是零基础。花了两周看文档上手,然后带着两个小伙伴吭哧吭哧干了四个月,终于把项目推上线了。这一路踩的坑,足够写一本小册子。挑几个印象最深的记录一下。
项目里有个需求是调用原生的蓝牙打印机SDK,Flutter官方没有现成的插件,只能自己写Platform Channel。这是我第一次接触Flutter和原生的桥接,折腾了将近一周。
踩坑:主线程阻塞导致UI卡顿
一开始我把蓝牙打印的所有操作都放在了Method Channel里同步执行,打印小票要3秒钟,这3秒钟Flutter的UI完全卡死,用户以为App崩溃了。
1 2 3 4 5 6 7 8 9 10
| class PrinterPlugin { static const platform = MethodChannel('com.example/printer');
static Future<void> printTicket(Map<String, dynamic> ticketData) async { await platform.invokeMethod('printTicket', ticketData); } }
|
原生Android端一开始的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13
| class PrinterPlugin : FlutterPlugin, MethodCallHandler { override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { "printTicket" -> { val data = call.argument<Map<String, Any>>("ticketData") val success = printerManager.printTicket(data) result.success(success) } } } }
|
修正:异步执行 + 进度回调
后来我们改成了Event Channel做进度通知,Method Channel只负责发起任务。
Dart端:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| class PrinterService { static const _methodChannel = MethodChannel('com.example/printer'); static const _eventChannel = EventChannel('com.example/printer/events');
Stream<PrintProgress>? _progressStream;
Stream<PrintProgress> get progressStream { _progressStream ??= _eventChannel .receiveBroadcastStream() .map((event) => PrintProgress.fromMap(event as Map<dynamic, dynamic>)); return _progressStream!; }
Future<String> startPrint(Map<String, dynamic> ticketData) async { final jobId = await _methodChannel.invokeMethod<String>( 'startPrint', {'ticketData': ticketData}, ); return jobId!; }
Future<void> cancelPrint(String jobId) async { await _methodChannel.invokeMethod('cancelPrint', {'jobId': jobId}); } }
class PrintButton extends StatefulWidget { @override _PrintButtonState createState() => _PrintButtonState(); }
class _PrintButtonState extends State<PrintButton> { final _printerService = PrinterService(); double _progress = 0; bool _isPrinting = false;
@override void initState() { super.initState(); _printerService.progressStream.listen((progress) { setState(() { _progress = progress.percent; _isPrinting = progress.status == PrintStatus.printing; }); }); }
Future<void> _handlePrint() async { final ticketData = await _buildTicketData(); await _printerService.startPrint(ticketData); }
@override Widget build(BuildContext context) { return Column( children: [ if (_isPrinting) ...[ LinearProgressIndicator(value: _progress / 100), Text('打印中... ${_progress.toStringAsFixed(0)}%'), ], ElevatedButton( onPressed: _isPrinting ? null : _handlePrint, child: Text(_isPrinting ? '打印中' : '打印小票'), ), ], ); } }
|
原生Android端改成异步执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| class PrinterPlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { private lateinit var context: Context private var eventSink: EventChannel.EventSink? = null private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val activeJobs = mutableMapOf<String, Job>()
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { context = binding.applicationContext MethodChannel(binding.binaryMessenger, "com.example/printer") .setMethodCallHandler(this) EventChannel(binding.binaryMessenger, "com.example/printer/events") .setStreamHandler(this) }
override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { "startPrint" -> { val data = call.argument<Map<String, Any>>("ticketData") val jobId = UUID.randomUUID().toString()
val job = scope.launch(Dispatchers.IO) { try { printerManager.printTicket(data) { progress, total -> val percent = (progress * 100 / total).toDouble() eventSink?.success(mapOf( "jobId" to jobId, "status" to "printing", "percent" to percent, "current" to progress, "total" to total )) } eventSink?.success(mapOf( "jobId" to jobId, "status" to "completed", "percent" to 100.0 )) } catch (e: Exception) { eventSink?.success(mapOf( "jobId" to jobId, "status" to "failed", "error" to e.message )) } } activeJobs[jobId] = job result.success(jobId) } "cancelPrint" -> { val jobId = call.argument<String>("jobId") activeJobs[jobId]?.cancel() activeJobs.remove(jobId) result.success(null) } else -> result.notImplemented() } }
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { eventSink = events }
override fun onCancel(arguments: Any?) { eventSink = null } }
|
改完之后UI完全不会卡顿,用户能看到打印进度条,还能中途取消。这个架构后来被我们复用到其他原生功能(扫码、NFC、指纹识别)上,成了一整套原生桥接的规范。
性能优化:列表卡顿和图片OOM
Flutter的列表性能是个老生常谈的问题。我们项目有个商品列表页,每个卡片有图片、价格、标签,滑动的时候在低端机上明显掉帧。
踩坑:ListView.builder没用对,图片没做缓存
一开始我们直接用了ListView,不是ListView.builder,这意味着所有列表项一次性全部构建,内存爆炸。
1 2 3 4
| ListView( children: productList.map((product) => ProductCard(product: product)).toList(), )
|
图片方面,我们直接用Image.network加载网络图片,没有做尺寸限制和缓存管理。结果列表滑动时不断创建新的Image对象,内存一直涨,最后触发OOM。
修正:ListView.builder + 图片缓存 + 尺寸优化
1 2 3 4 5 6 7 8 9 10 11
| ListView.builder( itemCount: productList.length, itemBuilder: (context, index) { return ProductCard(product: productList[index]); }, cacheExtent: 200, addAutomaticKeepAlives: false, addRepaintBoundaries: true, )
|
图片组件做了多层优化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| class OptimizedImage extends StatelessWidget { final String url; final double width; final double height;
const OptimizedImage({ required this.url, required this.width, required this.height, });
@override Widget build(BuildContext context) { return Image.network( url, width: width, height: height, fit: BoxFit.cover, cacheWidth: (width * MediaQuery.of(context).devicePixelRatio).toInt(), cacheHeight: (height * MediaQuery.of(context).devicePixelRatio).toInt(), loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; return Container( width: width, height: height, color: Colors.grey[200], child: Center( child: CircularProgressIndicator( value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, ), ), ); }, errorBuilder: (context, error, stackTrace) { return Container( width: width, height: height, color: Colors.grey[300], child: Icon(Icons.broken_image, color: Colors.grey[600]), ); }, ); } }
|
另外我们在pubspec.yaml里配置了图片缓存策略:
1 2 3 4 5 6 7 8 9 10
| dependencies: flutter: sdk: flutter cached_network_image: ^3.3.0
dependency_overrides: flutter:
|
对于更复杂的场景,我们用了cached_network_image插件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| import 'package:cached_network_image/cached_network_image.dart';
class ProductCard extends StatelessWidget { final Product product;
const ProductCard({required this.product});
@override Widget build(BuildContext context) { return Card( child: Column( children: [ CachedNetworkImage( imageUrl: product.imageUrl, width: double.infinity, height: 200, fit: BoxFit.cover, memCacheWidth: 800, maxHeightDiskCache: 800, placeholder: (context, url) => Container( height: 200, color: Colors.grey[200], child: Center(child: CircularProgressIndicator()), ), errorWidget: (context, url, error) => Container( height: 200, color: Colors.grey[300], child: Icon(Icons.error), ), ), Padding( padding: EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(product.name, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), SizedBox(height: 8), Text('¥${product.price}', style: TextStyle(fontSize: 18, color: Colors.red)), ], ), ), ], ), ); } }
|
改完之后列表滑动帧率从25fps提升到了55fps以上,内存占用也稳定了很多。
热更新:国内用不了Code Push,只能自己想办法
Flutter官方的热更新方案(Code Push)在国内基本不可用,但我们业务又需要紧急修复线上bug的能力。调研了一圈之后,我们搞了一套基于资源替换的热更新方案。
踩坑:直接替换dart文件不可行
Flutter的dart代码是编译成机器码的,不能像React Native那样直接下发JS文件替换。我们一开始想替换so文件,但Android上so文件被加载后无法替换,iOS更是有签名校验。
修正:Dart侧动态化 + 原生补丁
我们的方案分两部分:
- Dart侧:把可能变化的业务逻辑抽象成配置化的Widget树,下发JSON配置动态渲染
- 原生侧:对于必须改原生代码的bug,走原生热修复(Android用Tinker,iOS用JSPatch的替代方案)
Dart侧动态化实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
| class DynamicWidgetRenderer { static Widget build(Map<String, dynamic> config, BuildContext context) { final type = config['type'] as String; final props = config['props'] as Map<String, dynamic>? ?? {}; final children = config['children'] as List<dynamic>? ?? [];
switch (type) { case 'Container': return Container( padding: _parseEdgeInsets(props['padding']), margin: _parseEdgeInsets(props['margin']), color: _parseColor(props['color']), child: children.isNotEmpty ? build(children.first, context) : null, ); case 'Text': return Text( props['text'] ?? '', style: TextStyle( fontSize: props['fontSize']?.toDouble(), color: _parseColor(props['color']), fontWeight: props['bold'] == true ? FontWeight.bold : null, ), ); case 'Button': return ElevatedButton( onPressed: () => _handleAction(props['action'], context), child: children.isNotEmpty ? build(children.first, context) : Text(''), ); case 'Column': return Column( mainAxisAlignment: _parseMainAxisAlignment(props['mainAxisAlignment']), children: children.map((c) => build(c, context)).toList(), ); case 'Row': return Row( children: children.map((c) => build(c, context)).toList(), ); default: return SizedBox.shrink(); } }
static void _handleAction(Map<String, dynamic>? action, BuildContext context) { if (action == null) return; final type = action['type'] as String?; switch (type) { case 'navigate': Navigator.pushNamed(context, action['route']); break; case 'openUrl': launchUrl(Uri.parse(action['url'])); break; case 'apiCall': _callApi(action['endpoint'], action['params']); break; } }
static Color? _parseColor(String? colorStr) { if (colorStr == null) return null; return Color(int.parse(colorStr.replaceFirst('#', '0xFF'))); }
static EdgeInsets? _parseEdgeInsets(dynamic value) { if (value == null) return null; if (value is num) { return EdgeInsets.all(value.toDouble()); } if (value is Map) { return EdgeInsets.fromLTRB( value['left']?.toDouble() ?? 0, value['top']?.toDouble() ?? 0, value['right']?.toDouble() ?? 0, value['bottom']?.toDouble() ?? 0, ); } return null; }
static MainAxisAlignment _parseMainAxisAlignment(String? value) { switch (value) { case 'center': return MainAxisAlignment.center; case 'end': return MainAxisAlignment.end; case 'spaceBetween': return MainAxisAlignment.spaceBetween; case 'spaceAround': return MainAxisAlignment.spaceAround; default: return MainAxisAlignment.start; } } }
|
JSON配置示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| { "type": "Container", "props": { "padding": 16, "color": "#FFFFFF" }, "children": [ { "type": "Text", "props": { "text": "限时优惠活动", "fontSize": 20, "bold": true, "color": "#FF5722" } }, { "type": "Button", "props": { "action": { "type": "navigate", "route": "/promotion" } }, "children": [ { "type": "Text", "props": { "text": "立即参与", "color": "#FFFFFF" } } ] } ] }
|
热更新检查逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| class HotUpdateManager { static const String _updateUrl = 'https://api.example.com/app/config'; static const String _localVersionKey = 'dynamic_config_version';
static Future<void> checkUpdate() async { try { final prefs = await SharedPreferences.getInstance(); final localVersion = prefs.getInt(_localVersionKey) ?? 0;
final response = await http.get(Uri.parse('$_updateUrl?v=$localVersion')); if (response.statusCode != 200) return;
final data = jsonDecode(response.body); final remoteVersion = data['version'] as int;
if (remoteVersion > localVersion) { final configJson = jsonEncode(data['config']); await prefs.setString('dynamic_config', configJson); await prefs.setInt(_localVersionKey, remoteVersion);
EventBus().emit('config_updated'); } } catch (e) { print('热更新检查失败: $e'); } }
static Map<String, dynamic>? getLocalConfig() { return null; } }
|
这个方案只能处理UI布局和简单交互的动态化,对于Dart逻辑代码的修改无能为力。但对于运营活动页、弹窗、公告这类场景已经够用了。真正的逻辑bug还是得发版,或者走原生热修复兜底。
打包发布:Androidaab和iOS签名踩坑
Flutter的打包流程本身不复杂,但和原生项目混在一起的时候问题不少。
踩坑:Android aab打包后某些so文件缺失
我们项目用了几个Flutter插件,这些插件依赖了不同的ABI。用flutter build appbundle打包后,在arm64设备上运行正常,但在某些armv7设备上崩溃,报错找不到libflutter.so。
排查了半天,发现是build.gradle里ndk.abiFilters配置的问题:
1 2 3 4 5 6 7 8
| android { defaultConfig { ndk { abiFilters 'arm64-v8a' } } }
|
修正:正确配置ABI过滤
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| android { defaultConfig { ndk { abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' } }
buildTypes { release { signingConfig signingConfigs.release minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } }
|
另外aab包在Google Play上会自动按ABI拆分,但国内应用市场不一定支持。我们同时打了apk和aab两个包:
1 2 3 4 5
| flutter build appbundle --release
flutter build apk --release --split-per-abi
|
iOS签名和发布
iOS的签名比Android麻烦得多。我们用了Codemagic做CI/CD自动打包,但第一次配置证书的时候搞了一整天。
关键步骤:
- 在Apple Developer后台创建App ID、Distribution Certificate、Provisioning Profile
- 把证书和私钥导出为.p12文件,上传到Codemagic
- 配置codemagic.yaml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| workflows: ios-release: name: iOS Release Build environment: flutter: stable xcode: latest cocoapods: default scripts: - name: Install dependencies script: flutter pub get - name: Build iOS script: flutter build ios --release --no-codesign - name: Sign and export script: | xcodebuild -workspace ios/Runner.xcworkspace \ -scheme Runner \ -configuration Release \ -archivePath build/Runner.xcarchive \ archive xcodebuild -exportArchive \ -archivePath build/Runner.xcarchive \ -exportPath build/Runner.ipa \ -exportOptionsPlist ios/ExportOptions.plist artifacts: - build/Runner.ipa publishing: app_store_connect: api_key: $APP_STORE_CONNECT_KEY key_id: $APP_STORE_KEY_ID issuer_id: $APP_STORE_ISSUER_ID
|
总结
四个月Flutter开发下来,我的总体感受是:Flutter确实能大幅提升跨平台开发效率,但绝不是”写一次到处运行”那么美好。原生能力、平台差异、性能优化这些坑一个都躲不掉。
几点建议给准备入坑的同学:
- Platform Channel要设计好异步模型,别在主线程做耗时操作
- 列表性能要早优化,ListView.builder、图片缓存、RepaintBoundary都用上
- 热更新要有合理预期,国内环境别指望官方方案,自己搞一套轻量级的
- 打包配置要仔细测试,不同ABI、不同签名方式都要验证
- 混编项目要管理好依赖,Flutter和原生的Gradle/Podfile版本冲突很头疼
我们现在项目已经稳定运行了半年多,双端代码复用率大概在75%左右,开发效率比原来两个原生团队并行开发提升了将近一倍。虽然过程中踩了很多坑,但回头看还是值得的。