由于包名引发的惨案(安装 apk 闪退,拍照闪退,manifest》Provider》authorities导致的)

1,320 阅读7分钟

我们项目原本是这样的,在项目开始之初定的报名是 com.b.c ,然后为了让用户能成功从 1.0 升级到 2.0 ,在项目要开发完成以后改了包名 com.a.b,由于直接改整个项目目录结果并不简单,于是我们直接改了 app/build.gradle 下的 applicationId ,改成了最新的 com.a.b 。之前在编写程序内升级的时候,在 AndroidManifest.xml 中编写的 <provider> 是下面这样的:

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="com.b.c.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

在使用的过程中是这样的(部分代码):

Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".fileprovider", apk);

我们项目中包含有 react-native 代码,同时装了不少插件,其中一个插件 react-native-webviewAndroidManifest.xml 中也定义了 <provider> ,是这样的:

<provider
    android:name=".RNCWebViewFileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_provider_paths" />
</provider>

之前我们的升级一直都很完美,每一次都很成功;有一天我们领导决定抛弃 react-native ,全部改用 h5 ,于是我就负责把 react-native 相关的代码从项目中删除,删除的过程非常愉快与自然,删除成功以后我验证了删除部分的相关功能,发现一切正常。

很快项目迎来了更新,一切都那么理所当然,用户正常升级,删除的 react-native 并没有给项目带来问题,随着时间的推进,很快第二批功能开发完毕,即将迎来再一次的更新,我认为这次更新内容少,还加上测试也测试通过,应该没啥问题,但是坏消息在第二天早上发生了,大面积的升级失败,闪退率直线上升,于是我们根据现象尝试复现,发现这是必现的 bug

在这个时候我很高兴,但也很悲伤,高兴的是 bug 是百分之百复现,悲伤的是,由于我的原因让用户体验急剧下滑,我知道,目前要做的是用最快的速度修复 bug ,让更少的人“受伤”。

通过我的排查,发现是包名导致的,因为报错信息直指报错的那一行,信息提示:

Caused by: java.lang.IllegalArgumentException: Couldn't find meta-data for provider with authority com.a.b.fileprovider

于是我看了看 AndroidManifest.xml 文件,发现我们的 <provider>authorities 是写死的 com.b.c.fileprovider ,我知道出现问题的原因就是在这里,于是我就将配置改成下面这样:

<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

改完以后赶快打了一个补救包,上传了上去,我认为问题已经解决,但是我们没有找到原因,首先要定位的是什么代码导致的这个问题,为什么以前可以,于是开始查看提交记录和合并记录,最终定位到是因为 react-native 的删除导致的,但是又产生了一个问题,为什么我删除 react-native 会导致这个问题,等我还在纠结的时候,突然反馈 app 拍照功能不能使用,这个功能是我们 app 的核心功能,一下从原来的无伤大雅变成了遍体鳞伤,这下整个部门都在问什么原因,于是我赶快放下脑中的疑惑,开始去项目的茫茫大海中寻找答案,我知道答案就在那里,也就是跟包相关的,于是根据问题,我检查了跟包相关的代码,发现在拍照的地方由于要保存,代码(部分)如下:

private const val authorities = "com.b.a.fileprovider"
FileProvider.getUriForFile(requireContext(), authorities, file)

我知道是由于我之前把 AndroidManifest.xml 改了以后导致的。于是我就把相关的代码都检查了一遍,确定都跟包名想通了,我才打包给测试,测试完成以后才再一次上线。

这下问题都被我解决了,只不过脑袋里面仍然有很多疑惑,之前我从 react-native 开发的时候由于看原生代码比较困难,现在我觉得我能找到这个问题的最终答案,于是开始了我的寻找问题之旅。

首先回到刚才的问题,为啥删除 react-native 会对包名造成影响呢,于是我开始复原删除之前,通过递减删除的方式排查,看看到底是那一行删除导致的。

其实认真看到这里的小伙伴肯定知道,并不是 react-native 的问题,而是本身我们代码编写的有问题,所以准确的说是 react-native 的什么代码屏蔽了问题。其中 react-native 嵌入原生是根据集成到现有原生应用引入的。在使用排除法的过程中,发现在 app/build.gradle 中配置:

apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)

是这行代码导致的,于是我尝试看这个 native_modules.gradle 文件,首先我从构造函数看起,其实我不会 groovy 语言,只是我大致看了看发现跟 java 差不多,所以上面的代码大差不差能够看懂,先看构造:

ReactNativeModules(Logger logger, File root) {
    this.logger = logger
    this.root = root
    def (nativeModules, packageName) = this.getReactNativeConfig()
    this.reactNativeModules = nativeModules
    this.packageName = packageName
}

这里有 packageName ,于是我就想是不是因为执行这个 this.getReactNativeConfig() 修改了 packageName ,其实我一直不相信会修改包名,但是我不敢确定,毕竟我刚接触 android 不久。于是我就继续看这个函数的实现:

ArrayList<HashMap<String, String>> getReactNativeConfig() {
    if (this.reactNativeModules != null) return this.reactNativeModules
    ArrayList<HashMap<String, String>> reactNativeModules = new ArrayList<HashMap<String, String>>()
    def cliResolveScript = "console.log(require('react-native/cli').bin);"
    String[] nodeCommand = ["node", "-e", cliResolveScript]
    def cliPath = this.getCommandOutput(nodeCommand, this.root)
    String[] reactNativeConfigCommand = ["node", cliPath, "config"]
    def reactNativeConfigOutput = this.getCommandOutput(reactNativeConfigCommand, this.root)
    def json
    try {
      json = new JsonSlurper().parseText(reactNativeConfigOutput)
    } catch (Exception exception) {
      throw new Exception("Calling `${reactNativeConfigCommand}` finished with an exception. Error message: ${exception.toString()}. Output: ${reactNativeConfigOutput}");
    }
    def dependencies = json["dependencies"]
    def project = json["project"]["android"]
    if (project == null) {
      throw new Exception("React Native CLI failed to determine Android project configuration. This is likely due to misconfiguration. Config output:\n${json.toMapString()}")
    }
    dependencies.each { name, value ->
      def platformsConfig = value["platforms"];
      def androidConfig = platformsConfig["android"]
      if (androidConfig != null && androidConfig["sourceDir"] != null) {
        this.logger.info("${LOG_PREFIX}Automatically adding native module '${name}'")
        HashMap reactNativeModuleConfig = new HashMap<String, String>()
        reactNativeModuleConfig.put("name", name)
        reactNativeModuleConfig.put("nameCleansed", name.replaceAll('[~*!\'()]+', '_').replaceAll('^@([\\w-.]+)/', '$1_'))
        reactNativeModuleConfig.put("androidSourceDir", androidConfig["sourceDir"])
        reactNativeModuleConfig.put("packageInstance", androidConfig["packageInstance"])
        reactNativeModuleConfig.put("packageImportPath", androidConfig["packageImportPath"])
        this.logger.trace("${LOG_PREFIX}'${name}': ${reactNativeModuleConfig.toMapString()}")
        reactNativeModules.add(reactNativeModuleConfig)
      } else {
        this.logger.info("${LOG_PREFIX}Skipping native module '${name}'")
      }
    }
    return [reactNativeModules, json["project"]["android"]["packageName"]];
  }
}

发现这里实际上是从 nodejs 执行结果拿到的信息,而执行的 js 文件的位置在 rn项目/node_modules/react-native/node_modules/@react-native-community/cli/build/index.js 下,这里是具体执行的 js 文件,前面还有一个 js 文件,只不过没有代码,就是执行这里面的 run 方法:

async function run() {
  try {
    await setupAndRun();
  } catch (e) {
    handleError(e);
  }
}

接着看 setupAndRun() 函数:

