React Native包体积优化之图片优化

浦修文
2023-12-01

简介

由于随着项目业务的增加,导致包越来越大,大概有10M,每次发热更下载都比较久,而且会消耗较多的下载资源,在分析包内容的时候,发现图片占据了约80%的体积,因此提出优化图片的方案。

分析包内容

首先我们先看下包内容构成,以安卓包为例,如下所示

|-- dist 10MB
   |-- drawable-hdpi 8K
   |-- drawable-mdpi 8.2MB 
   |-- drawable-xhdpi 8K
   |-- drawable-xxhdpi 8K
   |-- index.android.bundle 1.8MB

从上图可以看出,图片跟bundle文件大概各占据了80%,优化掉了图片,可以减少80%的体积,相当可观的优化,当然bundle文件也是大头,但是这不是本篇文章要介绍的。

解决方案

压缩图片

我们用tinypng 进行批量压缩drawable-mdpi(共678张)下的所有图片,压缩完重新打包,结果如下

|—— dist  6.5MB

优化体积为 (10 - 6.5) = 3.5MB,可以看到图片体积减少了一部分,但包还是挺大的。

本地图片转成OSS图片

另外一种思路为将本地图片上传到OSS,然后本地图片替换为网络图片。这样我们就能去掉所有图片的体积, 大约有8M。显然这是一个巨大的改进效果。

而且图片是有缓存的,也就是图片缓存在本地后,app就不会在发起请求,那么就不会消耗OSS资源。

下文会详细介绍如何将本地图片转成OSS图片。

实现思路

第一步 上传本地图片

我们需要将本地图片上传至阿里云OSS,假设我们现在有以下目录下的几张图片:

|-- src
    |-- images
        |-- a.png
        |-- b.png
    |-- pages
        |-- images
            |-- c.png
        

考虑到图片有可能重复的可能,文件名取文件md5值。上传成功后记录文件映射表,映射表为json格式,如下所示

{
    "src/images/a.png": "4a8da6930cae6e4cae54d7bae3498fbc.png",
    "src/images/b.png": "d21c595c585f11d8bc24bdc5dc0c9b18.png",
    "src/pages/images/c.png": "674303925428601900ed2e58fdc7cbb6.png"
}

表key为图片的路径,值为md5值,假设阿里云地址为https://my.oss-cn-beijing.aliyuncs.com (下文会多次用到这个地址)。

我们随便挑md5表中的一个图片,如src/images/a.png,那么这张图对应的OSS地址如下

https://my.oss-cn-beijing.aliyuncs.com/4a8da6930cae6e4cae54d7bae3498fbc.png

第二步 本地图片替换成OSS图片

将本地图片代码,替换成引用OSS图片。比如有如下代码

<Image src={require('/src/images/a.png')}/>

然后替换成第一步md5表中的值

<Image src={{uri: "https://my.oss-cn-beijing.aliyuncs.com/4a8da6930cae6e4cae54d7bae3498fbc.png"}}/>

这一步需要用到的技术有Babel插件及AST抽象树。

AST抽象语法树介绍

首先我们的源代码编译成可执行代码,并不是一步生成的。而是源代码先解析成AST抽象语法树,然后再从AST抽象语法树转换成可执行代码。我们先看一下AST语法树长得什么样。

已知源代码如下:

let a = require('/src/images/a.png');
let b = {uri: "https://my.oss-cn-beijing.aliyuncs.com/4a8da6930cae6e4cae54d7bae3498fbc.png" }

会解析成以下语法树 

{
  "type": "File",
  "program": {
    "type": "Program",
    "body": [
      {
        "type": "VariableDeclaration",
        "declarations": [
          {
            "id": {
              "type": "Identifier",
              "name": "a"
            },
            "init": {
              "type": "CallExpression",
              "callee": {
                "type": "Identifier",
                "loc": {
                  "identifierName": "require"
                },
                "name": "require"
              },
              "arguments": [
                {
                  "type": "StringLiteral",
                  "value": "/src/images/ic_cp.png"
                }
              ]
            }
          }
        ]
      },
      {
        "type": "VariableDeclaration",
        "declarations": [
          {
            "id": {
              "type": "Identifier",
              "name": "b"
            },
            "init": {
              "type": "ObjectExpression",
              "properties": [
                {
                  "type": "ObjectProperty",
                  "key": {
                    "type": "Identifier",
                    "name": "uri"
                  },
                  "value": {
                    "type": "StringLiteral",
                    "value": "https://my.oss-cn-beijing.aliyuncs.com/4a8da6930cae6e4cae54d7bae3498fbc.png"
                  }
                }
              ]
            }
          }
        ]
      }
    ]
  }
}

语法树相对简单,就不逐行介绍了,可以使用在线AST语法树对应,从上我们可以看出AST语法树是一个个节点的树,我们可以替换节点以实现代码的替换。

实现细节

第一步:上传图片

遍历src下的所有图片,然后上传至OSS( 如何上传OSS,参考上传本地文件),代码如下

const klaw = require('klaw');
const fs = require("fs");
const path = require("path");
let OSS = require('ali-oss');
const crypto = require('crypto');

