React Native 代码阅读(十):Image 组件代码实现详解(Android)

2019-03-11

前言

Image 组件是 React Native 中的最常用组件之一,在本文中我们来看它在 Android 下的实现原理。

对于 Image 的使用,可以参考官方文档

在 React Native 中使用方式很简单:

<Image source={require('./my-icon.png')} />

这是如何实现的呢?它是如何映射到 Android 的 ImageView 呢?

image.android.js

Image 组件定义在 image.android.js(Libraries/Image/Image.android.js)中。

在这个文件中定义了:

  • Image 组件
  • getSize() 获取图片大小
  • prefetch() 预获取图片,存入磁盘缓存供后续使用
  • queryCache() 传入 URL 获取缓存状态(磁盘、内存、没有缓存)

其中:

  • Image 组件对应于 Java 类 com.facebook.react.views.image.ReactImageManager
  • 几个方法对应于 Java 类 com/facebook/react/modules/image/ImageLoaderModule.java

下面我们来详细看 Image 组件,它是一个函数式组件,一开始对 Props 进行一系列的校验。我们着重看它的 return 逻辑:

return (
  <TextAncestor.Consumer>
    {hasTextAncestor =>
      hasTextAncestor ? (
        <TextInlineImageNativeComponent {...nativeProps} />
      ) : (
        <ImageViewNativeComponent {...nativeProps} />
      )
    }
  </TextAncestor.Consumer>
);

其中:包含一个判断逻辑,这里我们直接看 ImageViewNativeComponent。它位于:Libraries/Image/TextInlineImageNativeComponent.js:

'use strict';

const requireNativeComponent = require('requireNativeComponent');

const ImageViewNativeComponent = requireNativeComponent('RCTImageView');

module.exports = ImageViewNativeComponent;

ReactImageManager

上面代码中的 RCTImageView 对应于 Android Native 侧的 com.facebook.react.views.image.ReactImageManager。

ReactImageManager 有一个 createViewInstance,React Native 通过这个方法创建 ReactImageView 实例。

同时 ReactImageManager 还有一系列 setter 方法:

  • setSource
  • setBlurRadius
  • setLoadingIndicatorSource
  • setBorderColor
  • setOverlayColor
  • setBorderWidth
  • setBorderRadius
  • setResizeMode
  • ……

这些方法的参数都满足一定的模式:第一个参数为 ReactImageView 实例,第二个参数为属性的值。比如:

// In JS this is Image.props.source
@ReactProp(name = "src")
public void setSource(ReactImageView view, @Nullable ReadableArray sources) {
  view.setSource(sources);
}

每个 setter 方法都调用了 ReactImageView 的对应 setter 方法。

需要注意的一点是,ReactImageView 每次被 set 属性后,不会立刻更新。它内部有一个 mIsDirty 状态,ReactImageView 会将这个状态先置为 true,之后统一根据是否 dirty 统一更新。

ReactImageView 图片的展示

现在我们知道,React Native JS 侧的 Image 组件对应于 Android 的 ReactImageView。那么 ReactImageView 是如何把图片展示出来的呢?

前面我们说到 ReactImageView 采用 dirty-update 的机制。update 方法为 com.facebook.react.views.image.ReactImageView#maybeUpdateView。

它会首先判断是否 dirty,如果不 dirty 就直接退出方法:

if (!mIsDirty) {
  return;
}

经过一系列的参数设置,最终创建出 Fresco 的图片加载请求:

ImageRequestBuilder imageRequestBuilder = ImageRequestBuilder.newBuilderWithSource(mImageSource.getUri())
  .setPostprocessor(postprocessor)
  .setResizeOptions(resizeOptions)
  .setAutoRotateEnabled(true)
  .setProgressiveRenderingEnabled(mProgressiveRenderingEnabled);

ImageRequest imageRequest = ReactNetworkImageRequest.fromBuilderWithHeaders(imageRequestBuilder, mHeaders);

// This builder is reused
mDraweeControllerBuilder.reset();

mDraweeControllerBuilder
  .setAutoPlayAnimations(true)
  .setCallerContext(mCallerContext)
  .setOldController(getController())
  .setImageRequest(imageRequest);

setController(mDraweeControllerBuilder.build());
mIsDirty = false;

mDraweeControllerBuilder.reset();

其中:

  • ReactImageView 使用 Fresco 库进行图片加载
  • ReactImageView 的继承关系为
    • ReactImageView
      • GenericDraweeView
        • DraweeView
          • ImageView
  • 其中 Drawee 是 Fresco 库内部的一个模块

这里我们不深入 Fresco 库内部的实现原理。在上面的代码中,我们知道了通过上述代码,调用 Fresco 库进行了图片加载。

同时在 maybeUpdateView 方法中,还有很多对 Prop 细节的解析,比如圆角、背景、边框、Scale 方式等等的解析与配置。这些操作都是基于 Fresco 库进行的二次封装,感兴趣的同学可以再进一步看一下。

Source 属性的处理

在我们的 JavaScript 代码中:

<Image source={require('./my-icon.png')} />

指定了 source 属性,指定图片的位置。 source 属性是 Image 组件最终的属性,它是如何解析的呢?

从 Image 使用文档中我们知道,React Native 的 Image 组件支持多种数据来源:JS 相对路径导入、Hybrid 资源引用、网络数据导入、Uri Data 都是可以的。

这么多种类的来源如何进行处理呢?

思考这个问题的思路如下:

  • 首先 source Prop 是 Image 的属性,因此它的过程是
  • 先调用 com.facebook.react.views.image.ReactImageManager#createViewInstance 创建实例
  • 再调用 com.facebook.react.views.image.ReactImageManager#setSource 设置属性

因此就找到了 com.facebook.react.views.image.ReactImageManager#setSource 方法:

// In JS this is Image.props.source
@ReactProp(name = "src")
public void setSource(ReactImageView view, @Nullable ReadableArray sources) {
  view.setSource(sources);
}

之后来到 com.facebook.react.views.image.ReactImageView#setSource 方法,它的核心实现如下:

首先获取 source Props:

String uri = source.getString("uri");
ImageSource imageSource = new ImageSource(getContext(), uri);

其中:

  • uri 就是我们指定的 source 值 ./my-icon.png

如何判定 source 是来源自网络、Hybrid 还是 Uri Data 呢?这就需要借助于 ImageSource 这个类,它专门用于解析来源。

ImageSource 解析来源的核心代码如下:

private Uri computeUri(Context context) {
  try {
    Uri uri = Uri.parse(mSource);
    // Verify scheme is set, so that relative uri (used by static resources) are not handled.
    return uri.getScheme() == null ? computeLocalUri(context) : uri;
  } catch (Exception e) {
    return computeLocalUri(context);
  }
}

private Uri computeLocalUri(Context context) {
  isResource = true;
  return ResourceDrawableIdHelper.getInstance().getResourceDrawableUri(context, mSource);
}

其中:

  • 通过计算之后,返回的则是标准的 Android Uri
  • 这个 Uri 可以 Fresco 图片库识别进行资源加载

Image 的四个回调方法

Image 组件有四个回调方法:

  • onLoadStart
  • onLoad
  • onLoadEnd
  • onError

它们是从哪里发出,如何在 JS 层接到的呢?

答案在 ReactImageManager 的 getExportedCustomDirectEventTypeConstants 方法中:

@Override
public @Nullable Map getExportedCustomDirectEventTypeConstants() {
  return MapBuilder.of(
    ImageLoadEvent.eventNameForType(ImageLoadEvent.ON_LOAD_START),
      MapBuilder.of("registrationName", "onLoadStart"),
    ImageLoadEvent.eventNameForType(ImageLoadEvent.ON_LOAD),
      MapBuilder.of("registrationName", "onLoad"),
    ImageLoadEvent.eventNameForType(ImageLoadEvent.ON_ERROR),
      MapBuilder.of("registrationName", "onError"),
    ImageLoadEvent.eventNameForType(ImageLoadEvent.ON_LOAD_END),
      MapBuilder.of("registrationName", "onLoadEnd"));
}

