React Native Android 图片资源问题

React Native作为facebook的大作,一直收到开发者的喜爱。facebook最初设想React Native独立 完成应用,但是我们开发者更多开发在已有应用基础上,引入React Native帮助我们完成部分功能的开发。

接下来我来描述下我在引入React Native后,遇到的有关图片资源管理问题。

React Native Bundle 打包

通过以下命令打包:

1
react-native bundle --entry-file index.android.js --platform android --dev false --bundle-output build/index.bundle --assets-dest build

输出为:

React Native在bundle后,会为开发者把资源整理到不同的drawable下。

使用Bundle和图片资源

React Native支持两种使用bundle和图片资源的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

private final ReactNativeHost mReactNativeHost
= new ReactNativeHost(this) {

@Override
protected List<ReactPackage> getPackages() {
return Arrays.<ReactPackage>asList(
new MainReactPackage()
);
}

@Nullable
@Override
protected String getBundleAssetName() {
return "****";
}

@Nullable
@Override
protected String getJSBundleFile() {
return "****";
}
}

getJSBundleFile指定内存和存储设备bundle的filePath;
getBundleAssetName指定assets中bundle的filePath;
其中优先使用getJSBundleFile。

可以看到我们只为React Native指定bundle文件路径,并没有指定资源路径。那么React Native 如何使用图片资源呢?

源码分析

分析图片资源访问问题,我们先找到node_modules/react-native/Libraries/Image/image.android.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

render: function() {
const source = resolveAssetSource(this.props.source);
const loadingIndicatorSource = resolveAssetSource(this.props.loadingIndicatorSource);

...

if (source && (source.uri || Array.isArray(source))) {
let style;
let sources;
if (source.uri) {
const {width, height} = source;
style = flattenStyle([{width, height}, styles.base, this.props.style]);
sources = [{uri: source.uri}];
} else {
style = flattenStyle([styles.base, this.props.style]);
sources = source;
}

const {onLoadStart, onLoad, onLoadEnd} = this.props;
const nativeProps = merge(this.props, {
style,
shouldNotifyLoadEvents: !!(onLoadStart || onLoad || onLoadEnd),
src: sources,
loadingIndicatorSrc: loadingIndicatorSource ? loadingIndicatorSource.uri : null,
});

...
}
return null;
}
});

可以看到render中通过resolveAssetSource对是source进行了处理。

继续去resolveAssetSource看看:

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
47

function getBundleSourcePath(): ?string {
if (_bundleSourcePath === undefined) {
const scriptURL = 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;
}

function setCustomSourceTransformer(
transformer: (resolver: AssetSourceResolver) => ResolvedAssetSource,

): void {

_customSourceTransformer = transformer;
}

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();
}

构造方法中创建了AssetSourceResolver,默认通过defaultAsset对asset进行处理,注意到:在构建AssetSourceResolver时,第二个参数getBundleSourcePath()中通过assets://和file://对bundle路径进行了区分处理,assets://是返回null。

继续去AssetSourceResolver中看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

isLoadedFromFileSystem(): boolean {
return !!this.bundlePath;
}

defaultAsset(): ResolvedAssetSource {
if (this.isLoadedFromServer()) {
return this.assetServerURL();
}

if (Platform.OS === 'android') {
return this.isLoadedFromFileSystem() ?
this.drawableFolderInBundle() :
this.resourceIdentifierWithoutScale();
} else {
return this.scaledAssetPathInBundle();
}
}

重点来啦,AssetSourceResolver是通过this.isLoadedFromFileSystem区分加载内存和存储设备或者res中drawable资源。在resolveAssetSource中,内置assets://时,this.isLoadedFromFileSystem为false,所以从res中加载图片资源。

分析总结

React Native图片资源加载机制,依赖bundle文件路径加载图片资源。

  • bundle文件内置assets时,加载res中的drawable资源,故内置bundle时,需要把bundle依赖的资源copy到res中;、
  • bundle文件在内存或者存储设备时,加载bundle文件同目录下的图片资源。

问题来啦

当我们内置bundle文件到assets中时,React Native要求把图片资源拷贝到res下才能访问到图片资源,以提升bundle文件访问图片资源的效率。但是这些资源在我们应用中只有bundle文件需要我们把bundle文件需要的图片资源和宿主应用的图片资源混在res中,不利于项目资源管理。

理想的情况是bundle文件和图片资源文件可以一起内置到assets中,那么我们怎么解决呢?

解决方案

在resolveAssetSource中,我们可以看到resolveAssetSource的构造方法中优先_customSourceTransformer对assets处理,不存在时,才使用resolver.defaultAsset()。看来我们需要自定义一个Transformer来搞定。

核心代码:

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
import {
AppRegistry,
Image,
NativeModules,
} from 'react-native'
import Root from './app/root'

const { setCustomSourceTransformer } = require('resolveAssetSource');
const PixelRatio = require('PixelRatio');
const AssetSourceResolver = require('AssetSourceResolver');
const assetPathUtils = require('./node_modules/react-native/local-cli/bundle/assetPathUtils');

function getAssetPathInDrawableFolder(asset) {
const scale = AssetSourceResolver.pickScale(asset.scales, PixelRatio.get());
const drawbleFolder = assetPathUtils.getAndroidDrawableFolderName(asset, scale);
const fileName = assetPathUtils.getAndroidResourceIdentifier(asset);
return `${drawbleFolder}/${fileName}.${asset.type}`;
}

setCustomSourceTransformer((resolver) => {
const { SourceCode } = NativeModules;
let bundlePath = SourceCode.scriptURL;
if (bundlePath.startsWith('assets://')) {
bundlePath = bundlePath.substring(9, bundlePath.lastIndexOf('/') + 1);
const rootURL = `asset:///${bundlePath}`;
const asset = resolver.asset;
return resolver.fromSource(
rootURL + getAssetPathInDrawableFolder(asset)
);
}
return resolver.defaultAsset();
})

AppRegistry.registerComponent('Root', () => Root)