React Native实践有感

React Native(简称RN)是Facebook于2015年开源的移动端跨平台开发框架。RN从开源以来已经有6个年头了,有着十分丰富的社区资源和生态,时至今日依然有很多移动端项目都使用RN来开发。本文主要通过以往的项目实践来谈谈在选择RN开发app可能需要注意的一些点,也算是自己的一个踩坑经验总结。

1. 技术选型 - 是否该用RN?

跨平台开发框架都是有局限性的,这一点RN也不例外,RN本身还是要使用原生API来实现UI的绘制,JS bridge的创建和与原生平台的通信都需要消耗资源,基于这样的前提,RN开发的应用相对于原生平台来说往往会占用更多的内存和CPU,因此而出现的卡顿、掉帧的概率也会更高,进而对用户体验造成较大的影响。

那么问题来了,RN真的很差、不适合工程实践吗?

这个问题就涉及到技术选型了,是否应该用RN?什么样的情况下适合使用RN作为首选开发技术?

我个人认为需要从以下几个方面考虑:

  • 产品类型和市场定位

    面向C端的产品一般最好还是使用原生开发技术,性能稳定性相对会更加可靠一些,尤其是这款产品的市场期望比较高,对用户和市场规模增长有比较大的期待时。性能更好、更加稳定可靠的技术应当是首选,这样会带来更好的用户体验。当然如果用户数量比较少,app应用场景比较单一的情况不太需要这样的考虑,比如功能并不复杂的工具类应用。

  • 业务需要

    很多app都使用原生与H5的Hybrid模式开发,但是H5的体验跟原生相比差距较大,RN的体验比H5就要好很多,而且RN还具有热更新的能力,这对于需要频繁更新内容的业务来说是一个不错的选择。比如像图书、漫画这种内容上新比较频繁或者UI排版更迭频繁的更适合用RN,像以音视频播放为主这种追求性能稳定的就不太合适了。

  • 团队规模和开发周期

    团队规模比较小,开发周期短的情况下尽量选择熟悉的技术栈,能够节省时间。

  • 技术储备

    这一点需要考虑到团队是否有相应的技术,比如如果团队没有Android或iOS原生开发的技术,都只有web前端开发,又需要做app,那么可以考虑RN,尤其是有React技术储备的情况。

  • 后期维护成本

    这一点一般来说考虑的优先级是最低的,开发团队可能很少会考虑维护的问题,因为交付之后项目谁维护、要不要维护都是个问题。作为跨平台开发框架来说,RN通常可能需要维护Android和iOS两端,尤其是app应用场景和功能比较复杂的情况下,与原生交互的部分就少不了,对于纯web前端开发来说是个不小的挑战,需要一个人负责两个平台的维护工作。总之,RN一个开发者维护的情况下,那么对开发者的要求是需要兼顾Android和iOS两个平台,这也是为什么说学了RN迟早安卓和iOS都要学。如果是原生开发,可能需要两个人维护,一人一个平台,就会提高维护成本。

综上,RN到底适不适合在项目中实践,最好按实际情况考虑。我个人觉得RN还是不错的,性能表现由于先天性的架构设计问题与原生有差距是正常的,但是也没有差到无法用的地步,这一点不能人云亦云。

2. 依赖库的升级维护

RN项目中经常会用到很多第三方库,比如路由框架react-navigation、数据存储AsyncStorage、状态管理react-redux等等。在项目维护时我们可能会面临第三方库的升级带来的一系列问题、某些library没人维护了,但是我们出于某些原因还需要继续使用等等,针对这些情况谈谈我的理解。

  • 第三方库适时升级

适时升级的意思就是第三方库有新版本的时候,在保持app稳定性、不引起regression问题的情况下尽可能的升级第三方库。在app的迭代中把第三方库的升级维护考虑进去是很有必要的,以我所在的项目为例:

我们项目中使用的react-navigation版本非常老旧了,还停留在v2版本,而最新的react-navigation实际已经到了v5版本,并且v5版本中对核心功能组件进行了拆分,意味着v5以后需要安装react-navigation的多个依赖包。react-navigation一直都是一个API变动非常大的router库,每一个大版本的迭代都可能导致原来的路由用法发生改变。对比老旧的v2版本来说,升级到新版本是更好的选择,功能和性能更强、路由灵活性更高,但是在我接手项目之前react-navigation一直都没升级过,直接升级到最新版本变动太大了,风险太高,容易引起功能上的bug。如果在之前的迭代中能把这块升级的工作考虑进去,随着每个迭代一起去做,改动会相对较小,就能平稳过渡到新版本。

  • 没人维护怎么办

