ReactNative图片解析及渲染流程

在做ReactNative的业务过程中,由于碰到了不少奇(keng)妙(die)的问题,所以不免要从源码入手,探究一下各个方面的原理。这一篇文章就是由一个图片路径问题引出的总结。

我们知道,ReactNative 中创建一个图片组件有两种方式:

1
2
3
4
5
6
7
8
9
// 代码1
// 指定一个本地图片资源
<Image source={require('../../../images_QRMedalHallRN/ranklinel.png')} style={styles.medalRankLine}>
</Image>
// 指定一个网络图片资源
<Image source={{uri:this.state.icon}} style={styles.medalPic}>
</Image>

本文主要分析了从这几行 js 代码,到最终在 Native 生成 UIImageView 的过程。

jsbundle中的图片

上一篇文章讲到,无论是真机调试,还是模拟器调试,都是执行打包生成的 jsbundlejsbundle中主要有两个东西比较值得注意:

  1. __d:即 define,其定义在 node_modules/metro-bundler/src/Resolver/polyfills/require.js 中,可以理解为将一个模块以一个唯一的模块 ID 进行注册。

  2. requirerequire的方法参数为模块 ID,也就是 __d 所注册的模块 ID,其调用了在 __d 中注册的工厂方法。

有关 jsbundle 更加具体的解读,请参考我师父的文章^w^

我们以 source={require('../../../images_QRMedalHallRN/ranklinel.png')} 指定的本地图片资源,会在 jsbundle中生成一段如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 代码2
__d(/* QRMedalHallRN/images_QRMedalHallRN/ranklinel.png */
function(global, require, module, exports) {
module.exports=require(169).registerAsset(
{"__packager_asset":true,
"httpServerLocation":"/assets/images_QRMedalHallRN",
"width":27,
"height":3.5,
"scales":[2,3],
"hash":"8c3679b44d91f790bf863b7f521fd6e1",
"name":"ranklinel",
"type":"png"}); // 169 = react-native/Libraries/Image/AssetRegistry
},
432,
null,
"QRMedalHallRN/images_QRMedalHallRN/ranklinel.png");

这里可以看到,一个本地图片资源文件,在RN中是当做一个模块来对待、处理的,有自己的 __d 方法。在这段代码中,function就是该模块所注册的工厂方法,在 require 该模块的时候会被调用。432即该模块的模块 ID。

1
2
<Image source={require('../../../images_QRMedalHallRN/ranklinel.png')} style={styles.medalRankLine}>
</Image>

这段代码在经过编译,打包后,在 jsbundle 中的存在形态如下:

1
2
3
4
5
6
7
8
9
10
// 代码3
_react2.default.createElement(
_reactNative.Image,
{ source: require(432), style: styles.medalRankLine, __source: {
// 432 = ../../../images_QRMedalHallRN/ranklinel.png
fileName: _jsxFileName,
lineNumber: 130
}
}),

可以看到,我们使用JSX写出的 <Image/> 控件,在经过编译后,变成了标准的js函数调用 createElement (这也是需要进行编译、打包的原因之一)。其中,调用了 require(432),其实也就是去执行了上面 代码2 中注册的 function

function 中,调用了 require(169),从注释可以看出,169react-native/Libraries/Image/AssetRegistry 的模块 ID。也就是这里调用了 AssetRegistryregisterAsset 方法。

AssetRegistry

AssetRegistry 为本地图片资源的一个注册站。它的代码很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 代码4
var assets: Array<PackagerAsset> = [];
function registerAsset(asset: PackagerAsset): number {
// `push` returns new array length, so the first asset will
// get id 1 (not 0) to make the value truthy
return assets.push(asset);
}
function getAssetByID(assetId: number): PackagerAsset {
return assets[assetId - 1];
}
module.exports = { registerAsset, getAssetByID };

registerAsset 函数会将图片信息(一个map)保存到全局的数组中,并返回一个从1开始的索引。getAssetByID 函数会根据索引,取出在全局数组中保存的图片信息。注意,如果是指定本地图片的话,这里的图片信息只包含相对路径,即 '../../../images_QRMedalHallRN/ranklinel.png' 这种。

因此,在 <Image source=...><Image/> 中,我们以 source={require()} 指定的图片,会变成 source=<1> 这样的格式,而以 「uri」 指定的网络地址则不会被改变。

image.ios.js

