由于包名引发的惨案(安装 apk 闪退,拍照闪退,manifest》Provider》authorities导致的)
我们项目原本是这样的,在项目开始之初定的报名是 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-webview
的 AndroidManifest.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
会合并的假设,然后又看到了这个检查合并后的清单并查找冲突:
然后我去看了看我们的项目,发现了这个,并且我看了看合并后的内容,发现 react-native-webview
的在最后,也就是会替换项目中 authorities
,但是我仔细看了看这个文件,发现下面还有定义的 authorities
,也就是说,如果是覆盖是说不通的,因为后面的 authorities
就会导致报错,但是实际上并没有,于是我尝试修改使用的地方,把 FileProvider.getUriForFile(requireContext(), authorities, file)
中的第二个参数改成这些定义的,发现仍然能成功,也就是说我们定义的所有这些都会生效,于是我想到了上面的那句被我标记为红色的话,发现一切迷雾都解开了。
到这里可以说结束了,但我在想为啥会这样设计呢?我最后想到的答案是,对于那些插件来说,他并不知道别人项目中的 authorities
定义,那怎么保证插件可以到处使用呢,答案很显然,那就是多个生效,插件不需要知道项目中是怎样定义的,只需要使用自己插件中定义好的。
作者:吴敬悦
链接:https://juejin.cn/post/7044044227063316488
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。