rn-relates icon indicating copy to clipboard operation
rn-relates copied to clipboard

混编下的RCTRootView加载方式

Open ljunb opened this issue 8 years ago • 29 comments

算下来转正也接近一个月了,在这里参与了两个混编项目,遇到一些坑,说实在的,自己差点就忘了做纯RN开发是什么感觉了。Σ( ° △ °|||)︴


目前正开发中的项目,是一个面向销售人员的汽车营销APP,基于公司原生技术的积累,该项目也是在原生的骨架上来开发。按目前项目分工来看,原生与RN的业务比例呈1:1分布,仍然没达到团队老大的理想效果,他期望的是2:3。RN表示我也很绝望啊,但是有些东西给原生这大佬去做,肯定是要好的嘛~

在目前的APP架构中,底部分4个Tab,其中有两个界面是由原生ViewController加载的RN界面。在实践过程中可以发现,如果在点击切换界面时才加载RNrootView,那么在第一次切换到RN界面时,就会出现短暂的白屏,非第一次则没有该问题。在与小伙伴的一番讨论之后,决定是在APP启动之后,采用预加载的形式提前渲染这两个RN界面。

其实需求比较简单,就是提前把rootView给加载出来,然后在相应ViewControllerviewDidLoad中去把保存好的rootView取出来,赋值给self.view就可以了。这里用单例形式来管理:

#import <React/RCTRootView.h>
#import <React/RCTBundleURLProvider.h>

@interface PCReactRootViewManager : NSObject
/*
 * 获取单例
 */
+ (instancetype)manager;

/*
 * 根据viewName预加载bundle文件
 * param: 
 *     viewName RN界面名称
 *     initialProperty: 初始化参数
 */
- (void)preLoadRootViewWithName:(NSString *)viewName;
- (void)preLoadRootViewWithName:(NSString *)viewName initialProperty:(NSDictionary *)initialProperty;

/*
 * 根据viewName获取rootView
 * param:
 *     viewName RN界面名称
 *
 * return: 返回匹配的rootView
 */
- (RCTRootView *)rootViewWithName:(NSString *)viewName;

@end

在混编模式中,渲染不同的RN界面有两种方式:

  • 统一由一个ViewController加载rootView,在initialProperties中添加区分不同界面的标识位,然后在JS中根据该标识位,渲染不同的Component
  • AppRegistryregisterComponent方法中,第一个参数需要跟加载的moduleName一一对应。通过加载对应的moduleName,然后在JS中注册对应组件,也可以达到渲染不同界面的目的

我司在实践中,采用的是第二种方式。在实践中,第二种方式依然可以统一由一个ViewController来作为加载rootView的入口,也可以根据业务情况,来新建一些独立的ViewController,方便业务逻辑分离。

抛开两种方式的不同点,我们会发现,不管用哪种方式,都需要有一个新建RCTRootView实例的过程,而查看源码可知,在这之前是需要新建一个RCTBridge实例的:

// RCTRootView.m
- (instancetype)initWithBundleURL:(NSURL *)bundleURL
                       moduleName:(NSString *)moduleName
                initialProperties:(NSDictionary *)initialProperties
                    launchOptions:(NSDictionary *)launchOptions
{
  RCTBridge *bridge = [[RCTBridge alloc] initWithBundleURL:bundleURL
                                            moduleProvider:nil
                                             launchOptions:launchOptions];

  return [self initWithBridge:bridge moduleName:moduleName initialProperties:initialProperties];
}

关于原生与RN的通信原理,网上已经有不少资料,有兴趣的伙伴戳:

所以,除了可做预加载之外,我们还可以在RCTBridge实例这里做下处理。首先让该类实现RCTBridgeDelegate协议,再添加一个bridge属性,以便不同的ViewController都可以访问到这个属性:

#import <React/RCTRootView.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTBridge.h>

@interface PCReactRootViewManager : NSObject<RCTBridgeDelegate>

// 全局唯一的bridge
@property (nonatomic, strong, readonly) RCTBridge * bridge;

/*
 * 获取单例
 */
+ (instancetype)manager;