// 记录
let record = {};
// 需要上传的图片
let uploadItems = [];
// 上传下标,按下标逐一上传
let uploadIndex = 0;


let client = new OSS({
    // yourRegion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
    region: 'oss-cn-beijing',
    // 阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维,请登录RAM控制台创建RAM用户。
    accessKeyId: '你的keyId',
    accessKeySecret: '你的secret',
    // 填写Bucket名称。
    bucket: '你的bucket'
});

//遍历所有图片
klaw(path.resolve(process.cwd(), "./src"))
    .on('data', (item) => {
        if (/\.(jpg|jpeg|png)$/.test(item.path)) {
            const filePath = item.path;
            uploadItems.push(filePath);
        }
    })
    .on('end', async () => {
        if (uploadItems.length > 0) {
            uploadItem();
        }
    })

/**
 * 上传单个文件
 */
async function uploadItem() {

    const filePath = uploadItems[uploadIndex];
    let fileExtension = filePath.substring(filePath.lastIndexOf('.'))
    // fileKey = /src/images/a.png
    const fileKey = filePath.replace(process.cwd(), "");
    const buffer = fs.readFileSync(filePath);
    const hash = crypto.createHash('md5');
    hash.update(buffer);
    const md5 = hash.digest('hex');
    // recordValue = 4a8da6930cae6e4cae54d7bae3498fbc.png
    const recordValue = md5 + fileExtension;

    // 填写OSS文件完整路径和本地文件的完整路径。OSS文件完整路径中不能包含Bucket名称。
    // 如果本地文件的完整路径中未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件。
    const result = await client.put(recordValue, path.normalize(filePath));

    if(result && result.res && result.res.statusCode == 200){
        //  "/src/images/a.png": "4a8da6930cae6e4cae54d7bae3498fbc.png",
        record[fileKey] = recordValue;
    }

    uploadIndex++;
    if (uploadIndex < uploadItems.length) {
        uploadItem();
    } else {
        const uploadRecordPath =  path.resolve(process.cwd(), "./upload-image-record.json")
        fs.writeFileSync(uploadRecordPath, JSON.stringify(record, null, "\t"));
    }

}

 上传成功后,会生成上传记录文件upload-image-record.json,如下所示

{
    "src/images/a.png": "4a8da6930cae6e4cae54d7bae3498fbc.png",
    "src/images/b.png": "d21c595c585f11d8bc24bdc5dc0c9b18.png",
    "src/pages/images/c.png": "674303925428601900ed2e58fdc7cbb6.png"
}

第二步:替换本地文件为OSS图片

这一步实现将本地图片替换成OSS图片,要用到上一步生成的记录表upload-image-record.json,代码如下所示:

const t = require('@babel/types');
const p = require('path');

module.exports = function () {

  return {
    visitor: {

      CallExpression(path, ref = { opts: {} }) {
        const node = path.node;
        if (node.callee.type === 'Identifier' && node.callee.name === 'require') {
          const value = node.arguments[0].value;
          if (typeof (value) === 'string' && /\.(jpg|jpeg|png)$/.test(value)) {
            /**
             * 以假设存在项目,/Users/zhangsan/rn-app/index.js代码为例,/Users/zhangsan/rn-app为项目在我电脑的绝对地址
              class HelloWorld extends React.Component {
                render() {
                  return (
                    <View style={styles.container}>
                      <Image source={require('./src/images/a.png')}/>
                    </View>
                  );
                }
              }
             */
            // upload-image-record.json 即上一步产生的md5表
            const uploadRecordPath = p.resolve(process.cwd(), "./upload-image-record.json")
            // filename = /Users/zhangsan/rn-app/index.js
            let filename = path.hub.file.opts.filename;
            // fileDir = /Users/zhangsan/rn-app
            let fileDir = filename.substring(0, filename.lastIndexOf("/") + 1);
            // imagePath = /Users/zhangsan/rn-app/src/images/ic_bjx.png
            let imagePath = pathP.resolve(fileDir, value);
            // rootPath = /Users/zhangsan/rn-app
            let rootPath = path.hub.file.opts.root;
            // imagePath = /src/images/a.png
            imagePath = imagePath.replace(rootPath, "");
            // foundPath = 4a8da6930cae6e4cae54d7bae3498fbc.png
            let foundPath = require(uploadRecordPath)[imagePath];
            const uriValue = "https://my.oss-cn-beijing.aliyuncs.com/" + foundPath;
            const objectExpression = t.objectExpression([
              t.objectProperty(
                t.identifier('uri'),
                t.stringLiteral(uriValue)
              )
            ]);
            // 替换节点,require('/src/images/a.png') 替换成
            // {uri: https://my.oss-cn-beijing.aliyuncs.com/4a8da6930cae6e4cae54d7bae3498fbc.png}
            path.replaceWith(objectExpression);
          }
        }
      }

    }
  }
}

至此图片替换的操作就完成了,再看下包大小

包大小
优化前10MB
优化后2MB

至此已完成体积优化,当然有些细节没处理,可以参考源码

 类似资料: