diff --git a/packages/cli-plugin-metro/src/commands/bundle/assetCatalogIOS.ts b/packages/cli-plugin-metro/src/commands/bundle/assetCatalogIOS.ts new file mode 100644 index 000000000..a90be8faa --- /dev/null +++ b/packages/cli-plugin-metro/src/commands/bundle/assetCatalogIOS.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import path from 'path'; +import fs from 'fs-extra'; +import assetPathUtils from './assetPathUtils'; +import {AssetData} from './buildBundle'; + +export function cleanAssetCatalog(catalogDir: string): void { + const files = fs + .readdirSync(catalogDir) + .filter((file) => file.endsWith('.imageset')); + for (const file of files) { + fs.removeSync(path.join(catalogDir, file)); + } +} + +type ImageSet = { + basePath: string; + files: {name: string; src: string; scale: number}[]; +}; + +export function getImageSet( + catalogDir: string, + asset: AssetData, + scales: readonly number[], +): ImageSet { + const fileName = assetPathUtils.getResourceIdentifier(asset); + return { + basePath: path.join(catalogDir, `${fileName}.imageset`), + files: scales.map((scale, idx) => { + const suffix = scale === 1 ? '' : `@${scale}x`; + return { + name: `${fileName + suffix}.${asset.type}`, + scale, + src: asset.files[idx], + }; + }), + }; +} + +export function isCatalogAsset(asset: AssetData): boolean { + return asset.type === 'png' || asset.type === 'jpg' || asset.type === 'jpeg'; +} + +export function writeImageSet(imageSet: ImageSet): void { + fs.mkdirsSync(imageSet.basePath); + + for (const file of imageSet.files) { + const dest = path.join(imageSet.basePath, file.name); + fs.copyFileSync(file.src, dest); + } + + fs.writeJSONSync(path.join(imageSet.basePath, 'Contents.json'), { + images: imageSet.files.map((file) => ({ + filename: file.name, + idiom: 'universal', + scale: `${file.scale}x`, + })), + info: { + author: 'xcode', + version: 1, + }, + }); +} diff --git a/packages/cli-plugin-metro/src/commands/bundle/assetPathUtils.ts b/packages/cli-plugin-metro/src/commands/bundle/assetPathUtils.ts index a0098b79c..c26ddd1da 100644 --- a/packages/cli-plugin-metro/src/commands/bundle/assetPathUtils.ts +++ b/packages/cli-plugin-metro/src/commands/bundle/assetPathUtils.ts @@ -64,7 +64,7 @@ function getAndroidResourceFolderName( return androidFolder; } -function getAndroidResourceIdentifier(asset: PackagerAsset): string { +function getResourceIdentifier(asset: PackagerAsset): string { const folderPath = getBasePath(asset); return `${folderPath}/${asset.name}` .toLowerCase() @@ -84,6 +84,6 @@ function getBasePath(asset: PackagerAsset): string { export default { getAndroidAssetSuffix, getAndroidResourceFolderName, - getAndroidResourceIdentifier, + getResourceIdentifier, getBasePath, }; diff --git a/packages/cli-plugin-metro/src/commands/bundle/buildBundle.ts b/packages/cli-plugin-metro/src/commands/bundle/buildBundle.ts index af141fef8..ca424e45a 100644 --- a/packages/cli-plugin-metro/src/commands/bundle/buildBundle.ts +++ b/packages/cli-plugin-metro/src/commands/bundle/buildBundle.ts @@ -119,7 +119,12 @@ export async function buildBundleWithConfig( }); // When we're done saving bundle output and the assets, we're done. - return await saveAssets(outputAssets, args.platform, args.assetsDest); + return await saveAssets( + outputAssets, + args.platform, + args.assetsDest, + args.assetCatalogDest, + ); } finally { server.end(); } diff --git a/packages/cli-plugin-metro/src/commands/bundle/bundleCommandLineArgs.ts b/packages/cli-plugin-metro/src/commands/bundle/bundleCommandLineArgs.ts index 706dbfbd2..c63a90eb2 100644 --- a/packages/cli-plugin-metro/src/commands/bundle/bundleCommandLineArgs.ts +++ b/packages/cli-plugin-metro/src/commands/bundle/bundleCommandLineArgs.ts @@ -10,6 +10,7 @@ import path from 'path'; export interface CommandLineArgs { assetsDest?: string; + assetCatalogDest?: string; entryFile: string; resetCache: boolean; resetGlobalCache: boolean; @@ -102,6 +103,10 @@ export default [ description: 'Experimental, transform JS for a specific JS engine. Currently supported: hermes, hermes-canary, default', }, + { + name: '--asset-catalog-dest [string]', + description: 'Path where to create an iOS Asset Catalog for images', + }, { name: '--reset-cache', description: 'Removes cached files', diff --git a/packages/cli-plugin-metro/src/commands/bundle/getAssetDestPathAndroid.ts b/packages/cli-plugin-metro/src/commands/bundle/getAssetDestPathAndroid.ts index 1c3e01326..54ce337da 100644 --- a/packages/cli-plugin-metro/src/commands/bundle/getAssetDestPathAndroid.ts +++ b/packages/cli-plugin-metro/src/commands/bundle/getAssetDestPathAndroid.ts @@ -14,7 +14,7 @@ function getAssetDestPathAndroid(asset: PackagerAsset, scale: number): string { asset, scale, ); - const fileName = assetPathUtils.getAndroidResourceIdentifier(asset); + const fileName = assetPathUtils.getResourceIdentifier(asset); return path.join(androidFolder, `${fileName}.${asset.type}`); } diff --git a/packages/cli-plugin-metro/src/commands/bundle/saveAssets.ts b/packages/cli-plugin-metro/src/commands/bundle/saveAssets.ts index f37551de7..800e5762d 100644 --- a/packages/cli-plugin-metro/src/commands/bundle/saveAssets.ts +++ b/packages/cli-plugin-metro/src/commands/bundle/saveAssets.ts @@ -6,14 +6,19 @@ * */ -import path from 'path'; +import {logger} from '@react-native-community/cli-tools'; import fs from 'fs'; - +import path from 'path'; +import { + cleanAssetCatalog, + getImageSet, + isCatalogAsset, + writeImageSet, +} from './assetCatalogIOS'; +import {AssetData} from './buildBundle'; import filterPlatformAssetScales from './filterPlatformAssetScales'; import getAssetDestPathAndroid from './getAssetDestPathAndroid'; import getAssetDestPathIOS from './getAssetDestPathIOS'; -import {logger} from '@react-native-community/cli-tools'; -import type {AssetData} from './buildBundle'; interface CopiedFiles { [src: string]: string; @@ -23,20 +28,23 @@ function saveAssets( assets: AssetData[], platform: string, assetsDest: string | undefined, + assetCatalogDest: string | undefined, ) { if (!assetsDest) { logger.warn('Assets destination folder is not set, skipping...'); - return Promise.resolve(); + return; } + const filesToCopy: CopiedFiles = Object.create(null); // Map src -> dest + const getAssetDestPath = platform === 'android' ? getAssetDestPathAndroid : getAssetDestPathIOS; - const filesToCopy: CopiedFiles = Object.create(null); // Map src -> dest - assets.forEach((asset) => { + const addAssetToCopy = (asset: AssetData) => { const validScales = new Set( filterPlatformAssetScales(platform, asset.scales), ); + asset.scales.forEach((scale, idx) => { if (!validScales.has(scale)) { return; @@ -45,7 +53,37 @@ function saveAssets( const dest = path.join(assetsDest, getAssetDestPath(asset, scale)); filesToCopy[src] = dest; }); - }); + }; + + if (platform === 'ios' && assetCatalogDest != null) { + // Use iOS Asset Catalog for images. This will allow Apple app thinning to + // remove unused scales from the optimized bundle. + const catalogDir = path.join(assetCatalogDest, 'RNAssets.xcassets'); + if (!fs.existsSync(catalogDir)) { + logger.error( + `Could not find asset catalog 'RNAssets.xcassets' in ${assetCatalogDest}. Make sure to create it if it does not exist.`, + ); + return; + } + + logger.info('Adding images to asset catalog', catalogDir); + cleanAssetCatalog(catalogDir); + for (const asset of assets) { + if (isCatalogAsset(asset)) { + const imageSet = getImageSet( + catalogDir, + asset, + filterPlatformAssetScales(platform, asset.scales), + ); + writeImageSet(imageSet); + } else { + addAssetToCopy(asset); + } + } + logger.info('Done adding images to asset catalog'); + } else { + assets.forEach(addAssetToCopy); + } return copyAll(filesToCopy); } @@ -57,7 +95,7 @@ function copyAll(filesToCopy: CopiedFiles) { } logger.info(`Copying ${queue.length} asset files`); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const copyNext = (error?: NodeJS.ErrnoException) => { if (error) { reject(error);