我们在RN中使用的 <Image/> 控件,其源码位于 image.ios.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 代码5,源码有删减
render: function() {
const source = resolveAssetSource(this.props.source) || { uri: undefined, width: undefined, height: undefined };
let sources;
let style;
if (Array.isArray(source)) {
style = flattenStyle([styles.base, this.props.style]) || {};
sources = source;
} else {
const {width, height, uri} = source;
style = flattenStyle([{width, height}, styles.base, this.props.style]) || {};
sources = [source];
if (uri === '') {
console.warn('source.uri should not be an empty string');
}
}
const resizeMode = this.props.resizeMode || (style || {}).resizeMode || 'cover'; // Workaround for flow bug t7737108
const tintColor = (style || {}).tintColor; // Workaround for flow bug t7737108
if (this.props.src) {
console.warn('The <Image> component requires a `source` property rather than `src`.');
}
return (
<RCTImageView
{...this.props}
style={style}
resizeMode={resizeMode}
tintColor={tintColor}
source={sources}
/>
);
}
const RCTImageView = requireNativeComponent('RCTImageView', Image);
module.exports = Image;

其中,第4行代码,调用了 resolveAssetSource 对传入的 source 进行了解析,并最终作为一个参数传入 RCTImageView 中,而 RCTImageView 就是 Native 端的 RCTImageView 类,下文会讲到。

进入 resolveAssetSource。其定义在 resolveAssetSource.js 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 代码6
/**
* `source` is either a number (opaque type returned by require('./foo.png'))
* or an `ImageSource` like { uri: '<http location || file path>' }
*/
function resolveAssetSource(source: any): ?ResolvedAssetSource {
if (typeof source === 'object') {
return source;
}
var asset = AssetRegistry.getAssetByID(source);
if (!asset) {
return null;
}
const resolver = new AssetSourceResolver(getDevServerURL(), getBundleSourcePath(), asset);
if (_customSourceTransformer) {
return _customSourceTransformer(resolver);
}
return resolver.defaultAsset();
}