没人维护的库怎么处理,分几种情况:

  1. 对功能没影响的无所谓,比如react-native-html,我只用它加载一小段html,它即使不维护了也没影响,因为功能已经实现了,后续也无变动;

  2. 跟app功能深度捆绑,依赖很强,替换需要很大effort,这种能继续维护就尽量自己维护;

  3. 可替代性较强,后续功能迭代还需要依赖它的,这种尽早替换。

  • RN版本升级

RN在0.59及之前的版本中只能手动安装第三方库,0.60及以上版本可以auto link了,项目的配置简单了许多,所以最好升级到0.60版本以上。

0.63版本解决了iOS 13中本地图片无法显示的问题,源于iOSRCTUIImageViewAnimated中一句代码[super displayLayer:layer];的缺失导致图片内容无法正常显示:

- (void)displayLayer:(CALayer *)layer
{
  if (_currentFrame) {
    layer.contentsScale = self.animatedImageScale;
    layer.contents = (__bridge id)_currentFrame.CGImage ;
  } else { 
    [super displayLayer:layer];
  }
}

从我们的项目来看,升级到RN 0.63版本会导致react-navigation老版本中的依赖库react-native-safe-area-view报错。所以连带的也需要升级react-navigation,但我上面提到升级react-navigation风险比较大,需要比较大的effort去做,所以这里我还是保持RN版本小于0.63,通过react-native-fix-image来修改iOS源码或者用patch-package打patch实现,做法虽然丑陋了点,但可以最小的effort先解决问题,后续再用更稳妥的方式逐步升级RN和react-navigation版本。

总之,RN和第三方依赖库版本太老长时间不升级会带来很多问题,如老API过时、新API变动太大,iOS、Android系统更新带来的兼容性问题都需要解决,升级应该作为一个task经常关注并适时执行。

  • 慎用RealmJS

Realm是一个开源的移动端数据库,性能表现非常不错,API也简单易用。但RealmJS真是太难用了,首先安装就很费劲,经常安装失败,即使安装成功,按照文档配置好了iOS也经常报错Missing Realm Constructor,并且这个错误问题还偶尔在production环境出现,导致app直接白屏无法使用。

而且在iOS 14beta版中RealmJS引发了一个crash,导致所有iOS 14beta版的用户都受到影响,虽然说这个crash在iOS 14的beta2迭代中就不存在了,但为了保险起见,我还是决定升级library。为此我曾尝试升级到v6.6版本,作为一个暂时的解决方案,但是安装依赖失败这一点简直不能忍,于是我决定彻底抛弃RealmJS,改用Realm的native SDK。虽然在Android和iOS两端都需要写native代码来实现存储功能,但真的比RealmJS用起来容易多了,再也不用担心打包失败和missing constructor了,真的谁用谁知道!

3. 跨平台的局限性

RN对原生平台依赖太强,取代不了原生。虽然它已经能做很多事了,但是:

  • 很多功能还是需要原生端实现

    既然根植于原生,必然是脱离不了原生平台的。很多功能使用原生方案实现是更好的选择,比如拍照、图片编辑、动画使用原生API实现更直接、性能表现更好。

  • Android/iOS系统升级适配

    Android和iOS系统更新或者条款更新总会需要开发者做一些适配工作,比如Android 10存储权限的变更,导致共享目录在Android 10以后不能再直接访问,WRITE_EXTERNAL_STORAGE权限也不起作用。我们项目中用到第三方库rn-fetch-blob来做下载功能,但是由于此库无人维护,只能自己适配。由于下载和存储是在Native端实现的,只能在Native端去做改动。

    此外,对于iOS来说,要适配更新的iOS系统,我们经常需要升级Xcode,可能在新版本的Xcode上就会遇到原来能编译通过的项目现在却编译失败了。

  • 调试不方便

    RN需要JS的运行环境,在开发模式下本地需要启动一个package server来监控文件的变更,配合chrome或者react dev tools来调试JS代码。Native代码仍然需要使用Android studio或者Xcode来调试,这无疑增加了调试工作量。让人难受的是有时候会因为环境问题或者第三方库的原因导致频繁出现红屏报错,为了解决这些error需要各种search,时间就耗在这些问题上了。

  • 安全性存在问题

    RN打包时会把JS代码和资源文件打包成一个js bundle文件,这个bundle文件中就包含了所有编译之后的JS代码,因此一些重要的配置信息如API key、secret等最好不要写在JS代码中,以免造成安全问题。官方文档也针对security做了比较清楚的说明。

  • 稳定性问题

    RN的稳定性与原生平台是有差距的,这一点必须承认,尤其是在Android端。RN需要JS的运行环境来解释执行JS编译之后的bundle文件,在Android端使用了webkit官方开源的jsc.so,此外还有很多其它的so调用,比如Android系统的libc.so。一些crash问题就是由动态链接库造成的,可能跟用户本身设备系统版本和webview版本有关,系统库导致的crash也没有堆栈信息,因此这些问题很难定位原因,比如libc.so导致的crash。还有RN组件本身导致的crash,这些问题都是RN稳定性不如原生的因素之一。

4. 关于性能优化

性能优化是应用开发中常见的话题,RN应用的优化需要从JS和原生端同时入手。

  • Crash问题的追踪

我们的项目中使用了Firebase crashlytics来统计分析crash log,从Firebase console可以看到,JS端的exception都会通过RN原生代码抛出,Android中通过ExceptionsManagerModule中的reportException抛出异常信息,iOS则通过RCTAsset中的RCTFormatError抛出异常。JS端的exception一般也会有堆栈信息,可以在js bundle中去查找相关代码定位exception。

Native的crash则分别按照Android和iOS平台的方式去定位,比如Android上传native debug symbol到Google play console,iOS上传dSYM文件到Firebase或相应的统计分析平台,将符号化的日志文件转化成更加清晰的堆栈信息,便于我们分析定位问题。

在实践中我发现很多JS端exception都是代码不规范导致的,轻则导致app白屏重则crash,比如从Object取值的时候Object可能是空的,不存在key value。类似这样的情况一定要谨慎处理,这里建议使用loadash的get函数取值,在取值为undefined的情况,还可以设置默认值。

import _ from "loadash";
const obj = {"key1": "1", "key2": "2"};
const a = _.get(obj, "key1.key2.key3", "");
if (a.length > 0) {
    // do something
}

本例中在路径“key1.key2.key3”下都取不到值,a就会是undefined,这时候如果不赋予一个空字符串作为默认值,那么在if判断时就会抛出异常,因为undefined没有length这个属性。在我们平常写代码过程中有很多类似这样的细节需要注意。

  • shouldComponentUpdate

官方文档说完善地使用这个函数可以避免重新渲染那些实际没有变化的子组件所带来的额外开销。但是在实际开发中,我们所面临的情况可能比官方给出的例子要复杂得多,实际的业务逻辑、状态变化远远不是一两个变量能cover的。对于这个函数的使用,在不影响系统功能的前提下,可以尽量去用它控制组件的重复渲染,但不要指望它能帮我们handle复杂的业务场景下的页面render规则。

  • 其它优化

这里贴上很久之前写的一点优化方案,可能部分已经不太适用了。其中防止navigator重复跳转的问题,处理方式并不是好的选择。这里以我目前项目为例,由于使用的是react-navigation,为了防止用户操作过快多次点击导致多次重复跳转同一页面,我们在页面跳转之前会判断下一个页面的routeName,传递的参数等是否与当前stack navigator中存在的页面相同,如果全部相同第二次之后就不再跳转页面。示例代码如下(由于react-navigation版本不同使用API可能略有差异):

export const navigateOnce = (getStateForAction: any) => (action: any, lastState: any) => {
  const { type, routeName, params } = action;
  return lastState &&
    type === NavigationActions.NAVIGATE &&
    routeName === lastState.routes[lastState.routes.length - 1].routeName &&
    JSON.stringify(params) === JSON.stringify(lastState.routes[lastState.routes.length - 1].params)
    ? null
    : getStateForAction(action, lastState);
};
CustomStackNavigator.router.getStateForAction =
navigateOnce(CustomStackNavigator.router.getStateForAction);

5. 一些开发中的建议 & tips

  • 不要过于依赖第三方库

    对于一些简单的功能,能自己动手实现的尽量自己写。这里不是提倡重复造轮子,而是引入过多第三方库可能会增加维护的工作量,毕竟不是你自己写的代码,一旦出了bug要么寄希望于他人修复、要么自己来改,而且随着版本迭代,可能这个库已经无法满足当前的功能需求了。一般来说大厂的SDK质量还是有保证的,小厂的或者个人开发者的就不好说了,引入太多第三方SDK也可能对app稳定性造成影响。

  • offline的调试

    开发过程中我们经常需要debug,RN会在本地启动一个package server运行在8081端口,对于iOS来说package server通过websoket与RN建立连接,Android由于通过adb reverse将package server端口映射到Android系统,所以即使断网也能保持package server和app的连接。因此通常需要断网调试时我都是把电脑网络断开,在模拟器上来debug。使用真机debug offline模式会比较麻烦,Android还好,iOS真机一旦断网就无法连接到package server了。如果app某些功能需要断网也能使用的场景,在offline调试时使用模拟器或者Android真机会比较方便一点。

  • webp支持

    webp其实不属于RN的范畴,它是Google的一种图片格式,使用webp格式图片替代png或jpg格式文件,能够减少图片文件大小,减小应用包的体积。如何转换webp图片可以看google官方文档。像Android项目中的大尺寸图片如splash启动页就可以转换成webp格式,可以大幅减小图片所占空间。

  • 图片快速加载fastimage

    RN中的Image组件加载网络图片比较缓慢,缓存机制不完善,对于大图的显示比较耗时,性能也比较差。这里推荐使用react-native-fast-image,其iOS端基于SDWebImage,Android使用Glide来加载图片,有比较完善的缓存机制,能够快速加载并显示图片。对于图片较多的页面,使用fast image组件能够提高图片渲染速度。

  • 如何打debug包

    这里我们打debug包的目的只是为了测试,仅供参考。

    在debug模式下想要不依赖package server让打出的debug包独立运行,需要先将js bundle打出来。可以使用如下命令,以Android为例:

npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output 
android/app/src/main/assets/index.android.bundle --assets-dest android/app/src/main/res

指定output路径和assets图片资源路径,可以将android bundle文件和图片资源输出到工程目录下,再通过./gradlew assembleDebug打包成debug版本的apk。iOS与此类似,只需要生成js bundle文件和导出assets图片资源,在Xcode——>Build Phases——>Compile Sources添加js bundle和assets的引用,就可以直接通过Xcode进行build。

npx react-native bundle --entry-file index.js --platform ios --dev false --bundle-output 
ios/main.jsbundle --assets-dest ios

为了build方便,可以将脚本写到package.json的scripts中,取个别名如ios-bundle,之后可以直接使用npm run ios-bundle进行打包。

禁用字体缩放效果

手机系统调节字体大小后,app中的文本字体大小也会随之变化,尤其在Android上影响非常明显。本来显示效果满分,调整字体大小后UI瞬间错乱。在RN中我们可以通过在app启动时禁用Text和TextInput组件的font scaling来实现,例如:

(Text as any).defaultProps = { ...((Text as any).defaultProps || {}), allowFontScaling: false };

    (TextInput as any).defaultProps = { ...((TextInput as any).defaultProps || {}), allowFontScaling: false };

强制使用LTR

有些语言如阿拉伯语、希伯来语是从右往左排列的,当Android手机语言切换到阿拉伯语时,app如果不做任何限制,UI会默认从右向左显示。可以通过如下方案强制LTR(left to right)显示。

在AndroidManifest文件中给application设置

android:supportsRtl="false"

对于一些组件仍然支持RTL样式的,需要在styles.xml中添加layoutDirection,使UI样式为LTR

在Android Application的onCreate方法中调用RN的I18nUtil,禁用RTL

I18nUtil.getInstance().allowRTL(getApplicationContext(), false);
  • Debug mode缓存问题

    在debug模式下,有时会遇到chrome有缓存的情况,无论怎么刷新模拟器,chrome dev tools中都无法显示最新代码。此时可以尝试清除浏览器缓存,关闭当前package server并重启。

  • 使用typescript

    在语言选择上,为什么要用typescript而不是javascript?因为typescript有类型定义,有类、接口、模块的概念,可以说它是建立在JavaScript的基础上的强类型语言,对于项目开发而言,我们希望每个类每个对象都有比较确定的类型,在编码阶段就能对数据类型进行明确的限定,杜绝错误的数据类型,而不是等到项目编译或者运行阶段才去发现错误,这是JS无法带给我们的。

  • 单元测试框架

    对于RN端,我们仍然使用常用的Jest框架,这一点与React别无二致。 iOS和Android原生端,仍然使用各自平台的测试框架,iOS用XCTest.framework,Android通常使用JUnit、AndroidJUnitRunner和Mockito。

总结

RN作为移动端跨平台开发框架来说,优缺点十分明显。优点是上手比较简单,开发者生态比较活跃,社区资源也比较丰富,缺点是性能稳定性与原生平台还是存在一定差距的,尤其是对功能复杂、与原生交互较多的应用可能并不适用RN开发。虽然近年来使用RN开发的热度貌似有所降低,尤其是以Airbnb为首的一些公司放弃了RN,并且Flutter这样跨平台框架的崛起,导致网上出现很多“RN已经凉了”的声音。但是时至今日,RN仍然还在很多项目中得到广泛应用,Facebook仍然还在持续维护,开发者生态依然生机勃勃,可以说RN的生态是移动端跨平台开发框架中最好的也不为过,说凉凉还为时过早。

我个人认为RN依然是有竞争力的,至于要不要用RN在技术选型阶段还是要多考虑考虑,怎么用、用不用得好在开发阶段就需要多研究,在实践过程中不断优化改进。最后,欢迎大家一起探讨,有好的实践可以互相交流。

参考文章


  1. Security - React Native

  2. React Native性能优化总结

  3. Loadash documentation

  4. Performance Overview

  5. 24 tips for React Native you probably want to know


文/Thoughtworks朱浩
原文链接:https://insights.thoughtworks.cn/react-native-practice/
更多精彩洞见,请关注微信公众号Thoughtworks洞见。

你可能感兴趣的:(React Native实践有感)