在 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 热更新。
这个过程中遇到了很多问题,但是在网络上并没有找到太多相关的资料,所以在此做一个记录,也让有相似需求的朋友少走一些弯路。
link
的依赖库不能存在冲突。这一步比较简单,RN 本身就支持这么做,只需要新建一个 Activity
,在 getMainComponentName()
函数中返回新的 app 注册的名字,(即 js 代码中 AppRegistry.registerComponent()
的第一个参数)就可以了。跳转 app 可参照 android 跳转 Activity
进行。
嵌入多个 bundle 还要互不影响,这就需要把 js 的运行环境隔离开,我们需要一个新的 ReactNativeHost
,ReactNativeHost
是在 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 进行两个 bundle 更新需要对 code-push 做一些更改,同时无法采用 code-push react-release
的一键式打包,需要手动打包。以下改动基于 [email protected]。
使用 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 getPackages() {
MainCodePush = new CodePush(codePushKey, getApplicationContext(), BuildConfig.DEBUG,codePushIp);
return Arrays.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 getPackages() {
MainCodePush = new CodePush(codePushKey, getApplicationContext(), BuildConfig.DEBUG,codePushIp);
return Arrays.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 getPackages() {
myCodePush = new CodePush(codePushKey, getApplicationContext(), BuildConfig.DEBUG,codePushIp);
return Arrays.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。
android 完成之后,ios 就容易的多。嵌入多个 app 和 android 类似,在 ios 上使用的是 UIViewController
,新建一个 UIViewController
,其他都和主 app 一致,只是在 init rootView 的时候修改一下 moduleName 为新的 app 注册的名字即可。通过 UINavigationController
来进行页面跳转,具体开发参见 IOS 原生开发。
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 中引入工程即可。
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 就不再进行更新。
修改过的 [email protected] 见 https://github.com/haven2worl...
搞定 (〃'▽'〃)。