
三年前我接了个活客户的需求写得很朴素iOS 和 Android 各出一版功能一样视觉一样一个月交付。我当时拍着胸脯说没问题Flutter 双端开发嘛一套代码的事。真上手才发现一套代码这四个字里面藏着的东西比业务逻辑本身多得多。iOS 那边卡在证书和开发者模式上折腾了一整天Android 这边被 Gradle 插件的声明方式变更和文件路径问题绊住两边加起来写 Dart 的时间可能只占了一半。后来项目多了踩的坑也攒够了从建工程、搭结构、对齐双端体验一直到打包上架这条路我基本走顺了。下面按我实际推进的顺序把 Flutter 双端开发从开发到上架的全流程拆开讲重点放在那些官方文档里不会写、但真做项目一定会撞上的地方尤其是 Windows 环境构建报错、iOS 真机调试、Android 文件 URI、双端性能差异和上架审核这几段。1. 一套代码的真实边界Flutter 到底替你省了哪些活刚接触 Flutter 的人容易走两个极端要么觉得它能搞定一切要么被几个原生交互劝退后觉得它啥也干不了。实际情况在中间而且这条分界线其实很清晰摸清了就能少走很多弯路。1.1 自绘渲染带来的双端一致性红利Flutter 不走系统原生控件它自己带一套渲染引擎把 Widget 树算成绘制指令直接画到画布上。老版本底层是 Skia新版本在 iOS 上已经默认切到 ImpellerAndroid 也在逐步跟进。这意味着什么你在 iOS 上看到的一个Container圆角半径、阴影扩散、边框绘制方式和 Android 上看到的是同一套代码算出来的像素级一致。这件事的价值只有做过原生开发的人才体会得到。原生双端里同一个按钮iOS 是UIButtonAndroid 是MaterialButton圆角、阴影、点击态、字体基线全都不同你还得写两遍样式去逼近一个看起来一样的效果。Flutter 里这些都是RenderObject的确定性计算不用对齐。但自绘也有代价。凡是系统级的东西Flutter 得自己模拟软键盘的弹出时机、输入法候选框的位置、滚动惯性曲线、日期选择器、系统返回手势。这些地方经常出现看着像手感差一点的情况。最典型的是 iOS 的侧滑返回——只有CupertinoPageRoute才带那个带阻尼的回弹用MaterialPageRoute在 iOS 上就没有这个手势。所以我的习惯是路由层按平台分两套构造函数iOS 走 CupertinoAndroid 走 Material一处配置两边都舒服。1.2 一条清晰的分界线Dart 管业务原生管能力我的判断标准很简单能用纯 Dart 解决的一律走 Dart必须调系统能力的才动桥接。中间的灰度地带优先找成熟插件找不到再自己写MethodChannel。能力需求常见落地方式容易忽略的点相机 / 相册camera、image_pickeriOS 必须写Info.plist用途描述Android 13 起读媒体权限拆成了READ_MEDIA_IMAGES文件读写path_provider dart:ioAndroid 10 起分区存储外部路径不能直接写系统分享share_plusiPad 上不给锚点矩形会直接崩视频播放video_playeriOS 底层 AVPlayer、Android 底层 ExoPlayer编码支持范围不一样定位geolocator后台定位两个平台都要单独声明审核会查推送firebase_messaging 或各厂商通道国内 Android 机型保活策略差异极大别指望一套配置通吃内嵌网页webview_flutter两个平台的 Cookie 存储和 JS 桥接行为不同这张表看着简单但每一条背后都有故事。比如分享这个功能我在 iPad 上第一次遇到崩溃时完全懵——代码在 iPhone 和 Android 上跑得好好的一上 iPad 就挂。原因就是 iPad 的分享面板是个弹出气泡必须给一个锚点位置否则系统不知道从哪儿弹。1.3 开工前的技术盘点清单在动手写第一行代码前我会先回答几个问题这几个答案基本决定了项目的架构走向有没有必须接入的第三方 SDK 只提供了 Objective-C 或 Java 版本如果有桥接成本要提前算进排期。有没有页面必须用原生实现比如复杂的相机滤镜、地图深度定制有的话平台视图和纯 Flutter 页面的混合路由怎么切包体积有没有硬性上限Flutter 的引擎本身会占十几兆加上字体和图片很容易超。团队里有没有人会写 Swift 或 Kotlin一个人都不会那技术选型就得往零原生代码的方向靠。这些问题里第一个最容易出事。很多行业 SDK支付、识别、打印、蓝牙设备只给原生库接入就得自己写通道而通道的另一头是两套完全不同的实现调试成本是普通功能的几倍。2. 环境搭建卡住最多人的四个节点环境这关我见过太多人第一天就放弃了。其实报错信息本身不难难的是不知道它在说什么。下面四个节点是出现频率最高的。2.1 flutter doctor 到底要绿到什么程度先纠正一个误区flutter doctor不是非要全绿。如果你只做 iOS Android 双端Windows 机器上那些 Chrome、Visual Studio 相关的红叉可以完全忽略。真正要关心的是三项Flutter SDK 版本、Android 工具链、Xcode只在 macOS 上。Android 这块的标准流程是装 Android Studio在 SDK Manager 里勾选对应 API 级别的 Platform 和 Build-Tools再装 cmdline-tools最后跑一次flutter doctor --android-licenses一路y敲下去。这一步不做后面构建会报许可证未接受。还有个容易被忽略的点是 JDK 版本。新版 Android Gradle Plugin 要求 JDK 17如果你机器上还挂着 JDK 8 或者 11Gradle 会直接报编译版本不匹配。默认用 Android Studio 自带的那份 JDK 最省事IDE 里设置一次就行。2.2 Windows 上那个 unable to find suitable Visual Studio toolchain这个报错我收到过不下十次提问。先说结论它跟 Android 构建没关系它属于 Windows 桌面目标desktop的构建链。报错原文的意思是没有找到合适的 Visual Studio 工具链因为编译 Windows 原生代码需要 Visual Studio 2022 并且勾选使用 C 的桌面开发工作负载。那为什么很多人明明在跑 Android 项目却看到它两个常见原因第一运行目标选错了。在 VS Code 里点运行如果没指定设备Flutter 会挑一个可用设备有时候就挑到了 Windows 桌面。解决办法是显式指定flutter devices flutter run -d 你的安卓设备ID第二项目目录里存在windows/文件夹。如果你用flutter create在 Windows 上建的工程默认会带上五个平台目录IDE 的启动配置有可能指到桌面上去。如果确定不做 Windows 桌面版最干脆的做法是删掉windows/或者在创建时就用--platforms限定flutter create --platformsandroid,ios my_app如果你确实要做 Windows 桌面版那就老老实实装 Visual Studio 2022安装器里勾上 C 桌面开发工作负载装完重启终端再跑flutter doctor。这一步没什么取巧空间。2.3 iOS 真机调试开发者模式与签名在 iOS 16 和 Xcode 15 之后真机调试多了一道新门槛开发者模式。这个开关默认不显示只有设备通过数据线连过 Xcode、并且信任过这台电脑之后才会在设置 → 隐私与安全性底部冒出来。打开它需要重启设备重启后还会再确认一次。很多人的排查顺序是反的先怀疑证书再怀疑描述文件最后才发现开关没开。我建议的顺序是——线插上Xcode 里能看到设备再去看那个开关。关于签名用三个词理解就够了证书证明你是谁开发者账号的身份凭证。描述文件说明哪个 App、装在哪几台设备、用哪张证书签名。Bundle IdentifierApp 的唯一身份证全局不能重复上架后基本不能改。个人开发者用免费账号也能真机跑但签名有效期只有 7 天到期要重新构建安装而且不能上架。要正式发布还是得加入开发者计划。2.4 Gradle 插件声明方式变了apply plugin 那行报错别硬改新版本 Flutter 把 Android 侧的 Gradle 插件改成了声明式引入。如果你从旧工程升级会看到类似正在用 apply 脚本方式强制应用 Flutter 主 Gradle 插件这种方式已废弃的提示。这个时候不要试图去改那一行apply正确的做法是整体迁移。settings.gradle要长这样pluginManagement { def flutterSdkPath { def properties new Properties() file(local.properties).withInputStream { properties.load(it) } def flutterSdkPath properties.getProperty(flutter.sdk) assert flutterSdkPath ! null, flutter.sdk not set in local.properties return flutterSdkPath }() includeBuild($flutterSdkPath/packages/flutter_tools/gradle) repositories { google() mavenCentral() gradlePluginPortal() } } plugins { id dev.flutter.flutter-plugin-loader version 1.0.0 id com.android.application version 8.1.0 apply false id org.jetbrains.kotlin.android version 1.8.22 apply false } include :appapp/build.gradle的顶部改成plugins { id com.android.application id kotlin-android id dev.flutter.flutter-gradle-plugin } android { namespace com.example.myapp compileSdkVersion flutter.compileSdkVersion }这是我的实操建议与其一行行改老工程不如新建一个空项目把新生成的settings.gradle、build.gradle、gradle-wrapper.properties三个文件对照着抄过来五分钟搞定比逐行 debug 快得多。namespace这个字段是新版 AGP 强制的老工程的AndroidManifest.xml里如果还写着package两个地方要保持一致。3. 工程骨架从 main.dart 到一个能长期维护的双端项目一个能跑起来的 Demo 和一个能维护两年的项目差距全在结构上。这部分我想讲的是那些当时看不出来、半年后救命的决策。3.1 目录分层该按功能切还是按类型切按类型切长这样models/、views/、controllers/、services/。项目小的时候很清爽一旦模块超过十个找代码就变成了来回跳目录。我更推荐按功能切lib/ ├── app/ 全局配置路由表、主题、多语言、启动引导 ├── core/ │ ├── network/ 网络客户端、拦截器、错误模型 │ ├── storage/ 本地数据库、键值存储 │ ├── utils/ 日期、格式化、校验 │ └── constants/ 常量、环境配置 ├── features/ │ ├── home/ │ │ ├── data/ 仓储实现、数据源、DTO │ │ ├── domain/ 实体、用例 │ │ └── presentation/ 页面、组件、状态 │ └── profile/ └── shared/ 跨功能复用的组件好处是删功能的时候直接删一个目录不用担心遗漏。团队协作时冲突也少——两个人做两个功能几乎不会改到同一个文件。3.2 状态管理选型先看团队再看技术这块没有标准答案只有适配答案。我的判断依据排序是团队人数 项目复杂度 个人偏好。方案上手曲线适合场景我的看法Provider低中小项目、状态不复杂够用但大项目里容易变成谁都能改Riverpod中中大型项目、依赖注入需求多编译期安全测试友好我个人现在首选Bloc中高大型项目、多人协作、事件驱动模板代码多但边界最清晰GetX低快速原型、小团队一把梭很爽长期维护要克制使用一个真实教训我接手过一个用 GetX 的项目路由、状态、依赖注入、弹窗全用它改一个页面要同时动五个地方新人进来两天上不了手。工具本身没问题是用法失控了。3.3 网络层封装拦截器里该放什么不该放什么网络层我一般收在一个单例里用 Dio。核心是BaseOptions和拦截器链。final dio Dio(BaseOptions( baseUrl: Env.baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), sendTimeout: const Duration(seconds: 15), )); dio.interceptors.add(AuthInterceptor()); // 注入 token dio.interceptors.add(RetryInterceptor()); // 幂等请求重试 if (kDebugMode) { dio.interceptors.add(LogInterceptor(responseBody: true)); }三个必须记住的点第一日志拦截器只能在调试模式加。我见过一个项目把它无条件加上了结果 release 包里所有请求头和响应体都打进了系统日志token 全在里面这是实打实的安全事故。第二统一错误模型。上层业务不应该感知 Dio 的存在所以要在拦截器里把DioException翻译成自己的ApiException带上业务错误码和可读消息。这样以后换网络库业务代码一行不动。第三token 刷新的并发问题。当访问令牌过期同时有三个请求返回 401如果每个都独立去刷新令牌会触发三次刷新后两次很可能因为刷新令牌已被作废而失败。正确的做法是加一个待刷新队列第一个请求触发刷新其余挂起等待刷新完成后一起重放。这个坑不踩一次很难想到。3.4 本地数据库与后端同步的落地方式本地存储分两类需求一类是键值配置用shared_preferences就够一类是结构化数据的离线可用要上数据库。方案类型特点sqflite关系型直接写 SQL生态成熟性能可控drift关系型编译期校验 SQL类型安全推荐新项目hive键值 / 对象极快但复杂查询能力弱isar文档型查询能力强适合数据量大且结构灵活的场景真正麻烦的不是选哪个是同步策略。我的做法是每条本地记录带上四个字段local_id本地生成的唯一标识、remote_id服务端返回的 ID未同步时为空、updated_atUTC 时间戳、sync_state干净 / 待上传 / 待删除。同步流程大致是Futurevoid sync() async { final pending await dao.findByState(SyncState.pendingUpload); for (final row in pending) { final remote await api.push(row.toRemoteJson()); await dao.markSynced(row.localId, remote.id); } final since await prefs.getLastSyncTime() ?? DateTime.utc(2000); final changes await api.pullSince(since); for (final item in changes) { await dao.upsertRemote(item); } await prefs.setLastSyncTime(DateTime.now().toUtc()); }冲突解决我一般用服务端优先 本地待上传优先写入的混合策略本地刚改还没上传的先推上去已经在服务端被改过的以服务端为准。别小看这个规则早期我用纯粹的最后写入获胜结果用户在一台设备上改的内容被另一台设备的旧数据覆盖过。时间戳一定要用 UTC。设备时区不一致、系统时间被用户手动改过都会让同步逻辑错乱。有条件的话以服务端返回的时间为准。4. 双端体验对齐一套代码跑出来却不一样的地方写完代码在两端跑一遍往往会发现一堆为什么这边是这样。这些差异不是 Bug是平台机制不同得逐个处理。4.1 文件与路径content:// 是怎么把新手绕进去的Android 10 引入分区存储之后从相册、下载目录拿到的文件很多时候给你的是content://开头的 URI比如content://com.android.providers.media.documents/...这种形式。这种 URI 用dart:io的File是打不开的会直接抛异常。稳妥的处理方式是拿到文件后先复制到应用自己的临时目录再拿真实路径去操作。这样既绕开了 URI 权限问题也避免了用户在外部删除原文件导致后续读取失败。FutureFile ensureLocalFile(XFile picked) async { final isContentUri picked.path.startsWith(content://); if (!isContentUri) return File(picked.path); final tmpDir await getTemporaryDirectory(); final target File(${tmpDir.path}/${DateTime.now().millisecondsSinceEpoch}_${picked.name}); final bytes await picked.readAsBytes(); await target.writeAsBytes(bytes); return target; }注意复制大文件会额外占内存和存储视频这类场景建议只拿 URI 去做流式上传别整个读进内存。4.2 权限申请两个平台是两套写法Android 侧先在AndroidManifest.xml声明再在运行时申请。Android 6 之后危险权限都是运行时申请。Android 13 起读图片和视频的权限从READ_EXTERNAL_STORAGE拆成了READ_MEDIA_IMAGES和READ_MEDIA_VIDEO如果不做版本判断在新机型上会直接拿不到权限。iOS 侧最坑的地方在于Info.plist里缺用途描述不是报权限被拒而是直接崩溃。而且崩溃日志不会直白告诉你缺哪个键。常用的几个键keyNSCameraUsageDescription/key string用于拍摄商品照片/string keyNSPhotoLibraryUsageDescription/key string用于从相册选择图片/string keyNSMicrophoneUsageDescription/key string录制视频时需要采集声音/string用途描述要写具体用途不能写需要此权限以提供服务这种空话审核会因此驳回。而且只有真正用到的权限才写多写一样会被问。4.3 分享、视频、进度条跟着平台走而不是硬掰分享用share_plus一行代码的事但 iPad 上必须传锚点await Share.share( 看看这个内容, sharePositionOrigin: Rect.fromLTWH( box.localToGlobal(Offset.zero).dx, box.localToGlobal(Offset.zero).dy, box.size.width, box.size.height, ), );没有这个矩形iPad 上弹窗找不到锚点系统会直接崩掉。视频播放用video_player底层 iOS 是 AVPlayer、Android 是 ExoPlayer。同一个视频文件两端对编码格式的支持范围是有差别的尤其是某些封装格式和非主流编码。我的经验是如果视频是用户上传的服务端做一次统一转码H.264 AAC MP4能省掉后面一大堆这个机型播不了的投诉。进度指示器这种小细节Android 用户习惯线性进度条iOS 用户对旋转菊花更熟悉。用Platform.isIOS判断是常见做法但记住在 Web 上dart:io的Platform会报错如果你以后可能编 Web 版就得先判断kIsWeb或者干脆用主题配置来切换。4.4 内存两端都会涨但涨的地方不一样Flutter 的内存问题八成出在图片和监听器上。图片这块最重要的一条Image默认按原图尺寸解码。一张 4000×3000 的照片解码成位图是 4000×3000×4 字节差不多 48MB如果列表里放了十张直接崩。解决办法是给图片指定解码尺寸Image.network( url, cacheWidth: (200 * devicePixelRatio).round(), cacheHeight: (200 * devicePixelRatio).round(), )再配合PaintingBinding.instance.imageCache的上限配置控制整体占用PaintingBinding.instance.imageCache.maximumSize 100; // 最多缓存 100 张 PaintingBinding.instance.imageCache.maximumSizeBytes 100 20; // 约 100MB常见泄漏点症状处理方式控制器未释放页面来回切内存阶梯上升dispose()里释放 AnimationController、TextEditingController流订阅未取消后台持续接收数据保存StreamSubscription在dispose里cancel()定时器未停止页面退出后仍在跑Timer.periodic要显式cancel()全局单例持有页面引用页面无法回收控制器不要塞进全局容器或退出时清理JSON 大对象解析解析瞬间卡顿、峰值高用compute丢到 Isolate排查工具就用 DevTools 的 Memory 面板。一个实用技巧来回切同一个页面十次看 heap 曲线如果每次峰值都比上一次高、且不回落基本就是泄漏了。5. 打包与上架最后一个环节也是最多人卡在门口的地方代码写完了能不能顺利送到用户手上是另一套技能。5.1 Android 签名、混淆与产物格式签名文件生成一次之后一直用丢了就得换包名重新上架一定要备份。keytool -genkey -v -keystore ~/upload-keystore.jks \ -keyalg RSA -keysize 2048 -validity 10000 -alias upload然后在android/key.properties里写storePassword你的密码 keyPassword你的密码 keyAliasupload storeFile/绝对路径/upload-keystore.jksapp/build.gradle里读取并配置签名同时打开混淆和资源压缩def keystoreProperties new Properties() def keystorePropertiesFile rootProject.file(key.properties) if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } android { signingConfigs { release { keyAlias keystoreProperties[keyAlias] keyPassword keystoreProperties[keyPassword] storeFile file(keystoreProperties[storeFile]) storePassword keystoreProperties[storePassword] } } buildTypes { release { signingConfig signingConfigs.release minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } } }混淆这一块有个坑如果你的原生依赖里有反射调用混淆后会在运行时崩必须在proguard-rules.pro里加 keep 规则。判断方法很简单——release 包崩、debug 包不崩八成就是混淆。构建产物flutter build appbundle --release # 用于 Google Play flutter build apk --release --split-per-abi # 用于国内多数商店--split-per-abi会按 CPU 架构拆成几个 APK每个体积小很多但上传时要注意商店是否支持多 APK。国内各家应用市场还有各自的资质审核材料要求周期往往不短建议提前几周就开始准备别等版本做完才发现卡在材料上。5.2 iOS 打包与 TestFlight先说一个新手最常见的错误打开的是ios/Runner.xcworkspace不是Runner.xcodeproj。有 CocoaPods 依赖的项目必须打开前者否则依赖找不到编译一堆报错。流程是Bundle Identifier 与 App Store Connect 上创建的 App 记录保持一致 → 选真机或 Any iOS Device 作为目标 →Product → Archive→ 在 Organizer 里Distribute App→ 选 App Store Connect → 上传。上传完成后不会马上能测要去 App Store Connect 的 TestFlight 页面等构建处理完通常几分钟到半小时。处理完还要填出口合规信息之类的东西然后就能加内部测试员了。注意pubspec.yaml里的version: 1.2.3451.2.3对应 iOS 的CFBundleShortVersionString45对应CFBundleVersion。上传完一个构建后想再传一个必须把45往上加否则会被拒收。5.3 审核被拒的常见原因被拒原因典型表现处理方式权限用途描述不具体审核备注要求说明用途在描述里写清用于什么场景不要写套话存在崩溃或白屏审核人员一打开就挂release 模式真机完整走一遍主流程别只测 debug核心功能无法体验需要账号才能看提供可用的测试账号并在备注里写清步骤缺失隐私说明文件引用的部分 SDK 需要按平台要求补齐隐私清单Android 目标版本过低商店提示不满足要求及时跟进targetSdkVersion的年度要求内容或文案问题描述与实际功能不符商店描述、截图与实际功能严格一致被拒不可怕可怕的是不知道怎么复现。我的做法是每次提交前自己用 release 包从全新安装开始把主要路径走一遍尤其是那些依赖权限和网络的功能。5.4 版本号与灰度发布版本号只能往前不能往回。versionCode在 Google Play 上永远只能增加减了会被拒。灰度方面Google Play 支持分阶段发布先放 5%观察再扩iOS 侧是分阶段自动发布国内各商店的灰度能力差异较大有的干脆没有所以更依赖后端开关来做功能控制。一个实用建议把能不能用和发不发版解耦。新功能背后挂一个服务端下发的开关出问题直接关掉比等审核快得多。6. 上线之后把能跑变成跑得住提交上架不是终点。真正耗精力的部分是上线后那段时间的监控和迭代。6.1 崩溃与性能监控符号表这件事必须做崩溃收集用 crashlytics 或 sentry 都行接入本身不难难的是符号化。Flutter 的 release 包是 AOT 编译的崩溃堆栈如果没有对应的符号文件你看到的是一堆地址完全没用。Android 侧需要上传mapping.txt在build/app/outputs/mapping/release/下。iOS 侧需要上传 dSYM 文件。这两步如果不做你会收到有崩溃的通知但永远定位不到是哪一行代码。我见过团队上线两周崩溃率 3%硬是不知道问题在哪最后回头补符号上传才定位到一个插件版本冲突。6.2 为什么 Flutter 不能像网页那样热更新这件事值得说清楚因为经常有人问。Flutter 的 release 构建是 AOT提前编译成机器码的Dart 代码在打包那一步就已经编译进二进制里了运行时没有脚本引擎去解释新代码所以改一行代码就必须重新打包上架。那能不能动态化可以但思路不同把多变的业务逻辑放到服务端下发配置规则、文案、布局描述客户端只做渲染。或者在 App 内嵌一个容器来承载高频变动的页面。这两条路都能走但都要额外设计不是加个库就完事。6.3 迭代节奏与自动化构建分支策略我用得比较土但稳定main放可发布代码develop做日常集成功能分支从develop切出去发布前合并到main打 tag。CI 用 GitHub Actions 的话有个关键限制要先知道iOS 构建必须跑在 macOS 的 runner 上Windows 和 Linux 的 runner 编不了 iOS。Android 则随便哪个平台都能跑。name: build on: push: tags: [v*] jobs: android: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: subosito/flutter-actionv2 with: flutter-version: stable - run: flutter pub get - run: flutter build apk --release --split-per-abi - uses: actions/upload-artifactv4 with: name: android-apk path: build/app/outputs/flutter-apk/*.apk ios: runs-on: macos-latest steps: - uses: actions/checkoutv4 - uses: subosito/flutter-actionv2 with: flutter-version: stable - run: flutter pub get - run: flutter build ios --release --no-codesign--no-codesign是先产出不带签名的产物签名和上传交给后续步骤处理比如用自动化发布工具。把构建时间从本机挪到 CI最大的好处是环境一致——不会出现我机器上能打出来别人打不出来这种事。6.4 几个我长期在用的小习惯最后分享几个实操层面的习惯都是被坑出来的每次升级 Flutter SDK 大版本先在develop分支上跑一遍完整回归再合并到主分支。跨大版本升级经常伴随插件不兼容别在主分支上试。插件的版本号在pubspec.yaml里锁死不要用any。某个插件在你没动代码的情况下悄悄发新版构建挂掉的事我遇到过两次。项目根目录维护一个TROUBLESHOOTING.md每次踩坑解决后记一行。团队新人接手时这个文件的价值比任何文档都高。定期清理ios/Pods、build/、.dart_tool/再重新拉依赖很多莫名其妙编译不过的问题会自己消失。这个操作我一般叫三板斧不解决再查别的。这套流程走下来一个中等复杂度的双端项目从零到上架大概需要三到五周其中真正写业务逻辑的时间可能只占一半剩下的是环境、适配、调试和上架流程。听起来效率不高但对比原生双端开发的人力投入还是省了不少——尤其是后续迭代阶段一套代码改一次两端同时更新这个优势会随着版本越滚越大。