前言 上半年我定的OKR目標是幫助團隊將App切入Flutter,實現統一技術棧,變革成多端融合開發模式。Flutter目前是跨平臺方案中最有潛力實現我們這個目標的,不管是Hybird還是React Native,我們的項目都有落地應用,跨平臺一直是終端團隊所追求的技術,能夠快速研發和部署也是我們不 ...
前言
上半年我定的OKR目標是幫助團隊將App切入Flutter,實現統一技術棧,變革成多端融合開發模式。Flutter目前是跨平臺方案中最有潛力實現我們這個目標的,不管是Hybird還是React Native,我們的項目都有落地應用,跨平臺一直是終端團隊所追求的技術,能夠快速研發和部署也是我們不斷給自己提出的挑戰。Flutter是什麼我在這裡就不多說了,很多文章都有介紹,本篇文章想分享的是如何在原生工程中嵌入Flutter來實現混編,幫助團隊快速落地Flutter遷移,這個對小團隊來說應該會有一定借鑒意義。
前置動作
在接入Flutter之前需要具備以下前置條件:
- 易於開發的操作系統(首推macOS)
- 配置Flutter開發環境(參考:https://flutter.dev/docs/get-started/install/macos )
- Android和iOS開發環境(自行搜索解決)
接入方案
業內絕大部分的App都不可能推倒重來,所以混合工程的方式接入Flutter是目前主流開發模式,下麵我簡單說說業界兩種工程管理模式:
統一管理模式(不推薦)
- 優點
- 適合全新使用Flutter開發的項目
- 缺點
- 後期代碼耦合嚴重,相關工具鏈耗時大幅增長,導致開發效率低
三端分離模式(推薦)
鹹魚方案:https://mp.weixin.qq.com/s/Q1z6Mal2pZbequxk5I5UYA?
官方方案:https://flutter.dev/docs/development/add-to-app
- 優點
- 快速實現Flutter功能“熱插拔”,降低原生工程的改造成本
- 可以直接進行Dart代碼和原生代碼開發調試
目前我們採用的是以module的形式接入,因為我們團隊人員少,溝通協作起來成本不大,初期直接源碼接入也方便我們快速接入開發和調試。
踩坑實踐
flutter doctor
如果想確認你當前的環境是否ok,執行下flutter doctor
命令,基本能解決大部分問題。如果遇到一直卡住,說明你當前環境是不通的,檢查下代理是否配置正確。
創建Flutter module工程
如果點擊Finish創建module一直卡死,說明還是網路問題,命令行輸入vi ~/.bash_profile
檢查下代理。如果實在不行,則通過命令行創建module:
flutter create -t module --org com.example my_flutter
Android原生工程集成Flutter
一期我們先接入Android工程,所以接下來主要以Android為主,後續如果有iOS相關的實踐會補充到這裡。
先看下我們的module工程:
目錄結構:
- .android(隱藏目錄,自動生成的Android工程)
- .ios(隱藏目錄,自動生成的iOS工程)
- build(Android和iOS的構建產物)
- lib(Flutter應用源文件)
- test(測試文件)
- *.iml(工程配置文件)
- pubspec.lock(記錄當前項目實際依賴信息的文件)
- pubspec.yaml(管理第三方庫資源信息的配置文件)
除了工程配置文件和自動生成的工程目錄之外,其他文件都需要進行托管。
瞭解完工程目錄之後,我們開始集成:
- 打開原生工程
setting.gradle
,加入以下配置
// 引入flutter module
setBinding(new Binding([gradle: this])) // new
evaluate(new File( // new
settingsDir.parentFile, // new
'edu_flutter_module/.android/include_flutter.groovy' // new
))
include ':edu_flutter_module'
project(':edu_flutter_module').projectDir = new File('../edu_flutter_module')
可以看到目前我們依賴的flutter module,是在原生工程目錄同級的。
- 打開主工程的
build.gradle
文件,在dependencies下加入以下配置:
implementation project(":flutter")
ok,這兩步是官方的指引,配置完之後就完事了? 太天真了,還需要有一些額外的調整。構建一下就知道了:
異常1:Gradle DSL method not found: 'google()'
項目中用的gradle版本還是比較舊的,需要升級一下:
異常2:AAPT error:resource android:attr/fontVariationSettings not found
這個異常需要將compileSdkVersion升級到28,之前是26。
異常3:assert appProject !=null
這個問題巨坑,我們的主工程名是course
,但flutter的構建腳本是硬編碼為app
,有兩種解決辦法:
- 重命名module名字,命名為app
- 修改flutter腳本(我選的是這種)
這樣,flutter腳本就能找到我們的工程,編譯也ok了。
但其實還有問題,因為目前我們還未升級support包到AndroidX版本,而創建出來的module工程預設是支持AndroidX的,所以我們需要進行降級,等後續升級工程之後再處理。
修改edu_flutter_module/pubspec.yaml
,將androidX改為false:
module:
androidX: false
androidPackage: com.tencent.edu
iosBundleIdentifier: com.tencent.edu
改完這個之後,終於工程編譯通過了,但這就結束了嗎,還有坑等著你呢。
原生頁面引入Flutter頁面
上一個主題我們解決掉一些坑之後終於把flutter作為一個module集成到我們的工程中,接下來我們嘗試寫個頁面嵌入到我們頁面。
目前課堂用的flutter版本是:v1.12.13+hotfix.5
,這個版本的使用跟之前的版本會有些差異,可以參考官方的wiki:
https://github.com/flutter/flutter/wiki/Upgrading-pre-1.12-Android-projects
這裡我嘗試把課堂的首頁替換成Flutter頁面,做了以下調整:
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
// TODO: 2020-04-01 增加flutter視圖
View view = inflater.inflate(R.layout.fragment_index, container, false);
FlutterEngine flutterEngine = new FlutterEngine(getActivity());
flutterEngine.getDartExecutor().executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
);
flutterEngine.getNavigationChannel().setInitialRoute("route1");
FlutterView flutterView = new FlutterView(getActivity());
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
FrameLayout flContainer = view.findViewById(R.id.fl_content);
// 關鍵代碼,將Flutter頁面顯示到FlutterView
flutterView.attachToFlutterEngine(flutterEngine);
flContainer.addView(flutterView, lp);
return view;
}
fragment_index.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 嵌入flutter視圖 -->
<FrameLayout
android:id="@+id/fl_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</LinearLayout>
dart代碼實現:
main.dart
import 'package:edu/home_page.dart';
import 'package:flutter/material.dart';
import 'dart:ui';
import 'dart:convert';
void main() {
runApp(_widgetForRoute(window.defaultRouteName));
}
// 獲取路由名稱
String _getRouteName(String s) {
if (s.indexOf('?') == -1) {
return s;
} else {
return s.substring(0, s.indexOf('?'));
}
}
// 獲取參數
Map<String, dynamic> _getParamsStr(String s) {
if (s.indexOf('?') == -1) {
return Map();
} else {
return json.decode(s.substring(s.indexOf('?') + 1));
}
}
Widget _widgetForRoute(String url) {
String route = _getRouteName(url);
Map<String, dynamic> params = _getParamsStr(url);
switch (route) {
default:
return MaterialApp(
theme: ThemeData(
primaryColor: Color(0xFF008577),
primaryColorDark: Color(0xFF00574B),
),
home: HomePage(route, params),
);
}
}
home_page.dart
import 'package:flutter/material.dart';
class HomePage extends StatefulWidget {
String route;
Map<String, dynamic> params;
HomePage(this.route, this.params);
@override
State<StatefulWidget> createState() {
return _HomePageState();
}
}
class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Flutter頁面'),
automaticallyImplyLeading: false,
),
body: Center(
child: Text('首頁'),
),
);
}
}
ok,Demo代碼到這裡就寫完了,然後信心滿滿的run起來,發現直接崩了。這就是我要跟你說的其中一個坑,so架構的問題:
大部分老項目工程中用到的是armeabi架構,但flutter最低支持到armeabi-v7a,如果不做特殊處理,就會出現上面的Crash。怎麼辦?解決辦法自然有,就是找到flutter module工程的構建物,把armeabi-v7a
下的libFlutter.so
拿出來,放到原生工程的armeabi
下,我寫了個shell腳本,然後通過Hook Gradle Task的方式插入到編譯流程中去。
copyFlutterSo.sh
#!/bin/bash
# 當前目錄
CURRENT_DIR="`pwd`"
# 當前build目錄,具體以工程為準
BUILD_DIR="`pwd`/build"
# gradle 5.6.2 armeabi so路徑
#ARMEABI_DIR="$BUILD_DIR/intermediates/merged_native_libs/debug/out/lib/armeabi"
# gradle 4.10.1 armeabi so路徑
ARMEABI_DIR="$BUILD_DIR/intermediates/transforms/mergeJniLibs/$1/0/lib/armeabi"
# armeabi-v7a so存放路徑
ARMEABI_V7A_DIR="$BUILD_DIR/intermediates/transforms/mergeJniLibs/$1/0/lib/armeabi-v7a"
echo -e "\033[47;30m ========== copy $1 libflutter.so ========== \033[0m"
if [[ "$1" == "debug" ]]; then
# 將libflutter.so copy到armeabi架構中去
cp -rf ${ARMEABI_V7A_DIR}/libflutter.so ${ARMEABI_DIR}
echo "copy ${ARMEABI_V7A_DIR}/libflutter.so to ${ARMEABI_DIR}"
elif [[ "$1" == "profile" ]]; then
# 將libflutter.so copy到armeabi架構中去
cp -rf ${ARMEABI_V7A_DIR}/libflutter.so ${ARMEABI_DIR}
# 將libapp.so也copy到armeabi架構中去
cp -rf ${ARMEABI_V7A_DIR}/libapp.so ${ARMEABI_DIR}
echo "copy ${ARMEABI_V7A_DIR}/libflutter.so to ${ARMEABI_DIR}"
echo "copy ${ARMEABI_V7A_DIR}/libapp.so to ${ARMEABI_DIR}"
elif [[ "$1" == "release" ]]; then
# 將libflutter.so copy到armeabi架構中去
cp -rf ${ARMEABI_V7A_DIR}/libflutter.so ${ARMEABI_DIR}
# 將libapp.so也copy到armeabi架構中去
cp -rf ${ARMEABI_V7A_DIR}/libapp.so ${ARMEABI_DIR}
echo "copy ${ARMEABI_V7A_DIR}/libflutter.so to ${ARMEABI_DIR}"
echo "copy ${ARMEABI_V7A_DIR}/libapp.so to ${ARMEABI_DIR}"
fi
Hook Gradle Task
afterEvaluate { project ->
android.applicationVariants.each { variant ->
/**
* 由於flutter不支持armeabi,此處在merge(Debug|Profile|Release)NativeLibs與strip(Debug|Profile|Release)DebugSymbols之間插入一個任務,
* 將libflutter.so和libapp.so拷貝到merged_native_libs/(debug|profile/release)/out/lib/armeabi目錄下,使它們能打到最終的apk里。
*
* 詳情見copyFlutterSo.sh
*/
def taskPostfix = variant.name.substring(0, 1).toUpperCase() +
variant.name.substring(1)
project.task("copyFlutterSo$taskPostfix") {
doLast {
exec {
// 執行shell腳本
commandLine "sh", "./copyFlutterSo.sh", variant.name
}
}
}
// 註意這個是在gradle 5.6.2版本的task
// project.tasks["copyFlutterSo$taskPostfix"].dependsOn(project.tasks["merge$taskPostfix" + "NativeLibs"])
// project.tasks["strip$taskPostfix" + "DebugSymbols"].dependsOn(project.tasks["copyFlutterSo$taskPostfix"])
//
// gradle 4.10.1,註意插入task的依賴順序
project.tasks["copyFlutterSo${taskPostfix}"].dependsOn(project.tasks["transformNativeLibsWithMergeJniLibsFor${taskPostfix}"])
project.tasks["process${taskPostfix}JavaRes"].dependsOn(project.tasks["copyFlutterSo$taskPostfix"])
}
}
}
這樣我們每次執行assembleDebug
或者assembleRelease
都能自動將對應的armeabi-v7a
的libflutter.so
和libapp.so
複製到armeabi
下。
然後再run一次,這個時候就真正把我們的混合工程跑起來了。
工程最佳實踐
這裡我提我們目前的做法:
- flutter module工程單獨以git倉庫托管
- 以submodule的方式將flutter module工程管理
- 調整依賴路徑如下:
// 引入flutter module
setBinding(new Binding([gradle: this]))// new
// module工程和setting.gradle文件同級
evaluate(new File( // new
settingsDir, // new
'edu_flutter_module/.android/include_flutter.groovy' // new
))
include ':edu_flutter_module'
project(':edu_flutter_module').projectDir = new File('edu_flutter_module')
主要改動是將module工程和setting.gradle文件同級.
- 通過持續構建系統搭建Flutter構建環境,滿足日常開發構建
總結
以module方式接入Flutter適合大部分存量的項目,目前我們項目已經以這種方式跑起來並且打通持續構建,目前已經踩了部分坑,總得來說經過這段時間對Flutter這個框架的實踐我們團隊已經掌握的新技術棧去為業務賦能,接下來的工作就是不斷提升和優化新的研發體驗,讓統一技術棧這個目標不是說說而已。未來也將會輸出更多乾貨,幫助業內的朋友也能加入到終端的研發變革中來。