作者简介
Deway,携程资深工程师,iOS客户端开发,热衷于大前端和动态化技术;
Frank,携程高级工程师,关注移动端热门技术,安卓客户端开发。
前言
随着各种多端技术的蓬勃发展,如今的移动端和前端早已不再拘泥于自身的边界,而是不断延伸、扩展和融合,逐步向着真正的大前端技术迈进。跨端技术也从早期的Cordova/PhoneGap、纯H5页面发展到如今的ReactNative(以下简称RN)、Weex、小程序、Flutter群雄并存的局面。各种技术栈各有优劣和特点,技术选型需视团队自身情况而定,没有绝对好坏之分。然而在实际开发中,并不是只选用一种技术栈,那么研究多种技术栈融合和嵌套使用的就有了迫切的必要性。
本文我们从实际业务场景出发,初步实践了在RN里面嵌套flutter view、在native里面嵌套flutter view,探索其可行性,并回顾这个过程中遇到的一些问题和解决方案。
一、背景
1.1 现状
随着时间的推移,携程app中酒店列表和详情两大页面已经全部转为flutter技术栈,初期的使用场景也比较单一,只在主流程使用。然而业务不断迭代之后,flutter页面在其他流程使用的频次也越来越高,比如列表页面,作为酒店一线SKU产品展示的主页面,复用的需求非常旺盛和迫切。那么此时需要思考更多的通用性和可移植性,以适用于在不同的场景不同的技术栈页面嵌入使用。
1.2 两大场景
- 场景一:上左图为携程大搜页面的酒店列表。在本次技术改造之前,大搜页面的酒店列表和酒店主流程的列表大相径庭,差异不光是在UI展示方面,酒店频道列表的信息和优惠更加完整,价格体系也更统一。大搜的技术栈是RN,而酒店列表技术栈是flutter,如果想要统一无非两种途径:1)查漏补缺追平RN侧业务;2)将flutter酒店列表打包嵌入RN页面。
- 场景二:上右图为查询页钟点房标签下的钟点房列表,查询页目前还是native技术栈,那么此时也必须考虑将flutter列表页嵌入native页面。
对于不同技术栈的业务场景,不断为多侧业务同步补齐功能,维护成本是相当巨大的。对于酒店列表业务来说,唯一可选的路径就是在大搜和酒店主频道等业务场景中共用一个列表,甩开历史包袱,实现真正意义上的业务对齐。所以,基于以上两个场景,我们初步探索了flutter页面在多种复杂结构的嵌套使用,即RN中嵌套flutter、原生ListView中嵌套flutter,并将解决方案记录在本文中,为之后可能遇到的多业务场景提供一个思路。
二、RN中使用Flutter
2.1 可行方案的探究
在接到这个嵌套需求的时候,考虑到成本最低的方式是直接在大搜页面层上盖列表,即在切换到酒店tab的时候将flutter view盖在上层。实际上在思考利弊之后,放弃了这个方案。有如下几个弊端:
- RN无法单独控制flutter view层的展示, 需要通过层层事件通知,复杂且繁琐
- RN需要计算出上盖offset的偏移值,在不同屏幕尺寸存在偏差
- 在不同tab切换的时候,flutter控制器生命周期难以及时被同步
基于上述的几个问题,那么考虑的方向就偏向于直接把flutter view包装成RN的Component使用。
2.2 Native Components的原理
我们先简单回顾下RN的启动流程(以iOS为例)。
RN启动流程
- 程序启动完成的时候创建了根视图RCTRootView,负责展示所见内容的根容器
- 创建管理native和js的交互的桥接对象RCTBridge
- 创建RCTBatchedBridge批量桥对象, 这个类是负责交互通信的核心
- 加载所有自定义的native modules。这些modules最终会被转为RCTModuleData类型,包含方法列表、队列等信息,并缓存到全局的模块配置信息表中
- 通过jsExecutor将native创建的模块表注册到js端
- 开始异步加载js代码,执行完成后通知到RCTUIManager去调用对应的native组件进行渲染
这里省去了一些非关键步骤,可以看到RN本身是支持调用native原生组件的,调用native UI components这一步比较关键的是RCT_EXPORT_MODULE。这是一个宏定义,重写了load方法,在其中调用RCTRegisterModule方法。再看看RCTRegisterModule的实现,其实就是将moduleClass注册到一个全局容器。
代码语言:javascript复制/// iOS
#define RCT_EXPORT_MODULE(js_name)
RCT_EXTERN void RCTRegisterModule(Class);
(NSString *)moduleName
{
return @ #js_name;
}
(void)load
{
RCTRegisterModule(self);
}
代码语言:javascript复制/// iOS
void RCTRegisterModule(Class moduleClass)
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
RCTModuleClasses = [NSMutableArray new];
RCTModuleClassesSyncQueue =
dispatch_queue_create("com.facebook.react.ModuleClassesSyncQueue", DISPATCH_QUEUE_CONCURRENT);
});
RCTAssert(
[moduleClass conformsToProtocol:@protocol(RCTBridgeModule)],
@"%@ does not conform to the RCTBridgeModule protocol",
moduleClass);
// Register module
dispatch_barrier_async(RCTModuleClassesSyncQueue, ^{
[RCTModuleClasses addObject:moduleClass];
});
}
moduleClass注册完之后什么时候使用呢?接下来就到RCTCxxBridge的start方法, 将所有注册的组件放入moduleClasses,并将继承于RCTViewManager的module单独拿出来再处理。
代码语言:javascript复制/// iOS
(void)[self _initializeModules:RCTGetModuleClasses() withDispatchGroup:prepareBridge lazilyDiscovered:NO];
代码语言:javascript复制/// iOS
_componentDataByName = [NSMutableDictionary new];
for (Class moduleClass in _bridge.moduleClasses) {
if ([moduleClass isSubclassOfClass:[RCTViewManager class]]) {
RCTComponentData *componentData = [[RCTComponentData alloc] initWithManagerClass:moduleClass bridge:_bridge];
_componentDataByName[componentData.name] = componentData;
}
}
从头到尾来理解下,在main函数开始执行之前,将申明为RCT_EXPORT_MODULE的组件注册到全局容器中,并在bridge中生成RCTViewManager对应的RCTComponentData对象,并配置moduleConfig的模块信息表(上述步骤4中完成)。然后在RCTUIManager中建立和js布局层的对应关系,最后在js层进行计算、排版之后通过UIManager.js通知到native层的RCTUIManager进行渲染绘制。这就是一个RN使用Native原生组件的原理和过程,由此可以见RN对于modules层的设计具备高度可扩展性和伸缩性。
2.3 前置条件
2.3.1 组件生命周期
携程主站是一个包含native、RN、H5、flutter技术栈的混合app,基础框架由native代码实现,因此flutter业务需要依赖于兼容native、flutter的技术框架,业内比较成熟的解决方案是FlutterBoost。
FlutterBoost的理念是将flutter像Webview那样来使用,通过native容器来管理flutter页面。类似的,携程app中RN技术栈也是一个RN-native混合方案CRN,用native容器封装了RN页面。这样的方案可以实现一个native容器中同时嵌套native、RN、flutter组件,并由native容器管理生命周期。
那flutter-RN组件嵌套时,如何实现不同组件生命周期相关联?由于目前列表flutter view是依附列表控制器存在的,在创建RN对应的列表控制器view时,将flutter view的控制器挂载到父控制器,这样实现了flutter view依赖RN的生命周期,伪代码如下。
代码语言:javascript复制// iOS
UIViewController *rootVC = (UIViewController *)[self currentVisibleViewController];
[rootVC addChildViewController:flutterViewControler];
从官方手册可知,
代码语言:javascript复制This method creates a parent-child relationship between the current view controller and the object in the childController parameter. This relationship is necessary when embedding the child view controller’s view into the current view controller’s content. If the new child view controller is already the child of a container view controller, it is removed from that container before being added.
Android的实现类似,从xml文件可以看出,同样是将flutter view挂载到RN父ViewGroup中,即RNLinearLayout。
代码语言:javascript复制<!-- Android -->
<?xml version="1.0" encoding="utf-8"?>
<com.android.list.RNLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.android.list.RNFrameLayout
android:id="@ id/flutter_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.android.list.RNLinearLayout>
小结一下,flutter的生命周期可以依赖于嵌入的父组件,如下表所示。
父组件 | 子组件 | 生命周期依赖对象 |
---|---|---|
RN | Flutter | RN |
Native | Flutter | Native |
2.3.2 flutter页面启动方式
由FlutterBoost官方文档可知,flutter页面以路由的方式启动,携程app中实现(以Android为例)如下代码段所示。启动时需配置一个flutter url,包含页面类型、业务参数、UI相关参数等,用一个fragment来管理view,并在fragment的生命周期不同阶段完成flutter初始化、绘制、销毁等操作,伪代码如下。
代码语言:javascript复制/// Android
val flutterFragment = new FlutterFragment
.CachedEngineFragmentBuilder(FlutterFragment.class, FlutterBoost.ENGINE_ID)
.flutterURL(mFlutterURL)
.build();
fragmentTransaction.add(R.id.fragment_stub, flutterFragment).commitNowAllowingStateLoss();
2.4 开始
从前述原理来看,native的UI组件直接遵守RCTViewManager的模式提供view方法就可以被RN调用。那么是不是flutter view的嵌入也可以遵从这套范式呢?顺着这个思路设计结构图如下:
rnFlutter混合结构图
- RNTListManager继承于RCTViewManager,负责提供view给RN,设置和更新视图的属性,并接受RN传递过来的事件,比如目的地关键字、入离日期以及业务埋点数据。
- FlutterEmbedderPlugin,统一处理flutter view的创建、回收、销毁以及与之相关的事件回调。之后需要增加业务场景时,那么创建其子类处理具体业务就行。
- GlobalSearchEmbedderPlugin作为FlutterEmbedderPlugin子类,负责处理具体场景下的业务,提供RNTListManager需要的视图。
js层包装类如下:
代码语言:javascript复制/// RN伪代码
class RNFlutterView extends PureComponent {
componentDidMount() {
const {keyword} = this.props;
let data = { keyword: keyword};
UIManager.dispatchViewManagerCommand(
findNodeHandle(this),
UIManager.FlutterListView.Commands.callFlutterEmbedderMethod,
[data]
);
}
/*..... 省略了无关的业务代码 */
render() {
return <FlutterListView style={{width: screenWidth}}
/>
}
componentWillUnmount() {
/// 页面消失的时候回调flutterEmbedder
UIManager.dispatchViewManagerCommand(
findNodeHandle(this),
UIManager.FlutterListView.Commands.disposeView,
null
);
}
}
const FlutterListView = requireNativeComponent('FlutterListView', RNFlutterView);
export default RNFlutterView;
下面罗列主体结构的部分代码。iOS的实现部分伪代码如下:
代码语言:javascript复制/// iOS
/// RNTListManager
RCT_EXPORT_MODULE(FlutterListView)
- (UIView *)view {
if (self.viewPlugin == nil) {
self.viewPlugin = [[GlobalSearchEmbedderPlugin alloc] init];
[self.viewPlugin createView];
}
return self.viewPlugin.exportView;
}
RCT_EXPORT_METHOD(setDestinationParam:(nonnull NSNumber *)reactTag param:(NSDictionary *)paramDict) {
[self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
UIView *view = (UIView*)viewRegistry[reactTag];
if (view) {
[self.viewPlugin setDestinationData:paramDict];
}
}];
}
RCT_EXPORT_METHOD(disposeView:(nonnull NSNumber *)reactTag) {
[self.bridge.uiManager addUIBlock:^(__unused RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
UIView *view = (UIView*)viewRegistry[reactTag];
if (view) {
[self.viewPlugin disposeView];
}
}];
}
代码语言:javascript复制/// iOS
@interface GlobalSearchEmbedderPlugin : FlutterEmbedderPlugin
- (void)createView;
- (void)setDestinationData:(NSDictionary *)param;
@end
@implementation GlobalSearchEmbedderPlugin
- (void)createView {
if (self.listVC == nil) {
[self initListViewController];
[self bindCacheData];
/* listVC挂载到UIViewController, 生命周期保持一致*/
UIViewController *rootVC = (UIViewController *)[self currentVisibleViewController];
[rootVC addChildViewController:self.listVC];
}
}
- (void)setDestinationData:(NSDictionary *)param {
/// 省略业务代码部分
[self.listVC sendSerivce];
}
@end
Android平台上对于flutter view嵌入RN容器有相似的流程。首先需要在RN初始化时创建ViewReactPackage,它会提供给RN一个RNViewManager,伪代码如下:
代码语言:javascript复制/// Android
class GlobalSearchEmbedderPlugin : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): MutableList<NativeModule> {
return ArrayList()
}
override fun createViewManagers(reactContext: ReactApplicationContext): MutableList<SimpleViewManager<*>> {
val result = ArrayList<SimpleViewManager<*>>()
val listManager = RNViewManager()
result.add(listManager)
return result
}
}
RNViewManager在RN里注册嵌入native模块的名字、layout、RN和native通信接口实现。native模块的名字需要与RN中的RCT_EXPORT_MODULE名字、iOS native模块的名字对应。command接口实现了相关业务逻辑,比如initFlutterFragment()方法中创建flutter view,其它command接口中实现了目的地关键字、入离日期以及业务埋点数据等等。
代码语言:javascript复制/// Android
class RNTListManager : SimpleViewManager<RNLinearLayout>() {
companion object {
const val COMMAND_SET = 1
}
override fun getName(): String {
return "FlutterListView"
}
override fun createViewInstance(reactContext: ThemedReactContext): RNLinearLayout {
return LayoutInflater.from(reactContext)
.inflate(R.layout.flutter_container, null) as RNLinearLayout
}
override fun getCommandsMap(): MutableMap<String, Int> {
return MapBuilder.of(
"setDestinationParam", COMMAND_SET,
)
}
override fun receiveCommand(root: RNLinearLayout, commandId: String?, args: ReadableArray?) {
when (commandId?.toInt()) {
COMMAND_SET -> initFlutterFragment(root.context, args)
}
}
}
RN native layout有一些特殊,由于flutter view自身是一个framelayout,RN native layout定义为一个子view是framelayout的linearlayout,这样可以实现动态地在RN native viewGroup中加入flutter view。根据官方文档,RN native view需要覆写requestLayout()方法,并在方法中重新做测量和布局,伪代码如下:
代码语言:javascript复制/// Android
public class RNLinearLayout extends LinearLayout {
public RNLinearLayout(Context context) {
super(context);
}
public RNLinearLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public RNLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
private final Runnable measureAndLayout = () -> {
measure(
MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY));
layout(getLeft(), getTop(), getRight(), getBottom());
};
@Override
public void requestLayout() {
super.requestLayout();
post(measureAndLayout);
}
}
考虑到flutter view并不是单一场景使用,比如上述1.2节的场景二,需要在酒店查询页移植列表页。最终的结构设计如下图,Native层对应具体的业务创建对应的EmbedderPlugin,负责处理相关的事件,flutter层在不同路由下对应具体的page。
flutterEmbedder结构
三、Native嵌套Flutter
3.1 可行方案的探究
从view树的角度,RN嵌套flutter的实现和native嵌套flutter的实现是一致的。RN嵌套flutter时,flutter view作为一个view group加入到RN container中,而native嵌套flutter时,flutter view作为一个view group直接加入到native view树中。这样的实现需要考虑四个要点:点击事件传递、view启动顺序、flutter层与native层的业务交互、页面的生命周期。
3.2 方案实现
3.2.1 点击事件传递
处理点击事件传递,flutter view作为一颗view子树,能够直接接受到从上到下传递的点击事件。点击事件传递过程如下左图所示,在flutter点击区域由flutter处理事件,若flutter不处理则回到父view处理。
flutter点击事件
flutter滑动事件
list滚动事件则需要在flutter view子树的祖先view中进行适当屏蔽,确保flutter列表能嵌套滚动。本次实现的业务场景是1.2节中的场景二,在一个native滚动列表最下方嵌入flutter滚动列表,flutter滚动列表正好能占满一个屏幕。整个列表向下滚动过程中,先滚动外层列表,当滚动到底部时滚动flutter列表;反之,整个列表向上滚动过程中,先滚动flutter列表,当flutter列表滚动到头部时滚动,向上滚动外层列表。
如上图所示,滑动过程(1)是flutter列表可滑动场景,需要将事件返回外层列表;滑动过程(2)是列表可滑动场景;滑动过程(3)是flutter列表不可上滑,而外层列表可上滑场景,此时需要将事件传递到外层列表使其上滑,这个过程中会有顿挫感,我们在实现中给外层列表添加了滑动效果进行补偿。
3.2.2 view启动顺序
通常是先创建native view树,在view树创建成功后,手动创建flutter view并加入view树中。手动创建flutter view可以根据业务需要,以懒加载的方式创建。在app启动之后,不管是否启动flutter view,都需要先初始化flutter引擎。
3.2.3 flutter-native业务交互
业务在flutter层与native层的交互,主要通过flutter method channel,在native层预先注册method channel和各种事件,在flutter view启动之后由flutter层或native层双向发送消息。
3.2.4 页面的生命周期
生命周期已在2.3.1节中详细描述,可以由native层容器或者flutter view来控制,通常是根据业务所占页面比例决定,我们的实现中是将flutter view包在一个native容器中,这样可以用相同的方法在native控制生命周期。
四、写在最后
至此我们初步探索了不同技术栈UI的嵌套,为之后的组件的复用提供了一套切实可行的实践依据。后续会在此基础上做进一步的优化,比如flutter view的滚动事件如何很平滑地传输到native,使得双列表嵌套滚动的时候没有顿挫感。在实践中,随着组件复杂度的和依赖度升高,混合的改造成本也是逐步增加的,那么是否需要混合、如何轻量化的移植也是需要进一步衡量和思考的。
参考文献
- Native UI Components
- flutter_boost
- ReactNative流程源码分析