文章示例代碼已上傳 GitHub绩社,地址:https://github.com/chenglei1986/reuse_a_flutter_engine_across_screens
1 準(zhǔn)備工作
集成 Flutter 到現(xiàn)有項(xiàng)目具體步驟請(qǐng)參考官方文檔。
如果你的項(xiàng)目是基于 Flutter stable 分支(在這篇文章完成當(dāng)時(shí)的版本為 1.17.1-stable),那么 Android 項(xiàng)目 Application 模塊的文件夾名稱必須為 app辉懒,否則編譯報(bào)錯(cuò)。
如果有一個(gè)工程下有多個(gè) Application 模塊谍失,那么請(qǐng)將 Flutter 切換到 dev 或 master 分支眶俩,并在 gradle.properties 文件中加入
# 指定 app 模塊的目錄名稱為 xxx
flutter.hostAppProjectName=xxx
然后再參考 Adding a Flutter screen to an Android app,編寫 Android 端代碼快鱼。
完成 Step 1 和 Step 2 就能運(yùn)行 Flutter 代碼了颠印,只是每次啟動(dòng)都很慢,而且 Activity 切換動(dòng)畫會(huì)有肉眼可見(jiàn)的黑色背景抹竹,純 Flutter 項(xiàng)目可以將啟動(dòng)頁(yè)的 windowBackground 設(shè)置成啟動(dòng)圖來(lái)提升用戶體驗(yàn)线罕,對(duì)于混合項(xiàng)目沒(méi)有幫助。
此時(shí)再看 Step 3窃判,翻一下文檔相應(yīng)部分如下:
默認(rèn)情況下钞楼,每個(gè) FlutterActivity 都會(huì)創(chuàng)建一個(gè)自己的 FlutterEngine。而每個(gè) FlutterEngine 都會(huì)有一個(gè)明顯的 “預(yù)熱” 時(shí)間袄琳。這就意味著啟動(dòng)標(biāo)準(zhǔn)的 FlutterActivity 窿凤,在 Flutter 界面顯示之前,將會(huì)有一個(gè)短暫的延遲跨蟹。為了將這個(gè)延遲最小化雳殊,你可以在啟動(dòng) FlutterActivity 之前對(duì) FlutterEngine 進(jìn)行預(yù)加載,之后使用這個(gè)預(yù)加載的 FlutterEngine 即可窗轩。
框架提供了方法夯秃,可以將 FlutterEngine 緩存起來(lái),這樣每次啟動(dòng) FlutterActivity 都是“熱啟動(dòng)”,用戶體驗(yàn)上與原生基本沒(méi)有差別仓洼。
至此介陶,對(duì)于文檔中提及的混合開(kāi)發(fā)方案中的所有步驟都已經(jīng)完成。此時(shí)我們還面臨一個(gè)最大的問(wèn)題就是色建,緩存了 FlutterEngine 之后哺呜,無(wú)法指定入口,這一點(diǎn)文檔也有提到箕戳,大意就是:
FlutterEngine 是獨(dú)立于 FlutterActivity 的某残,F(xiàn)lutterEngine 會(huì)在預(yù)熱的時(shí)候就執(zhí)行一部分 Dart 代碼,如果等到 FlutterActivity 啟動(dòng)的時(shí)候再指定入口就晚了陵吸。
2 解決多個(gè) Flutter 頁(yè)面的入口問(wèn)題
Flutter 混合開(kāi)發(fā)玻墅,一般都是使用 Flutter 來(lái)開(kāi)發(fā)部分模塊,所以原生界面啟動(dòng)在前壮虫。除非所有使用 Flutter 開(kāi)發(fā)的模塊都從同一個(gè)入口進(jìn)入澳厢,否則多入口是無(wú)法逃避的問(wèn)題。
上面提到 FlutterEngine 會(huì)在預(yù)熱的時(shí)候就開(kāi)始執(zhí)行 Dart 代碼囚似,這就意味著剩拢,同一個(gè) FlutterEngine 只能指定一個(gè)入口,所以我自己在做項(xiàng)目的時(shí)候最先想到的是饶唤,我可以緩存多個(gè) FlutterEngine 啊徐伐。
2.1 一次預(yù)熱多個(gè) FlutterEngine
核心代碼如下:
/**
* 初始化 FlutterEngine
*
* @param context 上下文
* @param routes 路由名稱列表
*/
void initFlutterEngine(Context context, List<String> routes) {
for (String route : routes) {
FlutterEngine flutterEngine = new FlutterEngine(context, null, false);
flutterEngine.getDartExecutor().executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
);
// 使用路由名稱作為 engineId
FlutterEngineCache.getInstance().put(route, flutterEngine);
}
}
這個(gè)方案可以解決問(wèn)題,但是面臨最大的風(fēng)險(xiǎn)與挑戰(zhàn)就是搬素,每初始化一個(gè) FlutterEngine 都要消耗相應(yīng)的內(nèi)存呵晨。
根據(jù)文檔魏保,在 Flutter v1.10.3 版本上使用 2015 年的低端手機(jī)測(cè)試熬尺,Android 系統(tǒng)中每加載一個(gè) FlutterEngine 需要 42 MB 內(nèi)存,渲染首頁(yè)需要約 12MB 內(nèi)存谓罗。隨著版本的升級(jí)粱哼,預(yù)計(jì)消耗內(nèi)存會(huì)更多。
隨著項(xiàng)目的迭代升級(jí)檩咱,功能模塊越來(lái)越多揭措,這個(gè)方案的風(fēng)險(xiǎn)就越大。
2.2 復(fù)用 FlutterEngine
2.2.1 改造 Flutter 入口代碼
一個(gè)典型的 Flutter 程序如下
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Default Home Page'),
),
body: Container(),
);
}
}
其中 MyApp 是繼承自 StatelessWidget刻蚯,所以是無(wú)法改變狀態(tài)的绊含,我們要進(jìn)行如下改造:
void main() => runApp(MyApp());
/// 繼承自 StatefulWidget
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
/// 實(shí)例化一個(gè) MethodChannel
MethodChannel _methodChannel = MethodChannel('com.example/method_channel');
/// Flutter 頁(yè)面入口
Widget _initRoute = DefaultHomePage();
@override
void initState() {
super.initState();
// 設(shè)置 MethodCallHandler 接收來(lái)自 Android 的消息
_methodChannel.setMethodCallHandler((call) async {
switch (call.method) {
case 'setInitRoute':
_handleInitRouteMethodCall(call);
break;
}
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
// home 設(shè)置成變量,當(dāng)接收到 Android 發(fā)過(guò)來(lái)的路由時(shí)炊汹,
// 將 home 修改以實(shí)現(xiàn)入口的切換
home: _initRoute,
);
}
/// 處理來(lái)自 setInitRoute 的消息
void _handleInitRouteMethodCall(MethodCall call) async {
switch (call.arguments) {
case '/page_a':
_initRoute = PageA();
break;
case '/page_b':
_initRoute = PageB();
break;
default:
_initRoute = DefaultHomePage();
break;
}
/// 更新界面
setState(() {});
}
}
/// 默認(rèn)入口躬充,空白即可
class DefaultHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// 因?yàn)槭锹酚蓷5淖畹讓樱圆粫?huì)自動(dòng)處理返回,我們要自己處理
leading: GestureDetector(
child: Icon(Icons.arrow_back),
// 退出 FlutterActivity充甚,iOS 不支持以政,要單獨(dú)處理
onTap: () => SystemNavigator.pop(),
),
title: Text('Default Home Page'),
),
body: Container(),
);
}
}
/// page_a.dart
class PageA extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: GestureDetector(
child: Icon(Icons.arrow_back),
onTap: () => onBackPressed(context),
),
title: Text('Page A'),
),
body: Container(),
);
}
void onBackPressed(BuildContext context) {
NavigatorState navigatorState = Navigator.of(context);
if (navigatorState.canPop()) {
navigatorState.pop();
} else {
SystemNavigator.pop();
}
}
}
/// page_b.dart
class PageB extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: GestureDetector(
child: Icon(Icons.arrow_back),
onTap: () => onBackPressed(context),
),
title: Text('Page B'),
),
body: Container(),
);
}
void onBackPressed(BuildContext context) {
NavigatorState navigatorState = Navigator.of(context);
if (navigatorState.canPop()) {
navigatorState.pop();
} else {
SystemNavigator.pop();
}
}
}
主要思路是把 MyApp 改成 StatefulWidget,同時(shí)創(chuàng)建一個(gè) MethodChannel 用于接收原生 App 發(fā)送的消息伴找,當(dāng)啟動(dòng) FlutterActivity 時(shí)盈蛮,使用該 MethodChannel 將要進(jìn)入的界面路由發(fā)送過(guò)來(lái),然后將 DefaultHomePage 替換掉技矮。
為此我們還要自定義一個(gè) FlutterActivity抖誉。
2.2.2 改造 Android 端代碼
public class AndroidFlutterActivity extends FlutterActivity {
static final String EXTRA_CACHED_ENGINE_ID = "cached_engine_id";
static final String EXTRA_ROUTE = "extra_route";
static final String EXTRA_DESTROY_ENGINE_WITH_ACTIVITY = "destroy_engine_with_activity";
public static void open(Context context, String route) {
Intent intent = new Intent(context, AndroidFlutterActivity.class)
.putExtra(EXTRA_CACHED_ENGINE_ID, "default_engine_id")
.putExtra(EXTRA_ROUTE, route)
// Activity 銷毀時(shí)保留 FlutterEngine
.putExtra(EXTRA_DESTROY_ENGINE_WITH_ACTIVITY, false);
context.startActivity(intent);
}
@Override
public void onFlutterUiDisplayed() {
super.onFlutterUiDisplayed();
// 設(shè)置 Flutter 界面入口,注意不要在 onCreate 方法中調(diào)用穆役,否則
// Flutter 入口不會(huì)更新寸五。
String route = getIntent().getStringExtra(EXTRA_ROUTE);
FlutterTools.setInitRoute(route);
}
}
需要注意得是,AndroidFlutterActivity 向 Flutter 發(fā)送入口路由的時(shí)機(jī)耿币。因?yàn)?FlutterEngine 在預(yù)加載的時(shí)候并不會(huì)執(zhí)行 Flutter 首頁(yè)的全部代碼,即在界面展示出來(lái)之前淹接,Widget 的 build 方法不會(huì)執(zhí)行,所以如果在 AndroidFlutterActivity onCreate 方法中向 Flutter 發(fā)送消息塑悼,雖然能夠收到劲适,但是時(shí)機(jī)過(guò)早霞势,當(dāng) Flutter 頁(yè)面真正展示之后,還是會(huì)展示 DefaultHomePage斑鸦。
public class FlutterTools {
public static final String ENGINE_ID = "default_engine_id";
public static final String ROUTE_PAGE_A = "/page_a";
public static final String ROUTE_PAGE_B = "/page_b";
private static final String METHOD_CHANNEL = "com.example/method_channel";
private static FlutterEngine sFlutterEngine;
private static MethodChannel sMethodChannel;
public static void preWarmFlutterEngine(Context context) {
if (null == sFlutterEngine) {
sFlutterEngine = new FlutterEngine(context);
sFlutterEngine.getDartExecutor().executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
);
sMethodChannel = new MethodChannel(sFlutterEngine.getDartExecutor(), METHOD_CHANNEL);
FlutterEngineCache.getInstance().put(ENGINE_ID, sFlutterEngine);
}
}
public static void setInitRoute(String route) {
sMethodChannel.invokeMethod("setInitRoute", route);
}
public static void destroyEngine() {
if (sFlutterEngine != null) {
sFlutterEngine.destroy();
}
}
}
原生 App 入口
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
setSupportActionBar(findViewById(R.id.tool_bar));
initViews();
// 預(yù)加載 FlutterEngine
FlutterTools.preWarmFlutterEngine(this);
}
private void initViews() {
findViewById(R.id.button_page_a).setOnClickListener(this);
findViewById(R.id.button_page_b).setOnClickListener(this);
}
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.button_page_a:
AndroidFlutterActivity.open(this, FlutterTools.ROUTE_PAGE_A);
break;
case R.id.button_page_b:
AndroidFlutterActivity.open(this, FlutterTools.ROUTE_PAGE_B);
break;
default:
break;
}
}
@Override
protected void onDestroy() {
super.onDestroy();
// 釋放資源
FlutterTools.destroyEngine();
}
}
最后看效果巷屿。模擬器上會(huì)有短暫的白屏,真機(jī)上基本看不出來(lái)嘱巾。