在 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的运行环境隔离开,我们需要一个新的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 热更新
使用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…
搞定(〃’▽’〃)。