/*
 * 根据viewName预加载bundle文件
 * param: 
 *     viewName RN界面名称
 *     initialProperty: 初始化参数
 */
- (void)preLoadRootViewWithName:(NSString *)viewName;
- (void)preLoadRootViewWithName:(NSString *)viewName initialProperty:(NSDictionary *)initialProperty;

/*
 * 根据viewName获取rootView
 * param:
 *     viewName RN界面名称
 *
 * return: 返回匹配的rootView
 */
- (RCTRootView *)rootViewWithName:(NSString *)viewName;

@end

具体的.m文件实现:

#import "PCReactRootViewManager.h"

@interface PCReactRootViewManager ()
// 以 viewName-rootView 的形式保存需预加载的RN界面
@property (nonatomic, strong) NSMutableDictionary<NSString *, RCTRootView*> * rootViewMap;
@end

@implementation PCReactRootViewManager

- (void)dealloc {
    _rootViewMap = nil;
    [_bridge invalidate];
    _bridge = nil;
}

+ (instancetype)manager {
    static PCReactRootViewManager * _rootViewManager = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _rootViewManager = [[PCReactRootViewManager alloc] init];
    });
    return _rootViewManager;
}

- (instancetype)init {
    if (self = [super init]) {
        _rootViewMap = [NSMutableDictionary dictionaryWithCapacity:0];
        _bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:nil];
    }
    return self;
}

- (void)preLoadRootViewWithName:(NSString *)viewName {
    [self preLoadRootViewWithName:viewName initialProperty:nil];
}

- (void)preLoadRootViewWithName:(NSString *)viewName initialProperty:(NSDictionary *)initialProperty {
    if (!viewName && [_rootViewMap objectForKey:viewName]) {
        return;
    }
    NSMutableDictionary *tmpInitialProperty = [NSMutableDictionary dictionaryWithDictionary:initialProperty];
    [tmpInitialProperty setObject:viewName forKey:@"viewName"];
    // 由bridge创建rootView
    RCTRootView * rnView = [[RCTRootView alloc] initWithBridge:self.bridge 
                                                    moduleName:@"YourAppName" 
                                             initialProperties:initialProperty];
    [_rootViewMap setObject:rnView forKey:viewName];
}

- (RCTRootView *)rootViewWithName:(NSString *)viewName {
    if (!viewName) {
        return nil;
    }
    return [self.rootViewMap objectForKey:viewName];
}

#pragma mark - RCTBridgeDelegate
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
    return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
}

以下是预加载与读取rootView的简易方式:

// APP启动后,在合适时机加载
[[PCReactRootViewManager manager] preLoadRootViewWithName:@"discovery"];
[[PCReactRootViewManager manager] preLoadRootViewWithName:@"mine"];

// 渲染界面时,获取rootView
self.view = [[PCReactRootViewManager manager] rootViewWithName:@"discovery"];

普通的RN界面渲染方式:

- (void)setupRootView {
    NSString * moduleName = [self.initialProperty objectForKey:@"viewName"];
    if (!moduleName || ![moduleName isKindOfClass:[NSString class]]) {
        NSLog(@"viewName为空,无法加载rootView");
        return;
    }
    // 设置初始属性
    [self configInitialProperties];

    // 根据唯一的bridge来创建rootView,减少新建bridge的时间和资源开销
    RCTRootView * rnView = [[RCTRootView alloc] initWithBridge:[PCReactRootViewManager manager].bridge
                                                    moduleName:@"YourAppName"
                                             initialProperties:self.initialProperty];
    self.view = rnView;
}

在 JavaScript 端:

// index.js
...
AppRegistry.registerComponent('YourAppName', () => Root);


// Root.js
class Root extends React.Component {
  render() {
    const { viewName } = this.props;
    if (viewName === 'Page1') {
      return <Page1 />;
    } else if (viewName === 'Page2') {
      return <Page2 />;
    }
  }
}

如果项目中还集成了 react-navigation 的话,入口文件需要修改为:

import { createStackNavigator } from 'react-navigation';
const createNavigatorWithInitialRouteName = (initialRouteName = 'Splash') => createStackNavigator(
{
  PageA: {},
  ...
}, {
  initialRouteName,
  ...
});

