Skip to content

Commit b15a964

Browse files
Use asset catalog for ios images (#1290)
1 parent 7ea7e93 commit b15a964

6 files changed

Lines changed: 131 additions & 13 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*/
8+
9+
import path from 'path';
10+
import fs from 'fs-extra';
11+
import assetPathUtils from './assetPathUtils';
12+
import {AssetData} from './buildBundle';
13+
14+
export function cleanAssetCatalog(catalogDir: string): void {
15+
const files = fs
16+
.readdirSync(catalogDir)
17+
.filter((file) => file.endsWith('.imageset'));
18+
for (const file of files) {
19+
fs.removeSync(path.join(catalogDir, file));
20+
}
21+
}
22+
23+
type ImageSet = {
24+
basePath: string;
25+
files: {name: string; src: string; scale: number}[];
26+
};
27+
28+
export function getImageSet(
29+
catalogDir: string,
30+
asset: AssetData,
31+
scales: readonly number[],
32+
): ImageSet {
33+
const fileName = assetPathUtils.getResourceIdentifier(asset);
34+
return {
35+
basePath: path.join(catalogDir, `${fileName}.imageset`),
36+
files: scales.map((scale, idx) => {
37+
const suffix = scale === 1 ? '' : `@${scale}x`;
38+
return {
39+
name: `${fileName + suffix}.${asset.type}`,
40+
scale,
41+
src: asset.files[idx],
42+
};
43+
}),
44+
};
45+
}
46+
47+
export function isCatalogAsset(asset: AssetData): boolean {
48+
return asset.type === 'png' || asset.type === 'jpg' || asset.type === 'jpeg';
49+
}
50+
51+
export function writeImageSet(imageSet: ImageSet): void {
52+
fs.mkdirsSync(imageSet.basePath);
53+
54+
for (const file of imageSet.files) {
55+
const dest = path.join(imageSet.basePath, file.name);
56+
fs.copyFileSync(file.src, dest);
57+
}
58+
59+
fs.writeJSONSync(path.join(imageSet.basePath, 'Contents.json'), {
60+
images: imageSet.files.map((file) => ({
61+
filename: file.name,
62+
idiom: 'universal',
63+
scale: `${file.scale}x`,
64+
})),
65+
info: {
66+
author: 'xcode',
67+
version: 1,
68+
},
69+
});
70+
}

packages/cli-plugin-metro/src/commands/bundle/assetPathUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function getAndroidResourceFolderName(
6464
return androidFolder;
6565
}
6666

67-
function getAndroidResourceIdentifier(asset: PackagerAsset): string {
67+
function getResourceIdentifier(asset: PackagerAsset): string {
6868
const folderPath = getBasePath(asset);
6969
return `${folderPath}/${asset.name}`
7070
.toLowerCase()
@@ -84,6 +84,6 @@ function getBasePath(asset: PackagerAsset): string {
8484
export default {
8585
getAndroidAssetSuffix,
8686
getAndroidResourceFolderName,
87-
getAndroidResourceIdentifier,
87+
getResourceIdentifier,
8888
getBasePath,
8989
};

packages/cli-plugin-metro/src/commands/bundle/buildBundle.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,12 @@ export async function buildBundleWithConfig(
119119
});
120120

121121
// When we're done saving bundle output and the assets, we're done.
122-
return await saveAssets(outputAssets, args.platform, args.assetsDest);
122+
return await saveAssets(
123+
outputAssets,
124+
args.platform,
125+
args.assetsDest,
126+
args.assetCatalogDest,
127+
);
123128
} finally {
124129
server.end();
125130
}

packages/cli-plugin-metro/src/commands/bundle/bundleCommandLineArgs.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import path from 'path';
1010

1111
export interface CommandLineArgs {
1212
assetsDest?: string;
13+
assetCatalogDest?: string;
1314
entryFile: string;
1415
resetCache: boolean;
1516
resetGlobalCache: boolean;
@@ -102,6 +103,10 @@ export default [
102103
description:
103104
'Experimental, transform JS for a specific JS engine. Currently supported: hermes, hermes-canary, default',
104105
},
106+
{
107+
name: '--asset-catalog-dest [string]',
108+
description: 'Path where to create an iOS Asset Catalog for images',
109+
},
105110
{
106111
name: '--reset-cache',
107112
description: 'Removes cached files',

packages/cli-plugin-metro/src/commands/bundle/getAssetDestPathAndroid.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function getAssetDestPathAndroid(asset: PackagerAsset, scale: number): string {
1414
asset,
1515
scale,
1616
);
17-
const fileName = assetPathUtils.getAndroidResourceIdentifier(asset);
17+
const fileName = assetPathUtils.getResourceIdentifier(asset);
1818
return path.join(androidFolder, `${fileName}.${asset.type}`);
1919
}
2020

packages/cli-plugin-metro/src/commands/bundle/saveAssets.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@
66
*
77
*/
88

9-
import path from 'path';
9+
import {logger} from '@react-native-community/cli-tools';
1010
import fs from 'fs';
11-
11+
import path from 'path';
12+
import {
13+
cleanAssetCatalog,
14+
getImageSet,
15+
isCatalogAsset,
16+
writeImageSet,
17+
} from './assetCatalogIOS';
18+
import {AssetData} from './buildBundle';
1219
import filterPlatformAssetScales from './filterPlatformAssetScales';
1320
import getAssetDestPathAndroid from './getAssetDestPathAndroid';
1421
import getAssetDestPathIOS from './getAssetDestPathIOS';
15-
import {logger} from '@react-native-community/cli-tools';
16-
import type {AssetData} from './buildBundle';
1722

1823
interface CopiedFiles {
1924
[src: string]: string;
@@ -23,20 +28,23 @@ function saveAssets(
2328
assets: AssetData[],
2429
platform: string,
2530
assetsDest: string | undefined,
31+
assetCatalogDest: string | undefined,
2632
) {
2733
if (!assetsDest) {
2834
logger.warn('Assets destination folder is not set, skipping...');
29-
return Promise.resolve();
35+
return;
3036
}
3137

38+
const filesToCopy: CopiedFiles = Object.create(null); // Map src -> dest
39+
3240
const getAssetDestPath =
3341
platform === 'android' ? getAssetDestPathAndroid : getAssetDestPathIOS;
3442

35-
const filesToCopy: CopiedFiles = Object.create(null); // Map src -> dest
36-
assets.forEach((asset) => {
43+
const addAssetToCopy = (asset: AssetData) => {
3744
const validScales = new Set(
3845
filterPlatformAssetScales(platform, asset.scales),
3946
);
47+
4048
asset.scales.forEach((scale, idx) => {
4149
if (!validScales.has(scale)) {
4250
return;
@@ -45,7 +53,37 @@ function saveAssets(
4553
const dest = path.join(assetsDest, getAssetDestPath(asset, scale));
4654
filesToCopy[src] = dest;
4755
});
48-
});
56+
};
57+
58+
if (platform === 'ios' && assetCatalogDest != null) {
59+
// Use iOS Asset Catalog for images. This will allow Apple app thinning to
60+
// remove unused scales from the optimized bundle.
61+
const catalogDir = path.join(assetCatalogDest, 'RNAssets.xcassets');
62+
if (!fs.existsSync(catalogDir)) {
63+
logger.error(
64+
`Could not find asset catalog 'RNAssets.xcassets' in ${assetCatalogDest}. Make sure to create it if it does not exist.`,
65+
);
66+
return;
67+
}
68+
69+
logger.info('Adding images to asset catalog', catalogDir);
70+
cleanAssetCatalog(catalogDir);
71+
for (const asset of assets) {
72+
if (isCatalogAsset(asset)) {
73+
const imageSet = getImageSet(
74+
catalogDir,
75+
asset,
76+
filterPlatformAssetScales(platform, asset.scales),
77+
);
78+
writeImageSet(imageSet);
79+
} else {
80+
addAssetToCopy(asset);
81+
}
82+
}
83+
logger.info('Done adding images to asset catalog');
84+
} else {
85+
assets.forEach(addAssetToCopy);
86+
}
4987

5088
return copyAll(filesToCopy);
5189
}
@@ -57,7 +95,7 @@ function copyAll(filesToCopy: CopiedFiles) {
5795
}
5896

5997
logger.info(`Copying ${queue.length} asset files`);
60-
return new Promise((resolve, reject) => {
98+
return new Promise<void>((resolve, reject) => {
6199
const copyNext = (error?: NodeJS.ErrnoException) => {
62100
if (error) {
63101
reject(error);

0 commit comments

Comments
 (0)