async function setupAndRun() {
  if (process.argv.includes('config')) {
    _cliTools().logger.disable();
  }
  _cliTools().logger.setVerbose(process.argv.includes('--verbose')); // We only have a setup script for UNIX envs currently
  if (process.platform !== 'win32') {
    const scriptName = 'setup_env.sh';
    const absolutePath = _path().default.join(__dirname, '..', scriptName);
    try {
      _child_process().default.execFileSync(absolutePath, {
        stdio: 'pipe',
      });
    } catch (error) {
      _cliTools().logger.warn(
        `Failed to run environment setup script "${scriptName}"\n\n${_chalk().default.red(
          error,
        )}`,
      );
      _cliTools().logger.info(
        `React Native CLI will continue to run if your local environment matches what React Native expects. If it does fail, check out "${absolutePath}" and adjust your environment to match it.`,
      );
    }
  }
  for (const command of _commands.detachedCommands) {
    attachCommand(command);
  }
  try {
    const config = (0, _config.default)();
    _cliTools().logger.enable();
    for (const command of [..._commands.projectCommands, ...config.commands]) {
      attachCommand(command, config);
    }
  } catch (error) {
    if (error.message.includes("We couldn't find a package.json")) {
      _cliTools().logger.enable();
      _cliTools().logger.debug(error.message);
      _cliTools().logger.debug(
        'Failed to load configuration of your project. Only a subset of commands will be available.',
      );
    } else {
      throw new (_cliTools().CLIError)(
        'Failed to load configuration of your project.',
        error,
      );
    }
  }
  _commander().default.parse(process.argv);
  if (_commander().default.rawArgs.length === 2) {
    _commander().default.outputHelp();
  }
  if (
    _commander().default.args.length === 0 &&
    _commander().default.rawArgs.includes('--version')
  ) {
    console.log(pkgJson.version);
  }
}

经过我打印日志,最终发现是 _commander().default.parse(process.argv) 这行代码返回给 groovy 的,但是我发现这行代码也只是读取配置的,跟修改不相关,于是我就开始假设,有没有可能是 groovy 最终修改,只是从 js 拿到相关的信息,于是我就直接把拿到的值进行修改,也就是 native_modules.gradle 里面的 this.getReactNativeConfig 函数返回值,于是我做了修改了:

ArrayList<HashMap<String, String>> getReactNativeConfig() {
    if (this.reactNativeModules != null) return this.reactNativeModules
    ArrayList<HashMap<String, String>> reactNativeModules = new ArrayList<HashMap<String, String>>()
    def dependencies = new JsonSlurper().parseText('{"react-native-webview":{"root":"/Users/wujingyue/Works/yq-bss-tour-rn/node_modules/react-native-webview","name":"react-native-webview","platforms":{"ios":{"sourceDir":"/Users/wujingyue/Works/yq-bss-tour-rn/node_modules/react-native-webview/ios","folder":"/Users/wujingyue/Works/yq-bss-tour-rn/node_modules/react-native-webview","pbxprojPath":"/Users/wujingyue/Works/yq-bss-tour-rn/node_modules/react-native-webview/ios/RNCWebView.xcodeproj/project.pbxproj","podfile":null,"podspecPath":"/Users/wujingyue/Works/yq-bss-tour-rn/node_modules/react-native-webview/react-native-webview.podspec","projectPath":"/Users/wujingyue/Works/yq-bss-tour-rn/node_modules/react-native-webview/ios/RNCWebView.xcodeproj","projectName":"RNCWebView.xcodeproj","libraryFolder":"Libraries","sharedLibraries":[],"plist":[],"scriptPhases":[]},"android":{"sourceDir":"/Users/wujingyue/Works/yq-bss-tour-rn/node_modules/react-native-webview/android","folder":"/Users/wujingyue/Works/yq-bss-tour-rn/node_modules/react-native-webview","packageImportPath":"import com.reactnativecommunity.webview.RNCWebViewPackage;","packageInstance":"new RNCWebViewPackage()"}},"assets":[],"hooks":{},"params":[]}}')
dependencies.each { name, value ->
      def platformsConfig = value["platforms"];
      def androidConfig = platformsConfig["android"]
      if (androidConfig != null && androidConfig["sourceDir"] != null) {
        this.logger.info("${LOG_PREFIX}Automatically adding native module '${name}'")
        HashMap reactNativeModuleConfig = new HashMap<String, String>()
        reactNativeModuleConfig.put("name", name)
        reactNativeModuleConfig.put("nameCleansed", name.replaceAll('[~*!\'()]+', '_').replaceAll('^@([\\w-.]+)/', '$1_'))
        reactNativeModuleConfig.put("androidSourceDir", androidConfig["sourceDir"])
        reactNativeModuleConfig.put("packageInstance", androidConfig["packageInstance"])
        reactNativeModuleConfig.put("packageImportPath", androidConfig["packageImportPath"])
        this.logger.trace("${LOG_PREFIX}'${name}': ${reactNativeModuleConfig.toMapString()}")
        reactNativeModules.add(reactNativeModuleConfig)

      } else {
        this.logger.info("${LOG_PREFIX}Skipping native module '${name}'")
      }
    }
    // 这儿直接返回我想要的值 com.a.b
    return [reactNativeModules, "com.a.b"];
  }
}

