当然啦,如果你是修改过 RN 源码工程然后将源码引入的模式,依赖摘除也类似,这都是 Android 开发的必备技术了,不再多提了。不过如果你想裁剪优化 RN 则这里的依赖可以不摘除,直接想办法替换为自己项目共用已有优质 lib 即可,只不过这个过程依据团队规模和投入慎重考虑,因为 RN 版本太快,合并代码很苦逼。
3-3 动态 so 库加载策略问题
现有项目中为了安装包体积和 CPU 兼容性问题,所有 so 动态库都是放在 armeabi 目录下的,没有其他目录,而 RN 却只支持编译如下 so:
//RN 的 Application.mk
APP_ABI := armeabi-v7a x86
APP_PLATFORM := android-9
这他妈就尴尬了,你提供 SDK 竟然不考虑提供完整的 ABI 编译支持。那我只能自己想办法了,首先想到的就是你不提供我就自己编译呗(前提是将 RN 以源码形式集成进项目),于是在 RN 的 Application.mk 的 APP_ABI 多添加了一个armeabi(别问我为何加在这里,后面等我写 RN 编译链分析你就明白了,别问我这是啥语法,这是 Android 开发应该必备的技能,和 RN 无关),在 build.gradle 中也对应只添加过滤 armeabi,然后编译了一把报错了,坑爹啊,依据错误信息一查看发现是有一处 Android.mk 执行时找不到一个文件,具体如下:
//编译报错的Android.mk文件路径
//react-native\ReactAndroid\src\main\jni\third-party\jsc
//Android.mk内容
LOCAL_PATH:= $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE:= jsc
LOCAL_SRC_FILES := jni/$(TARGET_ARCH_ABI)/libjsc.so //编译真实报错地方
LOCAL_EXPORT_C_INCLUDES := $(LOCAL_PATH)
include $(PREBUILT_SHARED_LIBRARY)
TARGET_ARCH_ABI 这玩意已经很明显了,做过 Android 都知道,指定是编译 armeabi ABI 时找不到 libjsc.so 文件,那就看看这个 so 是哪儿来的吧,通过 RN 源码自己的 build.gradle 可以看见如下:
// Create Android.mk library module based on so files from mvn + include headers fetched from webkit.org
task prepareJSC(dependsOn: downloadJSCHeaders) << {
copy {
from zipTree(configurations.compile.fileCollection { dep -> dep.name == ‘android-jsc’ }.singleFile)
from {downloadJSCHeaders.dest}
from ‘src/main/jni/third-party/jsc/Android.mk’
include ‘jni/**/.so’, '.h’, ‘Android.mk’
filesMatching(‘*.h’, { fname -> fname.path = “JavaScriptCore/${fname.path}”})
into “$thirdPartyNdkDir/jsc”;
}
}
dependencies {
…
compile ‘org.webkit:android-jsc:r174650’
…
}
这脚本已经告诉你 libjsc.so 是来自 org.webkit:android-jsc:r174650 这个依赖的,坑爹啊,上 maven 去下了一个解压 aar 包打开 libs 目录才惊讶的发现妹的 android-jsc 这货没提供 armeabi ABI 的 libjsc.so,怪不得会报错,也是由此猜想到 RN 为毛作为一个 SDK 不遵守 SDK so 提供的基础准则,估计是它用了 jsc,jsc也没有 armeabi ABI 的原因(这个初步是判断是这样的,至于 android-jsc 这个 lib 能不能自己下源码编译 armeabi 的 so还没研究。。。。。时间有限,啥时候闲了一定要下一份源码编译一下),所以这条路就这么浪费了我接近一个小时的时间,然后还是暂时失败了。
这时候不得不重新换个思路了,想了想还是在这一版先放弃 x86 吧(第一版接入就完美几乎不可能),但是放弃 x86 以后 armeabi-v7a 与 现有 armeabi 的目录在加载 so 时还是存在问题啊,就是那个坑爹的策略问题,所以直接暴力测试了一下,修改编译脚本,armeabi-v7a 编译好以后剪切到 armeabi 下再打包(为了简单测试,你还可以直接解压现有 apk 中 armeabi-v7a 目录的文件复制到 armeabi 下打包测试,最后再修改脚本),然后通过 Build 辅助类进行架构判断。 此坑暂时 mark,T_T,过几天有时间了要仔细搞下这个 so 兼容性问题。
3-4 RN 模块拖垮现有项目问题
接入 RN 前后对比了一下内存开销,确实大了不少,加之 RN 官方自己 ListView 的性能问题没有很好的解决,所以从各方面来说都有点不太放心,于是采取了多进程的方式,让 RN 模块运行在独立进程中,这样的话就能避免很多尴尬。
不过多进程方式还是建议不要通过使用 Application 注册配合 ReactActivity 方式,推荐高内聚的模块化方式,也就是自己 Activity implements DefaultHardwareBackBtnHandler,自己 setContentView 一个 ReactRootView 的方式。至于怎么多进程我想不用说了吧,做安卓的自己看着办。
3-5 RN 集成后热更新核心思路
RN 自身是不具备热更新全套机制的,尤其是比较老的低版本 RN 想要热更新是很费劲的,要做很多事情才能支持 JS 和 图片resource 的热更新;但是比较新版本的 RN 不存在这个问题了,因为有 PR 已经重新搞了 JS 和 resource 加载这块逻辑,所以热更新变得容易了很多,不过新版本中却又搞出了一个新的致命坑,下面第6点详细说明,这里还是探讨热更新。其实简单的热更新说白了就是一个典型的CS流程,客户端发起请求查询更新,依据返回 JSON 决定是否去 CDN 下载新包,然后客户端在指定新包路径 load 启动即可,大体如下流程:
其实你所看到的市面上的各种 RN 热更新框架无非都是这个主线,只是更加健壮和高效而已,譬如 CodePush 实质就是这么回事,但是我们不想受限服务器类型、也不想使用他人服务器,所以有必要自己搞一套热更新。出于商业项目问题,这里接下来只提供如何快速搞一个简单的热更新框架,其他细节需要自己完善,具体做法如下:
1、在现有代码中进行如下代码修改来支持热更新。
//在 RN ReactInstanceManager构造中通过setJSBundleFile方法设置外部热更新文件保存路径
mReactInstanceManager = ReactInstanceManager.builder()
…
.setJSBundleFile(RNHotUpdateAndroid.getJSBundleFile(this))
…
.build();
紧接着创建一个RNHotUpdateAndroid.java的类,实现如下:
public class RNHotUpdateAndroid {
//上面setJSBundleFile方法设置的路径来自此处
public static String getJSBundleFile(Context context) {
//首先判断外部指定路径下是否存在新下载的bundle文件
String bundleFile = FileUpdateManager.getExtraJSBundleFile(context);
if (FileUtils.exists(bundleFile)) {
//存在更新文件则直接将外部路径设置给ReactInstanceManager,也即RN使用热更新文件加载启动
return bundleFile;
}
//不存在更新文件则使用原来打包的assets路径
bundleFile = FileUpdateManager.getInnerJSBundleFile();
return bundleFile;
}
}
再看下FileUpdateManager.java的实现,如下:
public class FileUpdateManager {
public static final String BUNDLE_FILE_NAME = “index.android.bundle”;
public static final String BUNDLE_EXTRA_DIR = “RNHotUpdate”;
public static final String ASSETS_BUNDLE_PREFIX = “assets://”;
public static String getExtraHotUpdatePath(Context context) {
return context.getApplicationContext().getFilesDir().getAbsolutePath() + File.separator + BUNDLE_EXTRA_DIR;
}
public static String getExtraJSBundleFile(Context context) {
return getExtraHotUpdatePath(context)+ File.separator + BUNDLE_FILE_NAME;
}
public static String getInnerJSBundleFile() {
return ASSETS_BUNDLE_PREFIX + BUNDLE_FILE_NAME;
}
}
到此具备 JS 和 res 图片资源的热更新超级基础版可以算 OK 了,就是判断有没有更新文件存在,有就在启动时使用更新文件的路径,没有就使用原来 assets 的路径,简单吧,至于为毛这么设置就能热更新了后面文章我会详细介绍,现在先记得就行,饥渴的话可以自己去翻下源码就明白了。
2、本地随便搭建一个服务器,各种集成环境也可以,方便接下来的测试。
3、准备更新包,记得不要和打入assets的一样,免得看不出明显效果,随便改个字体大小、颜色啥的,然后进行官方打包命令操作:
//$OUTPUT_PATH为你指定的一个输出路径
react-native bundle --platform android --dev false --entry-file index.android.js --bundle-output $OUTPUT_PATH/index.android.bundle --assets-dest $OUTPUT_PATH
此时会在 $OUTPUT_PATH 路径下看到如下输出:
将这些文件选中压缩成 update.zip 的压缩包,如下:
如上两步除过 index.android.bundle.meta 文件可以不要以外,剩下无论是文件夹还是文件名都不要修改,千万不要修改,压缩到根目录,至此一个更新包就做好了(差分包那些自己实现,这里是最简单的热更新实现)。
4、上面热更新超级简单版机制和更新包zip文件都已经有了,接下来就得找个合适时机去向服务端请求查询是否有更新、获取更新链接进行下载解压了;这里就是你需要依据自己项目情况实现的细节了,譬如渠道控制、版本控制等等一堆匹配校验,我们都略掉吧,重点看下怎么更新成功,那就暴力点,假设有更新(直接从指定路径拉取zip包吧),所以局部代码如下:
public class RequestManager {
//第二步让你搭建的服务器,把第三步做好的更新包扔到如下路径即可(是不是很暴力很直接!!!)。
public static final String HOT_UPDATE_URL = “http://10.20.185.22/rn_hot_update/test_cdn/update.zip”;
private Context mContext;
public RequestManager(Context context) {
this.mContext = context.getApplicationContext();
}
public void start() {
//开启线程后台下载更新包解压等操作(仅仅为Demo,不具备实用价值!!!!!)
new Thread(new Runnable() {
@Override
public void run() {
OutputStream output = null;
File zipDownloadFile = null;
try {
Log.i(“YYYY”, “----------bundle download start”);
URL url = new URL(HOT_UPDATE_URL);
HttpURLConnection urlConn = (HttpURLConnection) url.openConnection();
InputStream input = urlConn.getInputStream();
File dir = new File(FileUpdateManager.getExtraHotUpdatePath(mContext));
if(!dir.exists()) {
dir.mkdirs();
}
//创建一个临时zip文件
zipDownloadFile = new File(FileUpdateManager.getExtraHotUpdatePath(mContext) + File.separator + “template”);
if(!zipDownloadFile.exists()){
zipDownloadFile.createNewFile();
}
output = new FileOutputStream(zipDownloadFile);
byte buffer [] = new byte[1024];
int inputSize = -1;
long totalSize = 0;
byte[] header = new byte[4];
while((inputSize = input.read(buffer)) != -1) {
if (totalSize < 4) {
for (int index=0; index<inputSize; index++) {
int headerOffset = (int)(totalSize) + index;
if (headerOffset >= 4) {
break;
}
header[headerOffset] = buffer[index];
}
}
totalSize += inputSize;
output.write(buffer, 0, inputSize);
}
output.flush();
//判断下的是不是一个zip,其实应该还有md5等各种校验的,这只是个Demo!!!!
boolean isRealZipFile = ByteBuffer.wrap(header).getInt() == 0x504B0304;
if (isRealZipFile) {
//解压文件到热更新目录供使用(仅仅是Demo,实际要考虑版本回退问题)
FileUtils.unzipFile(zipDownloadFile, FileUpdateManager.getExtraHotUpdatePath(mContext));
Log.i(“YYYY”, “----------bundle download and unzip OK”);
} else {
Log.i(“YYYY”, “----------bundle download, but not a zip file!”);
}
} catch (Exception e) {
Log.i(“YYYY”, “----------bundle download error”);
e.printStackTrace();
} finally{
//删掉下载解压后的zip文件
try{
if (zipDownloadFile != null) zipDownloadFile.delete();
if (output != null) output.close();
}
catch(Exception e){
e.printStackTrace();
}
}
}
}).start();
}
}
好了,在你合适的地方(譬如 RN Activity 启动后 onCreate 最后)进行如下调用:
new RequestManager(this).start();
至此一个从服务器下载更新包解压和再次进入界面展示热更新资源的超级简单热更新 JS 与 image 资源的方案就落地了。TT,其实远远不止这些,这只是给大家提供个思路,里面很多细节需要考虑的,版本回退、校验、查询、兼容性、容错、差分包等等,商业原因就不细细说明了,相信有了这个主要的核心思路,那些拓展完善大家都能做好的,而且是量身定做的。
怎么样,看似很神奇的 RN 热更新 JS 和 资源本质核心就这么回事。但是一直不明白为啥很多群里和论坛到处求助如何更新 RN 的 image 资源,我想说的是,如何更新自己去看代码啊,从代码找突破口啊!
3-6 RN 集成后 release 版本中可能存在的一个小概率崩溃
有了上面那些经历, React Native 基本就算 OK 了,但是无意间和同事讨论发现一个崩溃,追踪了一把才发现是个天坑(其实这个坑在 RN 低版本是不存在,后来的新版本不清楚为毛要这么干,不知道是不是失误);上面 release 版本的更新拽回来的 bundle 文件如果是一个人为搞错的文件打包成 zip 发布的话就会复现(这种 bundle 文件搞错,譬如有个 txt 文件,只是名字取成了 bundle 等,md5 校验也无力回天),虽然这种傻逼的做法不多,但是容错机制不能没有啊,不能让它在可预见的情况下崩溃啊,万一运营发版本傻逼了咋搞。
下面来看下比较新的 RN 版本 XReactInstanceManagerImpl.java 中 ReactContextInitAsyncTask 内部类的这个坑,具体先看下 RN 里 ReactContextInitAsyncTask 内部类的 doInBackground 方法,如下:
@Override
protected Result doInBackground(ReactContextInitParams… params) {
…
try {
JavaScriptExecutor jsExecutor = params[0].getJsExecutorFactory().create();
return Result.of(createReactContext(jsExecutor, params[0].getJsBundleLoader()));
} catch (Exception e) {
// Pass exception to onPostExecute() so it can be handled on the main thread
return Result.of(e);
}
}
看看 createReactContext 方法吧,如下:
/**
- @return instance of {@link ReactContext} configured a {@link CatalystInstance} set
*/
private ReactApplicationContext createReactContext(
JavaScriptExecutor jsExecutor,
JSBundleLoader jsBundleLoader) {
…
final ReactApplicationContext reactContext = new ReactApplicationContext(mApplicationContext);
//release 版本时 mUseDeveloperSupport 为 false,故忽略这一步逻辑
if (mUseDeveloperSupport) {
reactContext.setNativeModuleCallExceptionHandler(mDevSupportManager);
}
…
//依据我们外面是否设置了mNativeModuleCallExceptionHandler决定exceptionHandler的值,外部设置是通过ReactInstanceManager的builder时set的,默认没有设置
//坑就从这里慢慢开始了,如果我们设置了mNativeModuleCallExceptionHandler来处理全局 native 的异常,则exceptionHandler就被赋值为我们设置的啦,那我们继续往下看盯紧会发现它设置给了 CatalystInstanceImpl。
NativeModuleCallExceptionHandler exceptionHandler = mNativeModuleCallExceptionHandler != null
-
? mNativeModuleCallExceptionHandler
- mDevSupportManager;
CatalystInstanceImpl.Builder catalystInstanceBuilder = new CatalystInstanceImpl.Builder()
.setReactQueueConfigurationSpec(ReactQueueConfigurationSpec.createDefault())
.setJSExecutor(jsExecutor)
.setRegistry(nativeModuleRegistry)
.setJSModuleRegistry(jsModulesBuilder.build())
.setJSBundleLoader(jsBundleLoader)
.setNativeModuleCallExceptionHandler(exceptionHandler);
…
try {
catalystInstance.getReactQueueConfiguration().getJSQueueThread().callOnQueue(
new Callable() {
@Override
public Void call() throws Exception {
…
try {
//文件有问题,这里就会抛出异常到MessageQueueThreadImpl的callOnQueue方法中被捕获,然后通过SimpleSettableFuture的setException方法设置了该异常
catalystInstance.runJSBundle();
} finally {
Systrace.endSection(TRACE_TAG_REACT_JAVA_BRIDGE);
ReactMarker.logMarker(RUN_JS_BUNDLE_END);
}
return null;
}
}).get();
//上面get SimpleSettableFuture时就会得到上面设置的setException,然后被下面的ExecutionException捕获!!!!!
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
//异常抛出以后被包装以后传递给了AsyncTask的onPostExecute方法
if (e.getCause() instanceof RuntimeException) {
throw (RuntimeException) e.getCause();
} else {
throw new RuntimeException(e);
}
}
return reactContext;
}
接着看下抛到AsyncTask的onPostExecute方法,如下:
@Override
protected void onPostExecute(Result result) {
try {
setupReactContext(result.get());
} catch (Exception e) {
//来自上面包装的result.get()异常在此被捕获!!!!!!坑爹啊!!!!!此时mDevSupportManager为DisabledDevSupportManager(因为useDeveloperSupport=false),DisabledDevSupportManager直接抛出异常了,没鸟之前设置的mNativeModuleCallExceptionHandler!!!!!!!
mDevSupportManager.handleException(e);
} finally {
mReactContextInitAsyncTask = null;
}
…
}
具体抛出地方如下:
public class DefaultNativeModuleCallExceptionHandler implements NativeModuleCallExceptionHandler {
@Override
public void handleException(Exception e) {
if (e instanceof RuntimeException) {
// Because we are rethrowing the original exception, the original stacktrace will be
// preserved.
throw (RuntimeException) e;
} else {
throw new RuntimeException(e);
}
}
}
所以这是个坑爹故事!AsyncTask 的 onPostExecute 方法运行在主线程中,我在 ReactRootView 级别捕获个粑粑啊,所以一旦出现类似情况就只能让他崩溃了,给 ReactInstanceManager 设置setNativeModuleCallExceptionHandler 都没用啊,因为 release 中压根就不走这货!!!!
所以解决办法就是把上面 onPostExecute 的catch 中的 mDevSupportManager.handleException(e); 替换为如下:
NativeModuleCallExceptionHandler exceptionHandler = mNativeModuleCallExceptionHandler != null
-
? mNativeModuleCallExceptionHandler
- mDevSupportManager;
exceptionHandler.handleException(e);
这样通过外部设置捕获就能抓住这个坑爹的异常了!!!
3-7 RN Android OEM ROM 上运行问题
1、MIUI ROM 上 Text 文本截断问题。
开发过程中遇到一个诡异的现象,手头几台设备都没有问题,在跑 MIUI ROM 的设备上 Text 被不同程度截断了,坑爹啊,尤其是 ListView、ScrollView、和固定宽度情况下各种被截断不居中,包括一些开源第三方库也存在这个问题,没办法,只能修改规避,毕竟是小米的锅,规避的办法依据不同场景有如下几种(自己可以继续尝试别的办法):
-
给 Text 设置 numberOfLines 属性;
-
给 Text 设置 flex 比例;
-
尽量给 Text 使用 padding 代替 margin;
总之在小米设备上最好还是打开显示布局边界好好看看关于 Text 的区域吧, OEM 都很坑爹的。
2、大多数 OEM ROM 上定制类转换异常问题。
就拿我前东家设备上的一个异常来说吧(别的设备也有,OPPO等),比较低版本的 RN 在 Flyme OS 上可能会遇见如下一个崩溃:
com.meizu.widget.OverScrollerExt cannot be cast to android.widget.OverScroller
典型的中国特色啊,好在这个问题在今年9月下旬被官方合并解决了,具体可以参考 issues。所以遇见这个问题就升级一下 RN 的版本吧,这样就解决了!
3-8 RN 集成后各种持续性优化的问题
关于 React Native 集成进项目以后 Android 一侧的性能优化就不再提了,我以前专门写过一篇,感兴趣的移步 《Android应用开发性能优化完全分析》即可。额外需要关注的性能优化就不多说了,参考下面一些官方文档即可:
Profiling Android UI Performance
总结:
面试是一个不断学习、不断自我提升的过程,有机会还是出去面面,至少能想到查漏补缺效果,而且有些知识点,可能你自以为知道,但让你说,并不一定能说得很好。
有些东西有压力才有动力,而学到的知识点,都是钱(因为技术人员大部分情况是根据你的能力来定级、来发薪水的),技多不压身。
附上我的面试各大专题整理: 面试指南,满满的都是干货,希望对大家有帮助!
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!
com.meizu.widget.OverScrollerExt cannot be cast to android.widget.OverScroller
典型的中国特色啊,好在这个问题在今年9月下旬被官方合并解决了,具体可以参考 issues。所以遇见这个问题就升级一下 RN 的版本吧,这样就解决了!
3-8 RN 集成后各种持续性优化的问题
关于 React Native 集成进项目以后 Android 一侧的性能优化就不再提了,我以前专门写过一篇,感兴趣的移步 《Android应用开发性能优化完全分析》即可。额外需要关注的性能优化就不多说了,参考下面一些官方文档即可:
Profiling Android UI Performance
总结:
面试是一个不断学习、不断自我提升的过程,有机会还是出去面面,至少能想到查漏补缺效果,而且有些知识点,可能你自以为知道,但让你说,并不一定能说得很好。
有些东西有压力才有动力,而学到的知识点,都是钱(因为技术人员大部分情况是根据你的能力来定级、来发薪水的),技多不压身。
附上我的面试各大专题整理: 面试指南,满满的都是干货,希望对大家有帮助!
[外链图片转存中…(img-FFXOVcJU-1715745275978)]
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门,即可获取!