随机变量

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中的图片

上一篇文章讲到,无论是真机调试,还是模拟器调试,都是执行打包生成的 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中生成一段如下代码:

// 代码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),从注释可以看出,169react-native/Libraries/Image/AssetRegistry 的模块 ID。也就是这里调用了 AssetRegistryregisterAsset 方法。

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 地址或沙盒文件路径。

再看AssetSourceResolverAssetSourceResolver会将初始化时传入的三个参数进行整合,并加上图片分辨率等信息,返回一个包含完整图片资源路径的 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 会传到 RCTImageViewRCTImageViewUIImageView 的子类:

// 代码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 的路径,自然取不到沙盒路径下的图片了。由此可见,弄懂源码好像才是解决问题最彻底的手段。

#iOS #技术