同 app 下多个 react-native jsBundle 的解决方案

在 react-native (以下称 RN) 还是 0.39 的时候,我们开始着手构建了一个纯 RN app,之后由于长列表的性能问题,进行了一次更新,将版本更新到了 0.46,并一直维持 。直到前段时间,遇到了一个新的需求,要把隔壁部门用 RN 写的一个 app (以下称为 B app) 的一部分业务嵌入我们的 app 中。由于 B app 的业务重度依赖路由,而 B app 的路由和我们 app 所用的路由有一些冲突,简单的组件化然后引用的方式并不适用,同时将两个 app 打成一个 bundle 的方法由于依赖冲突也无法采用。最终选择了将两个 app 分别打成两个 bundle 的方式,并通过 code-push 热更新。

这个过程中遇到了很多问题,但是在网络上并没有找到太多相关的资料,所以在此做一个记录,也让有相似需求的朋友少走一些弯路。

前提

  • 在某一个版本后 RN 会在运行的时候检查 RN 原生部分的版本和 RN js 部分的版本,所以我们最后只能将 RN 升级到 B app 的 0.52 。从代码看如果有一两个版本的差距应该也可以,但是没有做尝试。
  • 最终解决方案中是以我方 app 的原生部分为基础,加入 B app 的 bundle,这意味着,虽然我们可以把 B app 的原生代码复制到我们的工程当中,但是双方需要 link 的依赖库不能存在冲突。

Android

嵌入多个 app

这一步比较简单,RN 本身就支持这么做,只需要新建一个 Activity,在 getMainComponentName() 函数中返回新的 app 注册的名字,(即 js 代码中 AppRegistry.registerComponent() 的第一个参数)就可以了。跳转 app 可参照 android 跳转 Activity 进行。

嵌入多个 bundle

嵌入多个 bundle 还要互不影响,这就需要把 js 的运行环境隔离开,我们需要一个新的 ReactNativeHostReactNativeHost 是在 MainApplication 类中 new 出来的,我们 new 一个新的即可。然后我们会发现,原本 RN 是通过实现了接口 ReactApplication 中的 getReactNativeHost() 方法对外返回 ReactNativeHost 的。

public class MainApplication extends Application implements ReactApplication {
...
    @Override
    public ReactNativeHost getReactNativeHost() {
        return mReactNativeHost;
    };
...
}

检查了一下这个方法的调用,发现 RN 框架中只有一处调用了此方法。在 ReactActivityDelegate 类中,

  protected ReactNativeHost getReactNativeHost() {
        return ((ReactApplication)         getPlainActivity().getApplication()).getReactNativeHost();
  }

于是我首先在 MainApplication 类中 new 了一个新的 ReactNativeHost,并且重写了 getBundleAssetName() 方法,返回了新的 bundle 名 index.my.android.bundle

private final ReactNativeHost mReactNativeMyHost = new ReactNativeHost(this) {
    @Override
    protected String getBundleAssetName() {
    return "index.my.android.bundle";
  }
}

然后写了一个新的接口 MyReactApplication,并且在 MainApplication 类中实现了这个接口,这个接口与实现如下

MyReactApplication.java

public interface MyReactApplication {

  /**
   * Get the default {@link ReactNativeHost} for this app.
   */
  ReactNativeHost getReactNativeMyHost();
}
--------------------
MainApplication.java

public class MainApplication extends Application implements ReactApplication, MyReactApplication {
...
    @Override
    public ReactNativeHost getReactNativeHost() {
        return mReactNativeHost;
    };
    @Override
    public ReactNativeHost getReactNativeMyHost() {
        return mReactNativeMyHost;
    };
...
}

然后重写了 ReactActivityDelegate 类,重点在于 getReactNativeHost() 方法,其他都是复制了 ReactActivityDelegate 类中需要用到的私有方法:

public class MyReactActivityDelegate extends ReactActivityDelegate{

  private final @Nullable Activity mActivity ;
  private final @Nullable FragmentActivity mFragmentActivity;
  private final @Nullable String mMainComponentName ;