这个方法中创建了一个 Map,对 ImageLoadEvent 枚举和名称进行了映射。

这几个回调对应于 com.facebook.react.views.image.ImageLoadEvent 中的几个常量:

// Currently ON_PROGRESS is not implemented, these can be added
// easily once support exists in fresco.
public static final int ON_ERROR = 1;
public static final int ON_LOAD = 2;
public static final int ON_LOAD_END = 3;
public static final int ON_LOAD_START = 4;
public static final int ON_PROGRESS = 5;

这些事件是在哪里发出的呢?答案是在 ReactImageView 的 setShouldNotifyLoadEvents 方法中。

这个方法的定义如下:

@ReactProp(name = "shouldNotifyLoadEvents")
public void setLoadHandlersRegistered(
            ReactImageView view, 
            boolean shouldNotifyLoadEvents) {
  view.setShouldNotifyLoadEvents(shouldNotifyLoadEvents);
}

其中,可以看出:

  • 这四个回调必须指定 shouldNotifyLoadEvents prop 才会触发

下面我们看 setShouldNotifyLoadEvents 的内部实现,核心代码如下:

final EventDispatcher mEventDispatcher = ((ReactContext) getContext()).getNativeModule(UIManagerModule.class).getEventDispatcher();

mControllerListener = new BaseControllerListener<ImageInfo>() {
    @Override
    public void onSubmit(String id, Object callerContext) {
      mEventDispatcher.dispatchEvent(
        new ImageLoadEvent(getId(), ImageLoadEvent.ON_LOAD_START));
    }

    @Override
    public void onFinalImageSet(
      String id,
      @Nullable final ImageInfo imageInfo,
      @Nullable Animatable animatable) {
      if (imageInfo != null) {
        mEventDispatcher.dispatchEvent(
          new ImageLoadEvent(getId(), ImageLoadEvent.ON_LOAD,
            mImageSource.getSource(), imageInfo.getWidth(), imageInfo.getHeight()));
        mEventDispatcher.dispatchEvent(
          new ImageLoadEvent(getId(), ImageLoadEvent.ON_LOAD_END));
      }
    }

    @Override
    public void onFailure(String id, Throwable throwable) {
      mEventDispatcher.dispatchEvent(
        new ImageLoadEvent(getId(), ImageLoadEvent.ON_ERROR,
          true, throwable.getMessage()));
    }
  };
}

其中:

  • 我们从 UIManagerModule 中获取到 EventDispatcher
  • 通过 EventDispatcher,我们能够从 Native 向 JS 发送 UI 事件
  • 这里的这几个回调就是通过 UI 事件在 JS 层触发的

对于 EventDispatcher 的内部机制,限于篇幅我们暂不在本文中展开,点到为止。

Image.getSize

在查看 React Native 的 Bug 列表时,发现有一个说 Image.getSize 方法在 Android 失效了。我想修复这个 Bug,因此需要研究这个方法的实现原理。

Issue 详情参见这里。需要注意的是,这个 Bug 只在加载来自 Drawable 的图片时会出现,加载其他来源的图片没有问题。

Image.getSize

Image.getSize 方法位于 js 侧 Image 组件,它的代码在 Libraries/Image/Image.android.js。

其代码如下:

function getSize(
  url: string,
  success: (width: number, height: number) => void,
  failure?: (error: any) => void,
) {
  return ImageLoader.getSize(url)
    .then(function(sizes) {
      success(sizes.width, sizes.height);
    })
    .catch(
      failure ||
        function() {
          console.warn('Failed to get size for image: ' + url);
        },
    );
}

其中:

  • 由于 bug,这段代码会进入 catch 部分
  • 最终会在界面上报错 Failed to get size for image

ImageLoaderModule.getSize

在上面的代码中,ImageLoader 是一个 Native Module,被映射到了 ImageLoaderModule 类,位于 com/facebook/react/modules/image/ImageLoaderModule.java。

下面我们来看这个方法的实现。这个类的完整代码参见这里

