集成Flutter到現(xiàn)有項(xiàng)目,并實(shí)現(xiàn)使用單個(gè)FlutterEngine管理多個(gè)入口

文章示例代碼已上傳 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)嘱巾。

screen_shot.gif
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市旬昭,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌问拘,老刑警劉巖慢味,帶你破解...
    沈念sama閱讀 218,451評(píng)論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異墅冷,居然都是意外死亡纯路,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,172評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門寞忿,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)驰唬,“玉大人,你說(shuō)我怎么就攤上這事腔彰〗斜啵” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 164,782評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵霹抛,是天一觀的道長(zhǎng)搓逾。 經(jīng)常有香客問(wèn)我,道長(zhǎng)杯拐,這世上最難降的妖魔是什么霞篡? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,709評(píng)論 1 294
  • 正文 為了忘掉前任,我火速辦了婚禮端逼,結(jié)果婚禮上朗兵,老公的妹妹穿的比我還像新娘。我一直安慰自己顶滩,他們只是感情好余掖,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,733評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著礁鲁,像睡著了一般盐欺。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上仅醇,一...
    開(kāi)封第一講書(shū)人閱讀 51,578評(píng)論 1 305
  • 那天冗美,我揣著相機(jī)與錄音,去河邊找鬼着憨。 笑死墩衙,一個(gè)胖子當(dāng)著我的面吹牛务嫡,可吹牛的內(nèi)容都是我干的甲抖。 我是一名探鬼主播,決...
    沈念sama閱讀 40,320評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼心铃,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼准谚!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起去扣,我...
    開(kāi)封第一講書(shū)人閱讀 39,241評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤柱衔,失蹤者是張志新(化名)和其女友劉穎樊破,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體唆铐,經(jīng)...
    沈念sama閱讀 45,686評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡哲戚,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,878評(píng)論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了艾岂。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片顺少。...
    茶點(diǎn)故事閱讀 39,992評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖王浴,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情秒裕,我是刑警寧澤钞啸,帶...
    沈念sama閱讀 35,715評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站入蛆,受9級(jí)特大地震影響硕勿,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜扼褪,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,336評(píng)論 3 330
  • 文/蒙蒙 一话浇、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧幔崖,春花似錦渣淤、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,912評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)忙迁。三九已至姊扔,卻和暖如春梅誓,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背证九。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,040評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工愧怜, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人蓬蝶。 一個(gè)月前我還...
    沈念sama閱讀 48,173評(píng)論 3 370
  • 正文 我出身青樓猜惋,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親缓窜。 傳聞我的和親對(duì)象是個(gè)殘疾皇子谍咆,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,947評(píng)論 2 355