React Native 代码阅读(十一):react-native link 的实现原理(Android)

2019-03-22

前言

react-native link 是一个我们在开发中经常用到的命令,它的作用是将第三方库中的 Native Modules 引入 Native 工程。在 Android 中,引入 Native 需要修改 Gradle、Java 代码,这是如何实现的呢?

另一点值得一提的是,React Native cli 已经从官方源中迁移出来了,成为一个社区维护的项目 react-native-cli。拥抱社区是 React Native 今年的一项重点工作,未来 React Native 生态会变得更好玩。

React Native Package Manager

介绍

React Native 的第三方库虽然使用 npm 分发并使用 npm/yarn 进行安装,但是对于带有 Native Modules 的模块细心的人可能会注意到,需要使用 react-native link 进行连接。

其实,React Native 是由自己的包管理器的,名为 React Native Package Manager(rnpm),link 操作是 rnpm 的一部分。

rnpm 曾经是一个独立项目,现在被整合进入了 React Native Core。

因此,想要弄清 link 的操作,我们首先要了解 React Native Package Manager。

背景

React Native 包与普通的前端包不同之处在于,React Native 包通常还包括 Android、iOS 原生代码。如何才能将原生代码成功连接到原生工程内呢?

rnpm 就是来解决这个问题的

package.json.rnpm

rnpm 对 package.json 进行了扩充。开发者在开发 rnpm 包时,在 package.json 通过添加 rnpm 字段来控制 rnpm 的行为。其中包含几个控制项。

commands

用于添加在连接前后执行的命令:

"rnpm": {
  "commands": {
    "prelink": "./bin/requestGAToken",
    "postlink": "./bin/linkingSucceeded"
  }
}

assets

用于指定需要连接进入工程的资源目录:

...
"rnpm": {
  "assets": ["Fonts"]
},
...

如何解析 rnpm 资源

package.json 的 rnpm 字段是如何被解析的呢?

让我们回到 react-native-cli 的代码,它位于 packages/cli/src/tools/getPackageConfiguration.js:

/**
 * Returns configuration of the CLI from `package.json`.
 */
export default function getPackageConfiguration(
  folder: string,
): PackageConfigurationT {
  return require(path.join(folder, './package.json')).rnpm || {};
}

其中:

  • require 像导入普通的 json 一样,将 package.json 导入
  • 之后访问它的 rnpm 字段

我们直接看它的返回类型 PackageConfigurationT:

/**
 * Configuration of the CLI as set by a package in the package.json
 */
export type PackageConfigurationT = {
  assets?: string[],
  commands?: {[name: string]: string},
  params?: InquirerPromptT[],
  android: AndroidConfigParamsT,
  ios: IOSConfigParamsT,
};

export type InquirerPromptT = any;

export type AndroidConfigParamsT = {};

这与文档上的说明是一致的。

第三方模块结构

在我们深入 link.js 代码之前,我们先来看下一个包含 Native Modules 的第三方库的结构。

第一个包含 Native Modules 的第三方库包含以下几个部分:

  • Android 原生代码:Android 平台的 Native Modules
  • iOS 原生代码:iOS 平台的 Native Modules
  • js 代码:对不同平台的 Native Modules 进行统一封装

需要注意的是,这种 React Native 第三方库使用 npm 进行分发,因此位于工程的 node_modules 目录下。

这对于习惯原生开发的同学来说是一个不同的地方。

下面我们以 react-native-intent-launcher 这个第三方库为例,它位于 node_modules/react-native-intent-launcher 下。目录结构如下:

node_modules/react-native-intent-launcher
├── IntentConstant.js
├── LICENSE
├── README.md
├── android
│   ├── android.iml
│   ├── build
│   ├── build.gradle
│   ├── gradle
│   ├── gradlew
│   ├── gradlew.bat
│   ├── local.properties
│   └── src
├── index.js
└── package.json

从中可以看出:

  • 它是一个前端工程,有 package.json 文件
  • android 目录是 Android 的 Native Modules,它被封装为一个 Android Module 工程
  • 因为这个项目是对 Android Intent 特性的封装,因此只有 Android 平台,没有 iOS 平台
  • 其中 android 目录是需要被 link 进主工程的 android 目录下的

浏览这个项目后发现:

  • package.json:是一个普通的前端工程,没有特别的与 link 相关的信息,里面有一个 main 字段,指向 index.js
  • index.js:它是前面提到的 js 代码入口,提供 JS 侧的 Native Module 接口
  • android/build.gradle:一个普通的 Android Library Modules,其中对 React Native 的依赖没有指定固定的版本号
  • 题外话:不太好的一点是发现它依赖了 appcompat-v7:23.0.1,这个依赖是多余的,最好去掉

通过对这个工程的分析,我们发现它并没有对 link 进行特殊的配置。

因此,解析工程、找出 Native Modules 的任务都落在 link 命令里面了。

有了这些背景知识,下面我们进入 link 内部一探究竟。

Android Platform 工具集

在本文中,我们的核心问题是在 Android 中,引入 Native 需要修改 Gradle、Java 代码,这是如何实现的呢?

这个问题的答案就在 packages/cli/src/commands/link/android 这个目录下。

它提供了一系列函数,用于注册原生模块、解注册原生模块、拷贝资源等待。