export default class NavigatorContainer extends React.Component {
  constructor(props) {
    super(props);
    this.Navigator = createNavigatorWithInitialRouteName(props.viewName);
  }
  
  render() {
    const { Navigator } = this;
    return <Navigator />;
  }
}

ljunb avatar Aug 28 '17 14:08 ljunb

现在有个问题请教下,项目刚接触rn,现在是每个新的rn都会去新建一个vc,但是在返回上一页vc释放了,但是内存并没有被释放,切每个页面都需要重新加载bundle 采用您这种写法,全局唯一的bridge 可以解决这种问题么

lh9403 avatar Dec 18 '17 12:12 lh9403

@lh9403 嗯是的,每次新建VC不会重新加载bundle,不过今天试了下,发现pop之后,内存貌似没怎么降,欢迎一起探讨是啥原因导致😆

ljunb avatar Dec 19 '17 01:12 ljunb

greaaaat job!!! 总结的真的好,学到了很多,3q,3q啦

sheepmiee avatar May 24 '18 05:05 sheepmiee

@sheepmiee 有帮助就好,本来当做自己的实践记录而已~

ljunb avatar May 24 '18 06:05 ljunb

请教 一个问题 我在 加载 [[ReactRootViewManager manager] preLoadRootViewWithName:@"tastRN"];// [[ReactRootViewManager manager] preLoadRootViewWithName:@"mainPage"]; 这个的时候mainpage 就报错 是为什么?

loss99 avatar May 31 '18 06:05 loss99

@loss99 请问是报什么错来的

ljunb avatar May 31 '18 07:05 ljunb

请问一下 内存没有降低这个应该怎么解决

SanlinBlackball avatar May 31 '18 08:05 SanlinBlackball

Application mainPage has not been registered.

Hint: This error often happens when you're running the packager (local dev server) from a wrong folder. For example you have multiple apps and the packager is still running for the app you were working on before. If this is the case, simply kill the old packager instance (e.g. close the packager terminal window) and start the packager in the correct app folder (e.g. cd into app folder and run 'npm start').

This error can also happen due to a require() error during initialization or failure to call AppRegistry.registerComponent.

loss99 avatar May 31 '18 10:05 loss99

2018-05-31 4 53 21 2018-05-31 4 53 03 2018-05-31 4 54 32 2018-05-31 6 27 24

loss99 avatar May 31 '18 10:05 loss99

我 现在 正在做 混合的这样一个 项目 非常希望您能指导一下

loss99 avatar May 31 '18 10:05 loss99

@loss99 如果是这种方式的话,每添加一个页面,都需要在 JavaScript 这边进行注册:

import MainPage from './pages/main/MainPage';
AppRegistry.registerComponent('mainPage', () => MainPage);

附:这里推荐另外一种方式,源码需要有所修改:

- (void)preLoadRootViewWithName:(NSString *)viewName initialProperty:(NSDictionary *)initialProperty {
    if (!viewName && [_rootViewMap objectForKey:viewName]) {
        return;
    }
    NSMutableDictionary *newDict = [NSMutableDictionary dictionaryWithDictionary:initialProperty];
    [newDict setObject:viewName forKey:@"viewName"];
    // 由bridge创建rootView
    RCTRootView * rnView = [[RCTRootView alloc] initWithBridge:self.bridge 
                                                    moduleName:@"YourAppRegistryComponentName" 
                                             initialProperties:newDict];
    [_rootViewMap setObject:rnView forKey:viewName];
}

在 JavaScript 端只要注册一个组件即可,具体的页面区分,依然可以通过 this.props.viewName 来获取:

import App from './pages';
AppRegistry.registerComponent('YourAppRegistryComponentName', () => App);

ljunb avatar May 31 '18 10:05 ljunb

@SanlinBlackball 囧,项目现在没有在跟了,内存这块没怎么跟进,之前印象是增加的不多,所以没怎么管

ljunb avatar May 31 '18 10:05 ljunb

