一款零侵入的高效Flutter混合栈管理方案,你值得拥有

在实际的工作场景中,我们很难从零开始用纯Flutter去建设一个项目,也正是因为这样,Native+Flutter混合栈跳转管理使我们在混合开发的时候不得不首先考虑的问题,因为我们很难保证不会遇到下面的情况。

混合栈跳转

那么如何做技术选型困惑了不少想要做混开的同学,毕竟Flutter的生态还不是十分成熟,现成的解决方案和轮子并不多,而且还不一定好用,要么资源占用过高,要么侵入性太强。好在经过这几天的摸索,总结出来了一套方案,供大家学习参考,相互交流。


按照国际惯例,我先介绍一下目前市场上的一些解决方案以及存在的问题。

本文章基于Flutter版本:2.2 & Platform:Android

1.Google官方(多引擎方案)

即每次使用一个新的FlutterEngine来渲染Widget树。虽然Flutter 2.0之后的创建FlutterEngine的开销大大降低,但是依然没有解决每个FlutterEngine是一个单独isolate,如果需要Flutter①和Flutter②之间交互数据的话,将会非常麻烦。我们同样无法保证他们之间不会进行数据交互,因此Pass。

2.大名鼎鼎的闲鱼flutter_boost(单引擎方案)

flutter_boost最近发布了3.0的bate版本,摒弃了2.0版本对引擎的侵入(赞!),但是依然存在不少问题:

①:高居不下的未关闭issues与回复不及时之间的矛盾(可以理解,竟然还是有工作要做的)。

②:复杂的设计,在出现使用问题的时候,通过改flutter_boost源码解决的成本很高。

③:flutter方面耦合度较高,我必须使用flutter_boost提供的一系列Route相关的工具和Widget才能达到混合栈跳转的效果,如果我以后想要更换框架,那对代码的修改将是海量的。

于是乎成功把我劝退。

3.哈喽单车团队的flutter_thrio(单引擎方案)

该库的优劣作者已经说得很详细了,这里就不再赘述,感兴趣的朋友可以进传送门亲自查看。

4.字节跳动团队的Isolate复用方案和腾讯心悦团队的TRouter方案

很可惜,目前这两个方案并没有开源出来,但很可能字节团队的方案的侵入性相当高。


既然没有现成的方案,那就撸起袖子造一个,先来一个混合栈跳转的效果演示:

项目地址:https://github.com/wangkunhui/min_stack_manager

效果演示

那么现在开始把上面的功能实现吧。

首先,要确定最终实现的目标,然后一步步朝着目标去完善:

目标一:复用FlutterEngine,避免额外的资源开销与FlutterEngine之间的通信成本。

目标二:Flutter Widget之间的跳转无需通过Native层控制(flutter_boost需要)。

目标三:每个打开的Flutter能且只能管理自己内部的栈,当前Flutter的Widget全部出栈后,退出当前Flutter。

目标四:支持Flutter带参数打开Native页面(返回值也可以支持,但目前还未添加进去)。

根据这些目标设计出来的模型图如下:

Native-Flutter栈内信息映射图

上图是依次交叉打开的5个Activity的栈信息示意图,可以分为三列,具体解释如下:

左侧的是Activity的栈信息,其中第二个和第四个挂载了Flutter,又分别打开了几个Flutter的Widget。

中间是FlutterEngine中的Widget栈示意图,因为复用FlutterEngine的原因,Widget A到Widget P都存在于一个栈中,而处在最底部的是一个空白的HostWidget,不负责任何业务逻辑,其目的就是保证其上方的业务相关的Widget能正常被Pop出栈(因为Flutter Navigator的最后一个Widget是无法被Pop的)。

右侧是对中间的FlutterEngine中Widget栈的管线信息说明,就是那些Widget是关联在那些HostActivity实例上的,为的就是能够控制我对Widget进行退栈操作的时候,知道退到哪个Widget时需要finish掉关联的Activity。

如果上面的描述不是很直观的话,那我举个栗子说明,我在HostActivity 实例2里打开两个Flutter页面,Widget O和Widget P,当这两个Widget完成自己的任务后,就要进行退出操作,当我退出Widget P时,界面就变成了Widget O,这是没问题的,然后我继续退出WIdget O,如果不进行特殊处理的话,FlutterEngine将会展示Widget N,这显然不是我们想要的。我们预期的结果是此时要finish掉HostActivity 实例2,展示NativeOneActivity,想要达到这样的效果,就需要认为的把FlutterEngine 栈里面在不同HostActivity实例中显示的Widget进行特殊的区分。

好了,理论工作已经做完了,接下来就是愉快的Coding时光,我们面临任务清单有以下几个:

一、如何对FlutterEngine进行复用?

二、FlutterActivity、FlutterFragment还是FlutterView?

三、如何监听FlutterEngine的栈变化信息?

四、如何把监听到的栈信息同步到宿主Activity实例?

五、如何处理宿主Activity和Flutter页面的同步?


