1. 图片加载方式 Image
React Native 的 Image 组件一共支持 4 种加载图片的方法:
- 静态图片资源;
- 网络图片;
- 宿主应用图片;
- Base64 图片。
静态图片资源
静态图片资源(Static Image Resources)是一种使用内置图片的方法。静态图片资源中 的“静态”指的是每次访问时都不会变化的图片资源。站在用户的视角看,App 的 logo 图片就是不会变化的静态图片资源,而每次访问新闻网站的新闻配图就是动态变化的图 片。
具体是怎么使用静态图片资源的呢?这里我们可以分为 3 步。
- 首先,把图片放到 React Native 的代码仓库中,
- 然后通过 require 的方式引入图片,
- 最后把图片的引用值传 给 source 属性。Image.source 属性是用来设置图片加载来源的。
const picture = require('./picture.jpg')
<Image source={picture} />
静态图片资源的加载原理
以加载picture图片(picture.jpg)为例,从编译时到运行时,剖析加载静态资源图片的全过程,一共分为三步。
- 第一步编译: 在编译过程中,图片资源本身是独立于代码文件之外的文件,图片资源本身 是不能编译到代码中的,所以,我们需要把图片资源的路径、宽高、格式等信息记录到代 码中,方便后面能从代码中读取到图片。
- 第二步构建: 编译后的 Bundle 和静态图片资源,会在构建时内置到 App 中。
- 第三步运行: 在运行时,require 引入的并不是静态图片资源本身,而是静态图片资源的信 息。Image 元素要在获取到图片路径等信息后,才会按配置的规则加载和展示图片。
可以通过 Image.resolveAssetSource 方法来获取图片信息。具体的示例代码如 下:
const dianxinIcon = require('./dianxin.jpg')
alert(JSON.stringify(Image.resolveAssetSource(dianxinIcon)))
// 弹出的信息如下:
{
"__packager_asset": true,
"httpServerLocation": "/assets/src/Lesson3Image",
"width": 190,
"height": 190,
"scales": [1],
"hash": "0d4ac32eb69529cf90a7b248fee00592",
"name": "dianxin",
"type": "jpg"
}
正是因为静态图片资源加载方式,它在“编译时”提前获取了图片宽高等信息,在“构建 时”内置了静态图片资源,因此在“运行时”,程序可以提前获取图片宽高和真正的图片 资源。相对于我们后面要介绍的网络图片等加载方式,使用静态图片资源加载,即使不设 置图片宽高,也有一个默认宽高来进行展示,而且加载速度更快。
网络图片
静态图片资源虽好,但它只适用于“静态不变的”图片资源,对于那些“动态变化的”和 不方便内置的业务场景,那就要用到网络图片了。
网络图片(Network Images)指的是使用 http/https 网络请求加载远程图片的方式。
在使用网络图片时,建议将宽高属性作为一个必填项来处理。为什么呢?静态图片资源不同的是,网络图片下载下来之前,React Native 是没法知道图片的宽高的,所以它只能用默认的 0 作为宽高。这个时候,如果没有填写宽高属性,初始化默认宽高是 0,网络图片就展示不了。
<Image source={{ uri: 'https://react.org/logo.png' }} style={{ width: 400, height: 400 }} />
缓存与预加载
- 网络图片虽然指的是走网络请求下载的图片,但也并不用每次都走网络下载,只要 有缓存就能直接从本地加载。
- React Native Android 用的是 Fresco 第三方图片加载组件的缓存机制,iOS 用的是 NSURLCache 系统提供的缓存机制。
第一次访问时,网络图片是先加载到内存中,然后再落盘存在磁盘中的。 后续如果我们需要再次访问,图片就会从缓存中直接加载,除非超出了最大缓存的大小限制。
例如,iOS 的 NSURLCache 遵循的是 HTTP 的 Cache-Control 缓存策略,同时当 CDN 图片默认都已经设置了 Cache-Control 时,iOS 图片就是有缓存的。
而 NSURLCache 的默认最大内存缓存为 512kb,最大磁盘缓存为 10MB,如果缓存图片 的体积超出了最大缓存的大小限制,那么一些老的缓存图片就会被删除。
图片缓存机制有什么用呢?
通过图片缓存机制和预加载机制的配合,可以合理地利用缓存来提高图片加载速度, 进一步地提升用户体验。
使用图片预加载机制,可以提前把网络图片缓存到本地。对于用户来说,提前缓存的图片 是第一次看到的,但对于系统缓存来说图片是第二次加载,它的加载速度是毫秒级的甚至 亚秒级的。这就是预加载机制,提升图片加载性能的原理。
React Native 也提供了非常方便的图片预加载接口 Image.prefetch。
宿主应用图片
宿主应用图片(Images From Hybrid App’s Resources )指的是 React Native 使用 Android/iOS 宿主应用的图片进行加载的方式。在 React Native 和 Android/iOS 混合应用中,也就是一部分是原生代码开发,一部分是 React Native 代码开发的情况下,你可能 会用到这种加载方式。
注意: 开发 React Native 的团队,和开发 Android/iOS 的团队很可能不是一个团队,甚至可能跨部门。复用的收益抵不上复用带来的安全风险、维护成本和沟通成本,因此并不推荐你使用。
Base64 图片
Base64 指的是一种基于 64 个可见字符表示二进制数据的方式,Base64 图片指的是使用 Base64 编码加载图片的方法,它适用于那些图片体积小的场景。
<Image
source={{
uri: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADMAAAAzCAYAAAA6oTAqAAAAEXRFWHRTb2Z0d2FyZQBwbmdjcnVzaEB1SfMAAABQSURBVGje7dSxCQBACARB+2/ab8BEeQNhFi6WSYzYLYudDQYGBgYGBgYGBgYGBgYGBgZmcvDqYGBgmhivGQYGBgYGBgYGBgYGBgYGBgbmQw+P/eMrC5UTVAAAAABJRU5ErkJggg=='
}}
/>
由于 Base64 图片是嵌套在 Bundle 文件中的,所以 Base64 图片的优点是无需额外的网 络请求展示快,缺点是它会增大 Bundle 的体积。
在动态更新的 React Native 应用中, Base64 图片展示快是以 React Native 页面整体加载慢为代价的。原因就是它会增加 Bundle 的体积,增加 Bundle 的下载耗时,从而导致 React Native 页面展示变慢。
即便是相同的图片,Base64 字符串的体积也要比二进制字节码的体积要大 1/3,这又进一 步增加 Bundle 的大小。
2. 如何实现一个体验好的点按组件 Pressable
点按组件的设计与我们的用户体验息息相关。作为直接和用户打交道的工程师,我们也得“懂”用户,也得去优化我们负责的 App、页 面的体验,还得在技术上搞懂点按组件使用方法和背后的原理,把这种最常用的人机交互 体验给做到及格,做到优秀。所以,以下面三个问题为脉络进行讲解:
- 点按组件是要简单易用还是要功能丰富,如何取舍?
- 点按组件是如何知道它是被点击了,还是被长按了?
- 点按组件为什么还要支持用户中途取消点击?
要简单易用还是功能丰富?
首先,点按组件是设计给你我这样的开发者来使用的,它功能越简单开发者用起来就越简单,它功能越复杂就能满足更多的需求场景。React Native 的点按组件经历了三个版本的迭代,才找到了两全其美的答案。等你了解了这个三个版本的迭代思路后,你就能很好明白优秀通用组件应该如何设计,才能同时在用户体验 UX 和开发者体验 DX 上找到平衡。
第一代 Touchable 组件
第一代点按组件想要解决的核心问题是,提过多种反馈风格。 一个体验好的点按组件,需要在用户点按后进行实时地反馈,通过视觉变化等形式,告诉 用户点到了什么,现在的点击状态又是什么。 但不同的原生平台,有不同的风格,反馈样式也不同。Android 按钮点击后会有涟漪, iOS 按钮点击后会降低透明度或者加深背景色。
第一代 Touchable 点按组件的设计思路是,提供多种原生平台的反馈风格给开发者自己选择。框架提供了 1 个基类和 4 个扩展类,Touchable 点 按组件在提供多样性的功能支持的同时,也带来了额外的学习成本。
第二代 Button 组件
第二代 Button 组件的实质是对 Touchable 组件的封装。在 Android 上是 TouchableNativeFeedback 组件,在 iOS 上是 TouchableOpacity 组件。
Button 组件的设计思想就是,别让开发者纠结选啥组件了,框架已经选好了,点按反馈的 样式就和原生平台的自身风格保持统一就好了。
Button 组件虽然降低了开发者选择成本,但是想在 UI 风格上让大家选择都原生平台自身 的风格,这太难了。
第三代 Pressable 组件
第三代 Pressable 点按组件,不再是 Touchable 组件的封装,而是一个全新重构的点按组 件,它的反馈效果可由开发者自行配置。
// 固定的基础样式
const baseStyle = { width: 50, height: 50, backgroundColor: 'red'}
<Pressable
onPress={handlePress}
style={baseStyle}
>
<Text>按钮<Text>
</Pressable>
- 第一代点按组件 Touchable,功能丰富但学习成本太高;
- 第二代点按组件 Button,简单易用但带了默认样式和反馈效果,通用性太差;
- 第三代点按组件 Pressable,同时满足了简单易用和复杂效果可扩展的特性。
因此,在实现自定义的业务按钮组件时,我更加推荐你使用第三代点按组件 Pressable。而且,Pressable 组件的动态 style 的设计思路,也是非常值得我们学习的。
如何知道是点击,还是长按?
整个点按事件的响应过程是硬件和软件相互配合的过程。 Pressable 组件响应的整体流 程,是从触摸屏识别物理手势开始,到系统和框架 Native 部分把物理手势转换为 JavaScript 手势事件,再到框架 JavaScript 部分确定响应手势的组件,最后到 Pressable 组件确定是点击还是长按。
在 Pressable 组件中,使用 onPressIn 来响应开始点按事件,使用 onPressOut 来 响应结束点按事件。示例代码如下:
<Pressable
onPressIn={handlePressIn}
onPressOut={handlePressOut}
>
<Text>按钮<Text>
</Pressable>
当你触碰到“按钮”开始点按时,React Native 框架就会帮你调用 handlePressIn 处理函 数,当你手指离开“按钮”结束点按时,就会调用 handlePressOut 处理函数。
实现点击事件 onPress 和长按事件 onLongPress,只需要判断 onPressIn 事件和 onPressOut 事件之间触发间隔耗时 就可以了:
如果间隔耗时 < 500ms 属于点击。用户的点按动作会先触发 onPressIn,再触发 onPressOut,在 onPressOut 事件中可以触发我们 “自定义的”点击事件 onPress; 如果间隔耗时 >= 500ms 属于长按。用户的点按动作会先触发 onPressIn,这个时候你 可以埋下一个定时器,并在第 500ms 时通过定时器触发我们 “自定义的” onLongPress,最后在用户松手的时候触发 onPressOut。
点击事件 onPress 和长按事件 onLongPress 是互斥的,触发了一个就不会再触发另一个了。
为什么支持中途取消?
要清楚这个问题,我们需要深入到事件区域模型,也就是点按操作手势的可用范围的概念。点按操作手势的可用范围包括盒模型区域、可触发区域 HitRect 和可保留区域 PressRect。
盒模型区域
React Native 中的盒模型概念来自于 Web 领域的 W3C 规范,宽度 width、高度 height,这两个属性就决定了盒模型(Box Modal)中的内容 content 大小。除此之外, 盒模型中还有内边距 padding、边框 border、外边距 margin。
可以直接修改宽高、边框、内边距的值,通过扩大盒模型的范围,提高点中的成功 率。但是,修改盒模型成本较高,它可能会导致原有 UI 布局发生变化。
更好的方案是,不修改影响布局的盒模型,直接修改可触发区域的范围,提高点中的成功率。
可触发区域 HitRect
Pressable 组件有一个可触发区域 HitRect,默认情况下,可触发区域 HitRect 就是盒模型 中的不透明的可见区域。你可以通过修改 hitSlop 的值,直接扩大可触发区域。
在老点不中、老勾不中的场景中,你可以在不改变布局的前提下,设置 Pressable 组件的 可触发区域 HitSlop,让可点击区域多个 10 像素、20 像素,让用户的更容易点中。
可保留区域 PressRect
例如,用户已经点到购买按钮了,突然犹豫,又不想买了,于是将手指从按钮区域移开 了。这时你得让用户能够反悔,能够取消即将触发的点击操作。
这种情况就需要可保留区域 PressRect。点按事件可保留区域的偏移量 (Press Retention Offset)默认是 0,也就是说默认情况下可见区域就是可保留区域。你 可以通过设置 pressRetentionOffset 属性,来扩大可保留区域 PressRect。
在你后悔点下购买按钮的情况下,你可以把已经按下的手指从可保留区域挪开,然后再松 手,这就不会再继续触发点击事件了。
盒模型区域的可见区域、可触发区域 HitRect 和可保留区域 PressRect 的关系图:
3. 如何实现一个体验好的输入框?TextInput
使用 TextInput 组件应该 知道的三件事。
输入框的文字
关于如何处理输入框的文字,有两种说法。有使用非受控组件来处理,他们认为“不应该使用 useState 去控制 TextInput 的文字状态”,因为 ref 方案更加简单; 有用受控组件来处理,这些人认为“直接使用 ref 去操作宿主组件这太黑科技了”。
对于非受控组件来说,存储跨域两次 render 的可行方案是 ref。ref 的值不会因为组件刷 新而重新声明,它是专门用来存储组件级别的信息的。 有三种场景可以用它:
- 存储 setTimeout/setInterval 的 ID;
- 存储和操作宿主组件(在 Web 中是 DOM 元素);
- 存储其他不会参与 JSX 计算的对象。
非受控组件的原理是最简单的,用户输入的“文本原件”是存在宿主组件上的,JavaScript 中的只是用textRef复制了一份 “文本的副本”而已。 但正是因为非受控组件使用的是副本,一些复杂的操作是做不了的,比如将用户输入的字 母由大写强制改为小写,等等。
因此要操作文本原件,必须得用受控(Controlled)组件。
对于非受控组件来说,用户输入文字和文字展示到屏幕的过程,全部都是在宿主应 用层面进行的,JavaScript 业务代码是没有参与的。
然而,对于受控组件来说,用户输入文字和文字展示这两步,依旧是在宿主应用层面进行 的。但后续 JavaScript 业务代码也参与进去了,业务代码依次执行了 onChangeText 函 数、setText 函数、controlledTextInput 函数,并且再次更新了展示值。
也就是说,受控组件更新了两次展示的值,只是因为两次展示的值是一样的,用户看不出 来而已。
更新两次的好处在于,可以更加自由地控制输入的文本,比如语音输入文字、通过地图定 位填写详细地址。这些复杂场景下,用户既可以自由输入文字,也可以引入程序参与进 来。而非受控组件只适用于用户自由输入的场景。
异步更新情况下,JavaScript 线程和 UI 主线程是独立运行的,此时即便 JavaScript 线程卡了 1s,主线程依旧可以正常输入文字。所以,不用担忧更新两次有性能问题。
所以,处理输入框的文本建议是,使用受控组件,并且使用异步的文字改变事件。
输入框的焦点
有些场景下,输入框的焦点是程序自动控制的,无需开发者处理。比如用户点击手机屏幕 上的输入框,此时焦点和光标都会移到输入框上。
有些场景下,是需要代码介入控制焦点的。比如你购物搜索商品,从首页跳到搜索页时, 搜索页的焦点就是用代码控制的。或者你在填写收货地址时,为了让你少点几次输入框, 当你按下键盘的下一项按钮时,焦点就会从当前输入框自动转移到下一个输入框。
TextInput 的 autoFocus 属性,就是用于控制自动对焦用的,其默认值是 false。
搜索页面只有一个搜索框的场景下 ,autoFocus 是好用的。但当一个页面有多个输入框 时,autoFocus 就没法实现焦点的转移了。
那要实现每点一次键盘的“下一项”按钮,将焦点对到下一个 TextInput 元素上,怎么实 现呢?具体的示例代码如下:
function AutoNextFocusTextInputs() {
const ref1 = React.useRef(null);
const ref2 = React.useRef(null);
const ref3 = React.useRef(null);
return (
<TextInput ref={ref1} onSubmitEditing={ref2.current?.focus} />
<TextInput ref={ref2} onSubmitEditing={ref3.current?.focus} />
<TextInput ref={ref3} />
);
}
联动键盘的体验
先来看第一个体验细节,iOS 微信搜索框的键盘右下角按钮有一个“置灰置蓝”的功能。 默认情况下,键盘右下角的按钮显示的是置灰的“搜索”二字,当你在搜索框输入文字 后,置灰的“搜索”按钮会变成蓝色背景的“搜索”二字。 置灰的作用是提示用户,没有输入文字不能进行搜索,按钮变蓝提示的是有内容了,可以 搜索了。
控制键盘右下角按钮置灰置蓝的,是 TextInput 的enablesReturnKeyAutomatically 属性,这个属性是 iOS 独有的属性,默认是false,也就是任何使用键盘右下角的按钮, 都可以点击。你也可以通过将其设置为 true,使其在输入框中没有文字时置灰。
第二个体验细节是,键盘右下角按钮的文案是可以变化的,你可以根据不同的业务场景进 行设置。 有两个属性可以设置这些文案,包括 iOS/Android 通用的 returnKeyType 和 Android 独有的 returnKeyLabel。
第三个体验细节是,登录页面的自动填写账号密码功能。完成快速填写功能的 TextInput 属性,在 iOS 上叫做 textContentType,在 Android 上叫做autoComplete。
还有一些键盘的体验细节,比如keyboardType可以控制键盘类型,可以让用户更方便地 输入电话号码phone-pad、邮箱地址email-address等等。
4. 如何实现高性能的无限列表?RecyclerListView
React Native 官方提供的列表组件是 FlatList,但是推荐你优先使用开源社区提供的列表组件 RecyclerListView。因为,开源社区提供的 RecyclerListView 性能更好。
渲染所有内容的滚动组件 ScrollView
使用 ScrollView 组件时,我们通常并不直接给 ScrollView 设置固定高度或宽度,而是给 其父组件设置固定高度或宽度。
一般而言,我们会使用安全区域组件 SafeAreaView 组件作为 ScrollView 的父组件,并给 SafeAreaView 组件设置布局属性 flex:1,让内容自动撑高 SafeAreaView。使用 SafeAreaView 作为最外层组件的好处是,它可以帮我们适配 iPhone 的刘海屏,节约我们 的适配成本,示例代码如下:
<SafeAreaView>
<ScrollView>
<Text>1<Text>
</ScrollView>
</SafeAreaView>
使用 ScrollView 组件时,ScrollView 的所有内容都会在首次刷新时进行渲染。内容很少的 情况下当然无所谓,内容多起来了,速度也就慢下来了。
按需渲染的列表组件 FlatList
FlatList 是 React Native 官方提供的第二代列表组件。FlatList 组件底层使用的是虚拟列表 VirtualizedList,VirtualizedList 底层组件使用的是 ScrollView 组件。
列表组件和滚动组件的关键区别是,列表组件把其内部子组件看做由一个个列表项组成的集合,每一个列表项都可以单独渲染或者卸载。而滚动组件是把其内部子组件看做一个整体,只能整体渲染。而自动按需渲染的前提就是每个列表项可以独立渲染或卸载。
总的来说,FlatList 性能比 ScrollView 好的原因是,FlatList 列表组件利用按需渲染机制 减少了首次渲染的视图,利用空视图的占位机制回收了原有视图的内存。
可复用的列表组件 RecyclerListView
RecyclerListView 是开源社区提供的列表组件,它的底层实现和 FlatList 一样也是 ScrollView,它也要求开发者必须将内容整体分割成一个个列表项。
在首次渲染时,RecyclerListView 只会渲染首屏内容和用户即将看到的内容,所以它的首 次渲染速度很快。在滚动渲染时,只会渲染屏幕内的和屏幕附近 250 像素的内容,距离屏 幕太远的内容是空的。
React Native 的 RecyclerListView 复用灵感来源于 Native 的可复用列表组件。
RecyclerListView 的复用机制是这样的,你可以把列表比作数组 list,把列表项类比成数 组的元素。用户移动 ScrollView 时,相当于往数组 list 后面 push 新的元素对象,而 RecyclerListView 相当于把 list 的第一项挪到了最后一项中。挪动对象位置用到的计算资 源少,也不用在内存中开辟一个新的空间。而创建新的对象,占用计算资源多,同时占用 新的内存空间。
总的来说,RecyclerListView 在滚动时复用了列表项,而不是创建新的列表项,因此性能好。
从底层原理看:
- ScrollView 内容的布局方式是从上到下依次排列的,你给多少内容,ScrollView 就会渲 染多少内容;
- FlatList 内容的布局方式还是从上到下依次排列的,它通过更新第一个和最后一个列表项 的索引控制渲染区域,默认渲染当前屏幕和上下 10 屏幕高度的内容,其他地方用空白 视图进行占位;
- RecyclerListView 性能最好,你应该优先使用它,但使用它的前提是列表项类型可枚举 且高度确定或大致确定。
FlatList 和 RecyclerListView 使用场景:
- RecyclerListView 性能最好,应该优先使用它,但使用它的前提是可枚举且高度确定或大致确定;
- FlatList 性能还过得去,但不推荐你优先使用它,只有在你的列表项内容高度不能事先确定,或者不可枚举的情况下使用它。