Flutter跨平台开发踩坑记录

声明:本文部分内容使用AI辅助生成,经人工编辑、审核和补充个人经验。

更新说明:本文最后更新于 2025-05-22。

Flutter跨平台开发踩坑记录

去年公司决定用Flutter重构原有的Android和iOS双端项目,我被指派为技术负责人。说实话,之前只写过原生Android,对Flutter完全是零基础。花了两周看文档上手,然后带着两个小伙伴吭哧吭哧干了四个月,终于把项目推上线了。这一路踩的坑,足够写一本小册子。挑几个印象最深的记录一下。

Flutter与原生交互:Platform Channel不是万能的

项目里有个需求是调用原生的蓝牙打印机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);
// 如果原生端同步执行打印,这里会卡3秒
}
}

原生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()

// 在IO线程执行打印,主线程立即返回jobId
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会一次性构建所有子项
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按需构建
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
# pubspec.yaml
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侧动态化 + 原生补丁

我们的方案分两部分:

  1. Dart侧:把可能变化的业务逻辑抽象成配置化的Widget树,下发JSON配置动态渲染
  2. 原生侧:对于必须改原生代码的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
// 动态Widget渲染器
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);

// 通知UI刷新
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.gradlendk.abiFilters配置的问题:

1
2
3
4
5
6
7
8
// 错误配置:只打包arm64
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
# 构建aab(用于Google Play)
flutter build appbundle --release

# 构建apk(用于国内应用市场)
flutter build apk --release --split-per-abi

iOS签名和发布

iOS的签名比Android麻烦得多。我们用了Codemagic做CI/CD自动打包,但第一次配置证书的时候搞了一整天。

关键步骤:

  1. 在Apple Developer后台创建App ID、Distribution Certificate、Provisioning Profile
  2. 把证书和私钥导出为.p12文件,上传到Codemagic
  3. 配置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
# codemagic.yaml
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确实能大幅提升跨平台开发效率,但绝不是”写一次到处运行”那么美好。原生能力、平台差异、性能优化这些坑一个都躲不掉。

几点建议给准备入坑的同学:

  1. Platform Channel要设计好异步模型,别在主线程做耗时操作
  2. 列表性能要早优化,ListView.builder、图片缓存、RepaintBoundary都用上
  3. 热更新要有合理预期,国内环境别指望官方方案,自己搞一套轻量级的
  4. 打包配置要仔细测试,不同ABI、不同签名方式都要验证
  5. 混编项目要管理好依赖,Flutter和原生的Gradle/Podfile版本冲突很头疼

我们现在项目已经稳定运行了半年多,双端代码复用率大概在75%左右,开发效率比原来两个原生团队并行开发提升了将近一倍。虽然过程中踩了很多坑,但回头看还是值得的。