其中 dependencies 变量的值远不止这些,很多个。首先我让 dependencies 的值是一个空值,也就是 new JsonSlurper().parseText('{}') ,然后我发现居然不行了,也就是升级闪退,之前是可以的;于是我根据这个现象提出假设,是由于这个字符串中的某一个插件导致的,于是我就根据这个假设开始把一个个插件放入其中进行测试,最后发现 react-native-webview ,你不知道的是 react-native-webview 是最后一个插件,我把前面所有的都测试了,真的是又喜又悲,终于我把范围进一步缩小了,接下来,我就开始对插件 react-native-webview 的代码进行检查。

我最喜欢的还是“注释法”,也就是经典的“排除法”,我首先把所有代码都注释掉,只剩下空壳,发现仍然可以正常安装,说明不是在代码上,然后我再对插件的 build.gradle 采用“注释法”,结果还是可以,说明不是在这里,这时我感觉到无力,但是这个时候我突然想到“山重水复疑无路,柳暗花明又一村”,于是我开始对整个插件的每个文件进行检查,然后一个文件出现在我眼前 AndroidManifest.xml ,我打开看了看,看到了这个插件也定义了 <provider> 。而且是正确的方式,于是我又提出假设来解释现象,如果 AndroidManifest.xml 最终采用的是插件 react-native-webview<provider> ,那么就能解释这个原因了,但这仅仅是假设,我得在实践中证明我的假设是正确的。

首先我尝试修改 react-native-webview 插件中的 AndroidManifest.xml 下的 authorities ,我首先修改成跟项目的相同,结果闪退,符合我得猜想,说明项目的确会进行合并,于是我开始翻阅文档进一步证明我的结论,首先我看了看 AndroidManifest.xml 配置相关的文档 ,我看到了下面这句描述,也就是代表 authorities 支持多个。

android:authorities
一个或多个 URI 授权方的列表,这些 URI 授权方用于标识内容提供程序提供的数据。列出多个授权方时,用分号将其名称分隔开来。为避免冲突,授权方名称应遵循 Java 样式的命名惯例(如com.example.provider.cartoonprovider)。通常,它是实现提供程序的ContentProvider子类的名称。
没有默认值。必须至少指定一个授权方。

第一次看到这个我没想到啥,只不过后面文档让我想到了这个,然后做了验证,最终找到了答案。首先是同事找到了合并多个清单文件这个,证实了 AndroidManifest.xml 会合并的假设,然后又看到了这个检查合并后的清单并查找冲突image.png 然后我去看了看我们的项目,发现了这个,并且我看了看合并后的内容,发现 react-native-webview 的在最后,也就是会替换项目中 authorities ,但是我仔细看了看这个文件,发现下面还有定义的 authorities ,也就是说,如果是覆盖是说不通的,因为后面的 authorities 就会导致报错,但是实际上并没有,于是我尝试修改使用的地方,把 FileProvider.getUriForFile(requireContext(), authorities, file) 中的第二个参数改成这些定义的,发现仍然能成功,也就是说我们定义的所有这些都会生效,于是我想到了上面的那句被我标记为红色的话,发现一切迷雾都解开了。

到这里可以说结束了,但我在想为啥会这样设计呢?我最后想到的答案是,对于那些插件来说,他并不知道别人项目中的 authorities 定义,那怎么保证插件可以到处使用呢,答案很显然,那就是多个生效,插件不需要知道项目中是怎样定义的,只需要使用自己插件中定义好的。