ReactNative图片解析及渲染流程
在做ReactNative的业务过程中,由于碰到了不少奇(keng)妙(die)的问题,所以不免要从源码入手,探究一下各个方面的原理。这一篇文章就是由一个图片路径问题引出的总结。
我们知道,ReactNative
中创建一个图片组件有两种方式:
// 代码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中的图片
上一篇文章讲到,无论是真机调试,还是模拟器调试,都是执行打包生成的 jsbundle
。jsbundle
中主要有两个东西比较值得注意:
__d
:即define
,其定义在node_modules/metro-bundler/src/Resolver/polyfills/require.js
中,可以理解为将一个模块以一个唯一的模块 ID 进行注册。require
,require
的方法参数为模块 ID,也就是__d
所注册的模块 ID,其调用了在__d
中注册的工厂方法。
有关
jsbundle
更加具体的解读,请参考我师父的文章w
我们以 source={require('../../../images_QRMedalHallRN/ranklinel.png')}
指定的本地图片资源,会在 jsbundle
中生成一段如下代码:
// 代码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。
<Image source={require('../../../images_QRMedalHallRN/ranklinel.png')} style={styles.medalRankLine}>
</Image>
这段代码在经过编译,打包后,在 jsbundle
中的存在形态如下:
// 代码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)
,从注释可以看出,169
是 react-native/Libraries/Image/AssetRegistry
的模块 ID。也就是这里调用了 AssetRegistry
的 registerAsset
方法。
AssetRegistry
AssetRegistry
为本地图片资源的一个注册站。它的代码很简单:
// 代码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
:
// 代码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
中:
// 代码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
的初始化中,传入了三个参数,分别是「网络服务器地址」,「本地图片路径」,「图片信息」。其中,前两个参数获取方式如下:
// 代码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 地址,是否是沙盒文件地址等:
// 代码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 地址或沙盒文件路径。
再看AssetSourceResolver
。 AssetSourceResolver
会将初始化时传入的三个参数进行整合,并加上图片分辨率等信息,返回一个包含完整图片资源路径的 JSON 格式的图片信息:
// 代码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
中:
// 代码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
会传到 RCTImageView
,RCTImageView
是 UIImageView
的子类:
// 代码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
的路径,自然取不到沙盒路径下的图片了。由此可见,弄懂源码好像才是解决问题最彻底的手段。