嗯 ,感谢您的回答, 如果我用第二个方法只注册 一个组件 "应用"这个 VC 可以对应的使用这个注册的 RN, 但是"我"这个 VC 怎么使用 mainPage 这个 RN 页面呢? this.props.viewName 没看懂在哪里用... 初入 RN坑 才疏学浅 请见谅 ... 能具体一点吗?

loss99 avatar Jun 01 '18 01:06 loss99

1、首先每新建一个 VC 实例后,都传入了 initialProperties 的初始参数,类型为字典,该字典里面保存了当前的 RN 页面名称,比如你可以用 viewName 来命名;

2、回到 JS 端,注册的唯一组件对应到 App.js,initialProperties 将作为 App 页面的 props,所以就可通过 this.props.viewName 获取到在原生设置的 RN 页面名称,剩下的就是根据这个名称,去匹配返回不同的 RN 页面组件了,比如 if ,或者是 switch,又或者配置一个路由表(可参考我这个 仓库 的做法:入口文件 - App,以及相关路由配置 - routers

ljunb avatar Jun 01 '18 01:06 ljunb

非常感谢您的回答,我现在已经完成了混合开发的基础工作,希望能加您的微信或者 QQ 方便请教您一些问题

loss99 avatar Jun 04 '18 06:06 loss99

@loss99 那你加我QQ好了 824771861

ljunb avatar Jun 04 '18 08:06 ljunb

@ljunb 这个会不会是因为创建了一个单例,即使退出界面但是渲染的view并没有被销毁

SanlinBlackball avatar Jun 08 '18 03:06 SanlinBlackball

@SanlinBlackball 刚才调试了下,界面 pop 掉的时候,会走 PayloadVC 的 dealloc 方法,所以是释放掉的了。没有销毁的情况,猜测是预加载那种(单例类中的数组持有了预加载的 VC),不过也提供了移除 VC 的相应方法。暂时还不知道,是什么原因导致这个内存不减的问题

ljunb avatar Jun 08 '18 06:06 ljunb

RCTBridge * bridge = _bridge; [bridge invalidate]; bridge = nil;

ZhangRuixiang avatar Nov 26 '18 08:11 ZhangRuixiang

@ZhangRuixiang thx

ljunb avatar Dec 01 '18 13:12 ljunb

更详细的应用例子 RNProjectPlayground,可看看 iOS 端的 native 相关代码

ljunb avatar Dec 26 '18 06:12 ljunb

RCTBridge * bridge = _bridge; [bridge invalidate]; bridge = nil;

想问下,[bridge invalidate]; 与 bridge = nil; 是在什么时机执行的,如果在页面退出前(dismissViewController 或 popViewControllerAnimated: )执行会导致 RN 视图立即被卸载,出现闪白屏的情况。

openingax avatar Jan 10 '19 08:01 openingax

混合编程的时候,iosapp每次上传appStore的时候,ios工程中存放的main.bundle需要更新成最新的,还是工程中的main.bundle一直不变?@ljunb

qingluanchou avatar Jan 17 '19 14:01 qingluanchou

@xieliying 这个属于 PCReactRootViewManager 单例的生命周期,App 退出才执行 dealloc

ljunb avatar Jan 20 '19 02:01 ljunb

@qingluanchou xcode里面有配置打包脚本的命令,每次 archive 都会生成新的 bundle

ljunb avatar Jan 20 '19 02:01 ljunb

楼主, 找到内存增长的原因了吗,目前也遇到这个问题了

mybadge avatar May 08 '19 09:05 mybadge

楼主 通过 initWithBridge 的方法初始化RCTRootView确实是不行,内存持续增大

jf4444 avatar May 08 '19 11:05 jf4444

@mybadge @jf4444 试试:

  • 1、在 PCReactRootViewManager 中添加一个移除内存中已经 pop 掉的页面方法,比如 - (void)removeRootViewWithName(String viewName);,根据 viewName 移除内存中对应的 RCTRootView 实例
  • 2、然后在加载 RCTRootView 的 VC 的 dealloc 方法中,去手动调用这个移除的方法

ljunb avatar May 13 '19 07:05 ljunb

推荐下公司的跨平台开发框架 CRN

ljunb avatar Jul 04 '19 06:07 ljunb