第一个问题,首先实例化FlutterEngine:

    /**
     * 初始化FlutterEngine
     * @param context 上下文
     * @param block 初始化状态回调 0 不需要初始化 1 开始初始化 2 初始化完毕
     */
    @Synchronized
    fun initFlutterEngine(context: Context, block: (status: Int) -> Unit) {
        //判断缓存已存在
        if (!FlutterEngineCache.getInstance().contains(FLUTTER_ENGINE)) {

            block(1)
            //初始化FlutterEngine
            var engine = FlutterEngine(context.applicationContext)
            //初始化BasicMessageChannel
            messageChannel = BasicMessageChannel(
                engine.dartExecutor,
                FLUTTER_MIX_STACK_CHANNEL,
                StringCodec.INSTANCE
            )
            //添加消息监听
            messageChannel?.setMessageHandler(messageHandler)
            
            this.initCallback = block

            //开始运行dart代码
            engine.dartExecutor.executeDartEntrypoint(
                DartExecutor.DartEntrypoint.createDefault()
            )
                        
            //缓存FlutterEngine
            FlutterEngineCache.getInstance().put(FLUTTER_ENGINE, engine)
        } else {
            block(0)
        }
    }

FlutterEngineCache已经帮我们封装好了FlutterEngine的缓存功能,不过要怎么使用这个缓存呢?

官方提供的FlutterActivity类中有一个withCachedEngine的静态方法,可以帮助我们获取使用缓存的Intent实例,也可以继承FlutterActivity重写getCachedEngineId方法,达到FlutterEngine复用的效果,直接启动FlutterActivity就可以加载Flutter页面,非常的简单方便,但是如果有一些稍微复杂的场景时,FlutterActivity就有些不太够用了。

第二个问题,Flutter 2.0 为我们提供了三种Flutter容器,分别是FlutterActivity(FlutterFragmentActivity)、FlutterFragment和FlutterView,以满足我们不同场景下的使用,从这三个类注释文档的丰富程度上来看,官方是非常推荐我们直接使用FlutterActivity的。简单看下源码就能发现,不管是FlutterActivity还是FLutterFragment,都是基于FlutterView实现的。如果项目有指定的基类需要继承或者要实现原生UI+Flutter UI的情况,或是想要更早的预热Flutter的Widget,FlutterView无疑会更灵活一些,所以这里我就选择使用了普通的Activity+FlutterView,伪代码如下:

class HostActivity : AppCompatActivity(){
  
  var flutterEngine //缓存中拿到的FlutterEngine
  var flutterChannel // BasicMessageChannel 下面会有专门的介绍
  var flutterView 
  onCreate(){
    //在onCreate方法调用之初,就提前预热FlutterEngine,可以使Widget加载更流畅
    flutterEngine.lifecycleChannel.appIsResumed()
    //同样在onCreate中发送消息给FluuterEngine,提前替换我们要加载的Widget,方式有过渡效果
    flutterChannel.sendMessage(routePath)
    super.onCreate()
    setContentView(flutterView = createFlutterView())
  }
  
  onResume(){
    super()
    flutterEngine.lifecycleChannel.appIsResumed()
  }
  
  onPause(){
    super()
    flutterEngine.lifecycleChannel.appIsPaused()
  }
  
  onDestroy(){
    super()
    flutterView.detachFromFlutterEngine()
  }
}

第三个问题,监听FlutterEngine里Widget栈信息的变化,在MaterialApp初始化的时候,有一个navigatorObservers的参数,支持我们添加navigator的变化信息(Navigator是Flutter提供的页面切换类)。

void main() {
  //这里使用了Get框架来演示,但这不是必须的
  var getApp = GetMaterialApp(
    initialRoute: RouterMapper.ROUTER_HOME,
    getPages: [
      GetPage(
          name: RouterMapper.ROUTER_HOST, //空白的HostView
          page: () => Host(),
          transition: Transition.rightToLeft),
      ...
    ],
    navigatorObservers: [RouteNavigatorObserver()],
  );
  runApp(getApp);
  RouterHelper.registerApp(); //在这里注册消息监听
}

class RouteNavigatorObserver extends NavigatorObserver {
  @override
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
    // 有Widget入栈 可以从route中获取name信息并同步到Native
  }

  @override
  void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
    // 有Widget出栈 可以从route中获取name信息并同步到Native
  }

  @override
  void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
    // 新的Widget替换了前一个Widget 可以从newRoute&oldRoute中获取name信息并同步到Native
  }

  @override
  void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
    // 一个Widget被remove了,一般不建议这么做
  }
}

通过继承NavigatorObserver类并注册到navigatorObservers中,我们实现了对FlutterEngine中路由信息变化的监听,那么接着来下一个问题,我们如何把消息同步给Native?

第四个问题,Flutter提供了三种方式用来和Native交互数据,分别是BasicMessageChannel,MethodChannel和EventChannel,其中前两个是可以双向传递数据的,EventChannel只支持Native给Flutter传递数据,常用语系统消息的一个通知。MethodChannel用来传递方法调用,BasicMessageChannel用来传二进制数据,更适合我们的试用场景,接下来我们需要在Flutter和Native层住的BasicMessageChannel用来数据的交互。