由注释和源码,我们很明显看出,这里传入的 source,要么是一个数字,即上文所述,在 AssetRegistry 中注册的索引,要么是以 uri 传入的地址,即一个字符串。如果是字符串,那么将直接返回,否则会将之前在 AssetRegistry 中注册的图片信息提取出来,经过 AssetSourceResolver 的加工,形成最终的 source 返回。而AssetSourceResolver 的初始化中,传入了三个参数,分别是「网络服务器地址」,「本地图片路径」,「图片信息」。其中,前两个参数获取方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 代码7
function getDevServerURL(): ?string {
if (_serverURL === undefined) {
var scriptURL = NativeModules.SourceCode.scriptURL;
var match = scriptURL && scriptURL.match(/^https?:\/\/.*?\//);
if (match) {
// Bundle was loaded from network
_serverURL = match[0];
} else {
// Bundle was loaded from file
_serverURL = null;
}
}
return _serverURL;
}
function getBundleSourcePath(): ?string {
if (_bundleSourcePath === undefined) {
const scriptURL = NativeModules.SourceCode.scriptURL;
if (!scriptURL) {
// scriptURL is falsy, we have nothing to go on here
_bundleSourcePath = null;
return _bundleSourcePath;
}
if (scriptURL.startsWith('assets://')) {
// running from within assets, no offline path to use
_bundleSourcePath = null;
return _bundleSourcePath;
}
if (scriptURL.startsWith('file://')) {
// cut off the protocol
_bundleSourcePath = scriptURL.substring(7, scriptURL.lastIndexOf('/') + 1);
} else {
_bundleSourcePath = scriptURL.substring(0, scriptURL.lastIndexOf('/') + 1);
}
}
return _bundleSourcePath;
}

可以看到,resolveAssetSource 中从 Native 端获取了 scriptURL,也就是 jsbundle 的 URL,并进行了判断:是否是网络地址,是否是本地 asset 地址,是否是沙盒文件地址等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 代码8
@implementation RCTSourceCode
RCT_EXPORT_MODULE()
@synthesize bridge = _bridge;
- (NSDictionary<NSString *, id> *)constantsToExport
{
return @{
@"scriptURL": self.bridge.bundleURL.absoluteString ?: @""
};
}
@end

如果是从本地服务器动态进行打包,那么获取到的就是 http://localhost:8081/index.ios.bundle?platform=ios&dev=true&minify=false 这种网络地址。如果是采用了本地静态资源 bundle 的形式,那么这里获取到的是本地 asset 地址或沙盒文件路径。

再看AssetSourceResolverAssetSourceResolver会将初始化时传入的三个参数进行整合,并加上图片分辨率等信息,返回一个包含完整图片资源路径的 JSON 格式的图片信息:

1
2
3
4
5
6
7
8
9
// 代码9
export type ResolvedAssetSource = {
__packager_asset: boolean,
width: ?number,
height: ?number,
uri: string,
scale: number,
};

这也就是最终传入到 Native 端,用于初始化 RCTImageView 的 JSON 数据。至此,js 端的工作结束了。

Native端

Native 端入口,自然是 RCTUIManager。有关 RCTUIManager,在我师父的另外一篇文章^w^中已经讲解的很清楚了,这里不再赘述了。

我们上面生成的 JSON 数据,会被解析成 RCTImageSource 类的对象,这段代码在 RCTImageSource 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 代码10
+ (RCTImageSource *)RCTImageSource:(id)json
{
if (!json) {
return nil;
}
NSURLRequest *request;
CGSize size = CGSizeZero;
CGFloat scale = 1.0;
BOOL packagerAsset = NO;
if ([json isKindOfClass:[NSDictionary class]]) {
// 逻辑1
if (!(request = [self NSURLRequest:json])) {
return nil;
}
size = [self CGSize:json];
scale = [self CGFloat:json[@"scale"]] ?: [self BOOL:json[@"deprecated"]] ? 0.0 : 1.0;
packagerAsset = [self BOOL:json[@"__packager_asset"]];
} else if ([json isKindOfClass:[NSString class]]) {
// 逻辑2
request = [self NSURLRequest:json];
} else {
RCTLogConvertError(json, @"an image. Did you forget to call resolveAssetSource() on the JS side?");
return nil;
}
RCTImageSource *imageSource = [[RCTImageSource alloc] initWithURLRequest:request
size:size
scale:scale];
imageSource.packagerAsset = packagerAsset;
return imageSource;
}

RCTImageSource 会把图片信息进行整合,创建出一个 NSURLRequest。其中,逻辑1对应 require() 指定本地图片地址的方式,逻辑2对应 uri 指定网络图片地址的方式。

最终,RCTImageSource 会传到 RCTImageViewRCTImageViewUIImageView 的子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 代码11
@interface RCTImageView : UIImageView
- (instancetype)initWithBridge:(RCTBridge *)bridge NS_DESIGNATED_INITIALIZER;
@property (nonatomic, assign) UIEdgeInsets capInsets;
@property (nonatomic, strong) UIImage *defaultImage;
@property (nonatomic, assign) UIImageRenderingMode renderingMode;
@property (nonatomic, copy) NSArray<RCTImageSource *> *imageSources;
@property (nonatomic, assign) CGFloat blurRadius;
@property (nonatomic, assign) RCTResizeMode resizeMode;
@end
@interface RCTImageView ()
@property (nonatomic, copy) RCTDirectEventBlock onLoadStart;
@property (nonatomic, copy) RCTDirectEventBlock onProgress;
@property (nonatomic, copy) RCTDirectEventBlock onError;
@property (nonatomic, copy) RCTDirectEventBlock onPartialLoad;
@property (nonatomic, copy) RCTDirectEventBlock onLoad;
@property (nonatomic, copy) RCTDirectEventBlock onLoadEnd;
@end
@implementation RCTImageView
{
__weak RCTBridge *_bridge;
// The image source that's currently displayed
RCTImageSource *_imageSource;
// The image source that's being loaded from the network
RCTImageSource *_pendingImageSource;
// Size of the image loaded / being loaded, so we can determine when to issue
// a reload to accomodate a changing size.
CGSize _targetSize;
/**
* A block that can be invoked to cancel the most recent call to -reloadImage,
* if any.
*/
RCTImageLoaderCancellationBlock _reloadImageCancellationBlock;
}

可以看到,RCTImageView 集中控制了图片的获取,下载(如果是网络图片的话),缓存,展示等等任务,能保证图片展示的高效性和流畅度,具体在此不再赘述了。另外,传入的是一个 RCTImageSource 的数组,这是为了 Native 根据不同分辨率的设备,选取合适的图片进行展示。

总结

说了这么多,引发这一串源码分析的,其实是一个图片资源路径错误的问题。因为我们采取了分包加载的方案,我们的 common.js 一开始放在了 appbundle 中,但我们业务的图片资源是下发后,保存在用户沙盒目录下,因此,在 代码8 中,返回的其实是 appbundle 的路径,自然取不到沙盒路径下的图片了。由此可见,弄懂源码好像才是解决问题最彻底的手段。