  public MyReactActivityDelegate(Activity activity, @Nullable String mainComponentName) {
    super(activity, mainComponentName);
    mActivity = activity;
    mMainComponentName = mainComponentName;
    mFragmentActivity = null;
  }

  public MyReactActivityDelegate(FragmentActivity fragmentActivity, @Nullable String mainComponentName) {
    super(fragmentActivity, mainComponentName);
    mFragmentActivity = fragmentActivity;
    mMainComponentName = mainComponentName;
    mActivity = null;
  }

  @Override
  protected ReactNativeHost getReactNativeHost() {
    return ((MyReactApplication) getPlainActivity().getApplication()).getReactNativeMyHost();
  }

  private Context getContext() {
    if (mActivity != null) {
      return mActivity;
    }
    return Assertions.assertNotNull(mFragmentActivity);
  }

  private Activity getPlainActivity() {
    return ((Activity) getContext());
  }
}

然后 ReactActivityDelegate 是在 Activity 中 new 出来的,回到我们为新 app 写的 Activity,重写其继承自 ReactActivity 的 createReactActivityDelegate() 方法:

public class MyActivity extends ReactActivity {

  @Override
  protected String getMainComponentName() {
    return "newAppName";
  }

  @Override
  protected ReactActivityDelegate createReactActivityDelegate() {
    return new MyReactActivityDelegate(this, getMainComponentName());
  }
}

然后只需要在 B app 中通过 react-native bundle --platform android --dev false --entry-file index.js --bundle-output outputAndroid/index.my.android.bundle --assets-dest outputAndroid/ 打出 bundle,然后将 bundle 和图片资源分别移动到主工程的 android 的 assets 和 res 目录下,打 release 包即可。需要注意的是,在 debug 模式下仍然无法访问第二个 app,由于 debug 模式下 android 的 bundle 读取机制比较复杂,未做深入研究,如有必要,可以通过改变默认 activity 的方式进入第二个 activity。

code-push 热更新

使用 code-push 进行两个 bundle 更新需要对 code-push 做一些更改,同时无法采用 code-push react-release 的一键式打包,需要手动打包。以下改动基于 code-push@5.2.1。

使用 code-push 需要用 getJSBundleFile() 函数取代上一节所写的 getBundleAssetName() 方法,由于 code-push 内通过一个静态常量存储了唯一的一个 code-push 实例,所以为了避免在取 bundle 的时候发生不必要的错误,我在 new ReactNativeHost 的时候用一个变量保存了 code-push 实例,并在 CodePush.getJSBundleFile("index.android.bundle", MainCodePush) 的时候,通过新增一个参数将这个实例传递了进去。当然需要在 code-push 中做一些对应的改动。

MainApplication.java
  private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
  ...
      public CodePush MainCodePush = null;

    @Override
    protected String getJSBundleFile() {
        return CodePush.getJSBundleFile("index.android.bundle", MainCodePush);
    }

    @Override
    protected List<ReactPackage> getPackages() {

        MainCodePush = new CodePush(codePushKey, getApplicationContext(), BuildConfig.DEBUG,codePushIp);

      return Arrays.<ReactPackage>asList(
          new MainReactPackage(),
              MainCodePush
      );
    }
...
mReactNativeMyHost同样如此
...
  };
--------
codePush.java
public static String getBundleUrl(String assetsBundleFileName) {
       return getJSBundleFile(assetsBundleFileName, mCurrentInstance);
}

public static String getJSBundleFile() {
        return CodePush.getJSBundleFile(CodePushConstants.DEFAULT_JS_BUNDLE_NAME, mCurrentInstance);
}
 
 public static String getJSBundleFile(String assetsBundleFileName, CodePush context) {
        mCurrentInstance = context;

         if (mCurrentInstance == null) {
             throw new CodePushNotInitializedException("A CodePush instance has not been created yet. Have you added it to your app's list of ReactPackages?");
         }
 
         return mCurrentInstance.getJSBundleFileInternal(assetsBundleFileName);
}
     