首先方法签名:

@ReactMethod
public void getSize(
    final String uriString,
    final Promise promise) {

其中:

  • uriString 是我们要加载的 Drawable 图片
  • promise 对应于 js 侧的 Promise

例如,drawable 中有一个图片名为 tv_banner,在 js 侧会调用 Image.getSize('tv_banner'),传到上面 Java 侧的方法时,uriString 的值也是 tv_banner

下面我们来看 getSize 方法内部:

Uri uri = Uri.parse(uriString);

首先,Uri.parse 会将 uriString 转为 Android 中的 Uri。

由于这里的信息非常少,解析得到的 Uri 里面许多字段都为空:

这里可能是导致问题的原因,我们先记住这里接着往下看。

ImageRequest 属于 Facebook 的 imagepipeline 库。这是 Fresco 项目的一个子项目。

ImagePipeline

好在社区中有一篇官方文档直接使用Image Pipeline,看起来跟我们的场景很像,快速学习一下。文档中的内容这里就不赘述了。

我们回到 getSize 方法。

首先是创建一个 ImageRequest:

ImageRequest request = ImageRequestBuilder.newBuilderWithSource(uri).build();

之后是获取数据源:

DataSource<CloseableReference<CloseableImage>> dataSource =
    Fresco.getImagePipeline().fetchDecodedImage(request, mCallerContext);

之后是订阅数据:

DataSubscriber<CloseableReference<CloseableImage>> dataSubscriber =
  new BaseDataSubscriber<CloseableReference<CloseableImage>>() {
    @Override
    protected void onNewResultImpl(
        DataSource<CloseableReference<CloseableImage>> dataSource) {
      if (!dataSource.isFinished()) {
        return;
      }
      CloseableReference<CloseableImage> ref = dataSource.getResult();
      if (ref != null) {
        try {
          CloseableImage image = ref.get();

          WritableMap sizes = Arguments.createMap();
          sizes.putInt("width", image.getWidth());
          sizes.putInt("height", image.getHeight());

          promise.resolve(sizes);
        } catch (Exception e) {
          promise.reject(ERROR_GET_SIZE_FAILURE, e);
        } finally {
          CloseableReference.closeSafely(ref);
        }
      } else {
        promise.reject(ERROR_GET_SIZE_FAILURE);
      }
    }

    @Override
    protected void onFailureImpl(DataSource<CloseableReference<CloseableImage>> dataSource) {
      promise.reject(ERROR_GET_SIZE_FAILURE, dataSource.getFailureCause());
    }
  };

最后是实际执行订阅操作:

dataSource.subscribe(dataSubscriber, CallerThreadExecutor.getInstance());

在上面的大代码块中,有多处异常逻辑,都会导致 promise.reject,最终是哪个导致的呢?

我们都下断点来 Debug 一下,发现最终落入了 onFailureImpl。

fetchDecodedImage

onFailureImpl 是在哪个步骤出错的呢?这个要看图片实际加载的过程,这个过程位于 com.facebook.imagepipeline.core.ImagePipeline#fetchDecodedImage(com.facebook.imagepipeline.request.ImageRequest, java.lang.Object, com.facebook.imagepipeline.request.ImageRequest.RequestLevel, com.facebook.imagepipeline.listener.RequestListener) 方法。

发现异常是从这一句抛出来的:

Producer<CloseableReference<CloseableImage>> producerSequence =
    mProducerSequenceFactory.getDecodedImageProducerSequence(imageRequest);

异常原因是什么呢?

java.lang.IllegalArgumentException: Unsupported uri scheme! Uri is: tv_banner

从中可以看出,问题确实出在这个 Uri 身上。

Android drawable URI

Android 的 Drawable uri 应该是什么样的呢?

它的格式可以参考下面文章:

结论

在本文中,我们深入分析了 Image 组件的实现原理。包括:

  • 图片展示
  • 回调
  • source 处理
  • Image.getSize

同时 Image.getSize 是以跟踪问题的形式进行调试,并最终找到了问题所在。