下面我们逐个来看:

注册原生模块

这个操作位于 packages/cli/src/commands/link/android/registerNativeModule.js 中:它的代码如下:

/**
 * 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.
 *
 * @format
 */

import applyPatch from './patches/applyPatch';
import makeStringsPatch from './patches/makeStringsPatch';
import makeSettingsPatch from './patches/makeSettingsPatch';
import makeBuildPatch from './patches/makeBuildPatch';
import makeImportPatch from './patches/makeImportPatch';
import makePackagePatch from './patches/makePackagePatch';

export default function registerNativeAndroidModule(
  name,
  androidConfig,
  params,
  projectConfig,
) {
  // 生成导入 Java 依赖的 patch
  const buildPatch = makeBuildPatch(name);

  // applyPatch 进行正则替换
  // 在 settings.gradle 中通过相对路径导入 node_modules 下的库的 android 目录
  applyPatch(
    projectConfig.settingsGradlePath,
    makeSettingsPatch(name, androidConfig, projectConfig),
  );

  // 在 app/build.gradle 下加入 Java Module 依赖
  applyPatch(projectConfig.buildGradlePath, buildPatch);
  // 向 strings.xml 中追加 string 资源
  applyPatch(projectConfig.stringsPath, makeStringsPatch(params, name));

  // 向注册 MainReactPackage 的地方插入第三方库的 Module
  applyPatch(
    projectConfig.mainFilePath,
    makePackagePatch(androidConfig.packageInstance, params, name),
  );

  // 向注册 MainReactPackage 的地方加上 Java 类的 import
  applyPatch(
    projectConfig.mainFilePath,
    makeImportPatch(androidConfig.packageImportPath),
  );
}

至此我们可以看出:

  • 核心原理是通过正则匹配进行内容替换
  • 即通过 Patch 的方法,在 Andorid 工程的各处打补丁

感悟

link 中最神秘的部分被揭开了,感觉没有想象中那么美丽……没想到是靠暴力正则替换打 patch 实现的,有些失望。但是它确实能工作,而且也工作地很稳定。

可能是 link 这个命令过于神奇(自动插入 gradle 依赖、自动插入 Java 代码),因此期望过大。也确实,这么一个神奇的功能总不能是通过人工智能来实现的吧 2333

这件事情给我的感悟是:

  • 基础的技术同样能做出很美妙的事情
  • 一个可靠运行的、实现不怎么高超的厉害功能远胜于一个实现高超但却跑不起来的功能
  • 有好点子的时候直奔目标而去,用直接的方法实现它,这就是最好的方法

这不就是 KISS 思想吗?!通过这次代码分析加深了我的认识。

资源拷贝

资源拷贝就要简单多了 packages/cli/src/commands/link/android/copyAssets.js:

/**
 * Copies each file from an array of assets provided to targetPath directory
 *
 * For now, the only types of files that are handled are:
 * - Fonts (otf, ttf) - copied to targetPath/fonts under original name
 */
export default function copyAssetsAndroid(
  files: Array<string>,
  project: {assetsPath: string},
) {
  const assets = groupFilesByType(files);

  logger.debug(`Assets path: ${project.assetsPath}`);
  (assets.font || []).forEach(asset => {
    const fontsDir = path.join(project.assetsPath, 'fonts');
    logger.debug(`Copying asset ${asset}`);
    // @todo: replace with fs.mkdirSync(path, {recursive}) + fs.copyFileSync
    // and get rid of fs-extra once we move to Node 10
    fs.copySync(asset, path.join(fontsDir, path.basename(asset)));
  });
}

从中可以看出,它现在只支持对字体资源的拷贝。

link 命令

入口

link 命令的入口在 packages/cli/src/commands/link/link.js。

我们先来看这个入口对外导出了什么:

export default {
  func: link,
  description: 'scope link command to certain platforms (comma-separated)',
  name: 'link [packageName]',
  options: [
    {
      command: '--platforms [list]',
      description:
        'If you want to link dependencies only for specific platforms',
      parse: (val: string) => val.toLowerCase().split(','),
    },
  ],
};

其中:

  • 包含有这个命令的使用说明
  • 负责干活的函数是 link

下面我们就来详细看 link 函数。

link 函数

link 函数的签名如下:

/**
 * Updates project and links all dependencies to it.
 *
 * @param args If optional argument [packageName] is provided,
 *             only that package is processed.
 */
function link([rawPackageName]: Array<string>, ctx: ContextT, opts: FlagsType) {

其中:

  • rawPackageName 需要连接的模块名称,可以不填

  • opts 配置选项

它将 link 操作拆分为以下几个步骤:

  1. 模块解析
  2. prelink
  3. linkDependency
  4. postlink
  5. linkAssets

原来还有资源文件也会一同链接进入主工程。

我们在前面已经分析过了模块解析,以及 Android Platform 工具集中的核心操作。

link 命令在这里只是进行了目录解析,和将这些过程串联起来,这些都是非核心的工作,因此不再赘述。

结论

在本文中,我们以 react-native link 命令为起点,通过深入挖掘,了解到原来 React Native 中还有 React Native Package Manager 项目,同时也分析了 react-native link 的实现原理,打开了它的神秘面纱。