此外,code-push 在取 bundle 的时候会做一些检查,在 CodePushUpdateManager 中 getCurrentPackageBundlePath() 方法会尝试从更新包的元数据中获取 bundle 名,在此处我做了一个处理,当元数据的 bundle 名和传入的 bundle 名不一致时,采用传入的 bundle 名,当然这也会使代码的健壮性有所下降。

CodePushUpdateManager.java
    public String getCurrentPackageBundlePath(String bundleFileName) {
        String packageFolder = getCurrentPackageFolderPath();
        if (packageFolder == null) {
            return null;
        }

        JSONObject currentPackage = getCurrentPackage();
        if (currentPackage == null) {
            return null;
        }

        String relativeBundlePath = currentPackage.optString(CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, null);


        if (relativeBundlePath == null) {
            return CodePushUtils.appendPathComponent(packageFolder, bundleFileName);
        } else {
            String fileName = relativeBundlePath.substring(relativeBundlePath.lastIndexOf("/")+1);
            if(fileName.equals(bundleFileName)){
                return CodePushUtils.appendPathComponent(packageFolder, relativeBundlePath);
            }else{
                String newRelativeBundlePath = relativeBundlePath.substring(0,relativeBundlePath.lastIndexOf("/")+1) + bundleFileName;
                return CodePushUtils.appendPathComponent(packageFolder, newRelativeBundlePath);
            }

        }

    }

此外,之前的 getReactNativeMyHost() 方法存在一些问题,因为 code-push 只会去调用 RN 定义的接口 getReactNativeHost(),如果大幅度自定义 code-push 比较麻烦,而且可能造成更多的潜在问题,所以我修改了一下 getReactNativeHost() 接口。通过 android 的生命周期在 MainApplication 中获取当前的 Activity,并保存起来,在 getReactNativeHost() 中通过,判断当前 Activity 的方式,决定返回的 ReactNativeHost。同时仍然保留之前的写法,因为这种方法是不可靠的,有可能在跳转 Activity 后返回错误的 ReactNativeHost,所以保留之前的方法为 RN 框架提供准确的 ReactNativeHost,这种写法暂时能满足 code-push 的需要,由于本人 java 和 android 的水平所限只能做到这种程度,希望大佬赐教。最后完整版的 MainApplication 如下:

public class MainApplication extends Application implements ReactApplication, MyReactApplication {
...

  public static String currentActivity = "MainActivity";

  private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
      public CodePush MainCodePush = null;

    @Override
    protected String getJSBundleFile() {
        return CodePush.getJSBundleFile("index.android.bundle", MainCodePush);
    }

    public boolean getUseDeveloperSupport() {
      return BuildConfig.DEBUG;
    }

    @Override
    protected List<ReactPackage> getPackages() {

        MainCodePush = new CodePush(codePushKey, getApplicationContext(), BuildConfig.DEBUG,codePushIp);

      return Arrays.<ReactPackage>asList(
          new MainReactPackage(),
              MainCodePush
      );
    }

    @Override
      protected String getJSMainModuleName() {
        return "index";
    }
  };

    private final ReactNativeHost mReactNativeMyHost = new ReactNativeHost(this) {
        public CodePush myCodePush = null;

        @Override
        public boolean getUseDeveloperSupport() {
            return BuildConfig.DEBUG;
        }

        @Override
        protected List<ReactPackage> getPackages() {
            myCodePush = new CodePush(codePushKey, getApplicationContext(), BuildConfig.DEBUG,codePushIp);

            return Arrays.<ReactPackage>asList(
                    new MyMainReactPackage(),
                    myCodePush
            );
        }

        @Override
        protected String getJSBundleFile() {
            return CodePush.getJSBundleFile("index.my.android.bundle", myCodePush);
        }

        @Override
        protected String getJSMainModuleName() {
            return "index";
        }
    };

    @Override
    public ReactNativeHost getReactNativeHost() {

        if(MainApplication.currentActivity.equals("MainActivity")){
            return mReactNativeHost;
        }else if(MainApplication.currentActivity.equals("MyActivity")){
            return mReactNativeMyHost;
        }
        return mReactNativeHost;
    };

    @Override
    public ReactNativeHost getReactNativeMyHost() {
        return mReactNativeMyHost;
    };


  @Override
  public void onCreate() {
    super.onCreate();

    this.registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
      public String getActivityName(Activity activity){
          String allName = activity.getClass().getName();
          return allName.substring(allName.lastIndexOf(".")+1);
      }

      @Override
      public void onActivityStopped(Activity activity) {}

      @Override
      public void onActivityStarted(Activity activity) {
          MainApplication.currentActivity = getActivityName(activity);
          Log.i(getActivityName(activity), "onActivityStarted");
      }

      @Override
      public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}

      @Override
      public void onActivityResumed(Activity activity) {}

      @Override
      public void onActivityPaused(Activity activity) {}

      @Override
      public void onActivityDestroyed(Activity activity) {}

      @Override
      public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
          MainApplication.currentActivity = getActivityName(activity);
          Log.i(getActivityName(activity), "onActivityCreated" );
      }
    });

  }

...  
}

到此为止,android 的 code-push 改造就完成了。
更新的时候,需要首先分别通过上文提到的 react-native bundle ... 命令将两边的工程分别打包,然后合并到同一个文件夹中,最后通过 code-push release appName ./outputAndroid x.x.x 命令上传更新,命令的具体细节请参考 code-push github。

IOS

嵌入多个 app

android 完成之后,ios 就容易的多。嵌入多个 app 和 android 类似,在 ios 上使用的是 UIViewController,新建一个 UIViewController,其他都和主 app 一致,只是在 init rootView 的时候修改一下 moduleName 为新的 app 注册的名字即可。通过 UINavigationController 来进行页面跳转,具体开发参见 IOS 原生开发。

嵌入多个 bundle

ios 在引入 bundle 的时候十分灵活,只需要在 init 新的 rootView 的时候修改 initWithBundleURL 的值即可。可如下:

@implementation MyViewController

- (void)viewDidLoad{
  [super viewDidLoad];
  
  NSURL *jsCodeLocation;
  
#ifdef DEBUG
    jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.bundle?platform=ios&dev=true"];
#else
    jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
  RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
                                                      moduleName:@"appName"
                                               initialProperties:nil
                                                   launchOptions:nil];
  rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1];
  self.view = rootView;
}

@end

不管 debug 时的远程 packager 服务的地址还是 release 时包名都可以自行更改。
最后在 B app 中通过 react-native bundle --platform ios --dev false --entry-file index.js --bundle-output outputIOS/my.jsbundle --assets-dest outputIOS/ 打出 bundle,将 jsbundle 和图片资源在 Xcode 中引入工程即可。

code-push 热更新

ios 下的热更新依然需要对 code-push 做一些修改,在取 bundle 的时候,code-push 会去比较一个本地 bundle 修改时间与元数据中是否一致,当取第二个 bundle 的时候,此值会不一致,具体原因因时间原因没有深究,暂时处理为,当 bundle 名与元数据中不同时,不检查修改时间。修改的代码如下:

+ (NSURL *)bundleURLForResource:(NSString *)resourceName
                  withExtension:(NSString *)resourceExtension
                   subdirectory:(NSString *)resourceSubdirectory
                         bundle:(NSBundle *)resourceBundle
{
    bundleResourceName = resourceName;
    bundleResourceExtension = resourceExtension;
    bundleResourceSubdirectory = resourceSubdirectory;
    bundleResourceBundle = resourceBundle;

    [self ensureBinaryBundleExists];

    NSString *logMessageFormat = @"Loading JS bundle from %@";

    NSError *error;
    NSString *packageFile = [CodePushPackage getCurrentPackageBundlePath:&error];

    NSURL *binaryBundleURL = [self binaryBundleURL];
    
    if (error || !packageFile) {
        CPLog(logMessageFormat, binaryBundleURL);
        isRunningBinaryVersion = YES;
        return binaryBundleURL;
    }
    
    NSString *binaryAppVersion = [[CodePushConfig current] appVersion];
    NSDictionary *currentPackageMetadata = [CodePushPackage getCurrentPackage:&error];
    if (error || !currentPackageMetadata) {
        CPLog(logMessageFormat, binaryBundleURL);
        isRunningBinaryVersion = YES;
        return binaryBundleURL;
    }
    
  

    NSString *packageDate = [currentPackageMetadata objectForKey:BinaryBundleDateKey];
    NSString *packageAppVersion = [currentPackageMetadata objectForKey:AppVersionKey];
 
    Boolean checkFlag = true;//双bundle情况下bundle名和meta中不一致不检查修改时间
    //用来取自定义的bundle
    NSArray *urlSeparated = [[NSArray alloc]init];
    NSString *fileName = [[NSString alloc]init];
    NSString *fileWholeName = [[NSString alloc]init];
    urlSeparated = [packageFile componentsSeparatedByString:@"/"];
    fileWholeName = [urlSeparated lastObject];
    fileName = [[fileWholeName componentsSeparatedByString:@"."] firstObject];
    
    if([fileName isEqualToString:resourceName]){
        checkFlag = true;
    }else{
        checkFlag = false;
    }
    
    if ((!checkFlag ||[[CodePushUpdateUtils modifiedDateStringOfFileAtURL:binaryBundleURL] isEqualToString:packageDate]) && ([CodePush isUsingTestConfiguration] ||[binaryAppVersion isEqualToString:packageAppVersion])) {
        // Return package file because it is newer than the app store binary's JS bundle
        
        if([fileName isEqualToString:resourceName]){
            NSURL *packageUrl = [[NSURL alloc] initFileURLWithPath:packageFile];
            CPLog(logMessageFormat, packageUrl);
            isRunningBinaryVersion = NO;
            return packageUrl;
        }else{
            NSString *newFileName = [[NSString alloc]init];
            NSString *baseUrl = [packageFile substringToIndex:([packageFile length] - [fileWholeName length] )];
            newFileName = [newFileName stringByAppendingFormat:@"%@%@%@", resourceName, @".", resourceExtension];
            NSString *newPackageFile = [baseUrl stringByAppendingString:newFileName];
        
            NSURL *packageUrl = [[NSURL alloc] initFileURLWithPath:newPackageFile];
            CPLog(logMessageFormat, packageUrl);
            isRunningBinaryVersion = NO;
            return packageUrl;
        }
        
        
    } else {
        BOOL isRelease = NO;
#ifndef DEBUG
        isRelease = YES;
#endif

        if (isRelease || ![binaryAppVersion isEqualToString:packageAppVersion]) {
            [CodePush clearUpdates];
        }

        CPLog(logMessageFormat, binaryBundleURL);
        isRunningBinaryVersion = YES;
        return binaryBundleURL;
    }
}

到此为止,ios 的 code-push 改造就完成了。
更新的时候,需要首先分别通过上文提到的 react-native bundle ... 命令将两边的工程分别打包,然后合并到同一个文件夹中,最后通过 code-push release appName ./outputIOS x.x.x 命令上传更新,命令的具体细节请参考 code-push github。

待解决的问题

暂时已发现的崩溃只有一个,当进入过 B app 之后,返回主 app,这个时候如果进行 code-push 更新检查,并且发现更新之后进行更新,ios 会崩溃,更新失败;android 会报更新错误,但实际上更新成功,需要下次启动 app 才生效。
android 的原因没深入研究,ios 的原因主要是因为 code-push 中有些静态变量是在加载 bundle 的时候保存的,当进入 B app 的时候修改了这些变量的值,返回主 app 的时候并没有重新加载 bundle,所以仍然保留了错误的值,更新的时候会涉及到相关的值,然后就会崩溃报错。
解决方法暂时为记录 flag,一旦进入过 B app 就不再进行更新。

修改过的 code-push@5.2.1 见 https://github.com/haven2worl...

搞定 (〃'▽'〃)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值