那我们分别在Flutter和Native层创建BasicMessageChannel:

//Flutter
class RouterHelper {
  //注册消息通道
  static final _routerMessageChannel =
      BasicMessageChannel<String?>("flutter_router_channel", StringCodec());

  static registerApp() {
    //防止下面空异常
    WidgetsFlutterBinding.ensureInitialized();
    //注册消息监听
    _routerMessageChannel.setMessageHandler((String? message) async {
      if (message != null) {
        //native层传递过来的消息
      }
    });
  }
}
//Kotlin
fun initMessageChannel(){
  var messageChannel = BasicMessageChannel(
      engine.dartExecutor,
      "flutter_router_channel",
      StringCodec.INSTANCE)
  
  messageChannel.setMessageHandler{ message, reply ->
      //处理Flutter发送的消息
  }
}

消息的发送由BasicMessageChannel提供的send方法实现,在上面的一个问题中,我们已经通过监听Navigator来判断Flutter中页面的变化信息,接下来就要在监听方法中,把对应的信息发送到Native层进行处理,在Native层我们可以抽象出来一个接口,让HostActivity实现这接口,通过面向接口编程的方式,把消息分发到当前活跃的HostActivity中。而HostActivity中维护了一个栈来记录Flutter中栈信息的变化。

/**
 * flutter路由变化回调
 */
interface RouteCallback {
        //flutter route入栈消息
    fun onPush(route: String)
        //flutter route出栈消息
    fun onPop(route: String)
        //flutter route栈替换消息
    fun onReplace(newRoute: String, oldRoute: String)
        //flutter route被移除的消息
    fun onRemove(route: String)
        //flutter申请打开Native页面的消息
    fun routeNative(nativeRoute: String, params: HashMap<String, Any>? = null)
        //flutter route出栈消息
    fun getLifecycleOwner(): LifecycleOwner
}

class HostActivity : AppCompatActivity(), RouteCallback {
      //记录flutter的路由栈信息
    private val routeStack: Stack<String> by lazy {
        Stack()
    }
        
    //TODO RouteCallback接口的实现方法,在方法中对routeStack进行入栈和出栈操作
}

最后一个问题,我们需要用户进行返回操作的时候,Widget页面要和HostActivity保持同步,例如当前HostActivity只打开了一个Widget,那么这个Widget退出的时候,HostActivity也要同步finish掉,即使FlutterEngine中还存在其他的Widget。我们可以通过HostActivity中维护的routeStack来实现。

override fun onBackPressed() {
        //判断当前栈的长度,如果当前HostActivity栈的长度小于等于1,那么Activity就要finsih
    if (routeStack.size <= 1) {
      super.onBackPressed()
      flutterEngine.navigationChannel.popRoute()
    } 
    //正常执行Navigator的pop操作
    else {
      flutterEngine.navigationChannel.popRoute()
    }
}

当然还有一个问题需要注意,有些Widget的返回并不是通过返回键处理的,那我们就需对onPop方法进行一些特殊处理:

override fun onPop(route: String) {
    //正常处理出栈逻辑
    if (routeStack.isEmpty() && !isFinishing) {
        //如果已经全部出栈,则当前HostActivity的业务已完成  
        finish()
    }
}

这样,就能把HostActivity和该宿主内打开的Widget进行关联,做到共同进退,主要的逻辑实现起来也不负责,也不需要使用任何第三方的框架,这样就有了很大的灵活性,尤其是对Flutter来说,Flutter的生态还不是十分成熟,避免使用侵入性过大的框架,也为以后技术的迭代留有足够的空间。


缺点和不足

虽然通过对Flutter内栈信息变化的监听和通过BasicMessageChannel的消息同步来做好混合栈管理,但是目前依然有一些问题需要解决。

1:Flutter和Native页面的返回值监听,OneActivity打开了一个HostActivity并想要在HostActivity退出的时候给OneActivity一个返回值,这个功能实例代码中还没有实现,等有空继续完善该框架。

2:跨域返回的问题,这个“跨域”是我自己定义的一个名字,就是如果我打开了两个HostActivity,第二个HostActivity通过Navigator的remove方法想要关闭掉第一个HostActivity中打开的Widget,这个是可以做到了,因为这两个HostActivity使用的是同一个FlutterEngine,如果出现这种操作,那就会让第一个HostActivity的页面显示出现问题。其实我在想,理想的混合管理框架应该是把每个打开的HostActivity当做一个WebView来看待,想要在第二个WebView中关闭第一个WebView里打开的页面,这显然也是不太合适的。这种跨域返回是否需要支持,也是一个值得考虑的问题。


好了,以上就是本篇文章的全部内容,该方案目前还只是演示阶段,还没有完善到成为一个高可用的框架,只是为混合开发中管理混合栈提供了一个思路,如果大家有什么更好的方案,欢迎多多交流~
一起嗨皮呀
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容