看源码带着问题看,不解决问题就是白看
那么首先,install这个流程我们想学到什么?
什么是install?install怎么生成对应路径的?怎么解析apk的?
ok那么一步步看
安装apk有几个阶段?
前期准备的工作有拷贝、完整性校验、解析apk、提取native libs、版本号验证;安装的工作有准备 (Prepare) 、扫描 (Scan) 、调和 (Reconcile) 、提交 (Commit) ;后期收尾的工作有创建app data根目录、dex优化、移除已有apk、发送安装成功广播。
首先 PackageInstallerService 会创建 PackageInstallerSession
那么他们的职责是什么?PackageInstallerSession远程通信跟谁通信?
PackageInstallerService 是服务管理所有的已经安装的apk
安装apk第一步是需要把apk(不管apk来源于哪)进行拷贝,拷贝到 /data/app/xxxx.tmp(xxxx是一个随机的字符串)目录下面,拷贝的apk的名字一般被命名为:base.apk,拷贝完后的apk文件的路径是 /data/app/xxxx.tmp/base.apk 这样的
一上来就要拷贝,为啥要拷贝吗?我的理解是拷贝会增加apk的安装时长,如果apk特别大,安装时长更会加长,不拷贝不行吗?
拿adb install或者应用市场安装apk的方式来说明问题吧,Session我是运行于systemserver进程。通过adb install安装的话,apk是位于pc上,pc上的apk对于Session是肯定不能拿来直接用的;通过应用市场安装的话,apk是被应用市场进程所存储的,而Session我也是基本不可以访问的(除非apk被下载到可共享的目录)。因此我需要先把apk拷贝到我可以访问的目录下面,这样我就可以直接操作apk了。
private ParcelFileDescriptor doWriteInternal(String name, long offsetBytes, long lengthBytes,
ParcelFileDescriptor incomingFd) throws IOException {
// Quick validity check of state, and allocate a pipe for ourselves. We
// then do heavy disk allocation outside the lock, but this open pipe
// will block any attempted install transitions.
final RevocableFileDescriptor fd;
final FileBridge bridge;
synchronized (mLock) {
if (PackageInstaller.ENABLE_REVOCABLE_FD) {
fd = new RevocableFileDescriptor();
bridge = null;
mFds.add(fd);
} else {
fd = null;
bridge = new FileBridge();
mBridges.add(bridge);
}
}
try {
// Use installer provided name for now; we always rename later
if (!FileUtils.isValidExtFilename(name)) {
throw new IllegalArgumentException("Invalid name: " + name);
}
final File target;
final long identity = Binder.clearCallingIdentity();
try {
target = new File(stageDir, name);
} finally {
Binder.restoreCallingIdentity(identity);
}
// If file is app metadata then set permission to 0640 to deny user read access since it
// might contain sensitive information.
int mode = name.equals(APP_METADATA_FILE_NAME) ? APP_METADATA_FILE_ACCESS_MODE : 0644;
ParcelFileDescriptor targetPfd = openTargetInternal(target.getAbsolutePath(),
O_CREAT | O_WRONLY, mode);
Os.chmod(target.getAbsolutePath(), mode);
// If caller specified a total length, allocate it for them. Free up
// cache space to grow, if needed.
if (stageDir != null && lengthBytes > 0) {
mContext.getSystemService(StorageManager.class).allocateBytes(
targetPfd.getFileDescriptor(), lengthBytes,
InstallLocationUtils.translateAllocateFlags(params.installFlags));
}
if (offsetBytes > 0) {
Os.lseek(targetPfd.getFileDescriptor(), offsetBytes, OsConstants.SEEK_SET);
}
if (incomingFd != null) {
// In "reverse" mode, we're streaming data ourselves from the
// incoming FD, which means we never have to hand out our
// sensitive internal FD. We still rely on a "bridge" being
// inserted above to hold the session active.
try {
final Int64Ref last = new Int64Ref(0);
FileUtils.copy(incomingFd.getFileDescriptor(), targetPfd.getFileDescriptor(),
lengthBytes, null, Runnable::run,
(long progress) -> {
if (params.sizeBytes > 0) {
final long delta = progress - last.value;
last.value = progress;
synchronized (mProgressLock) {
setClientProgressLocked(mClientProgress
+ (float) delta / (float) params.sizeBytes);
}
}
});
} finally {
} else if (PackageInstaller.ENABLE_REVOCABLE_FD) {
}
} catch (ErrnoException e) {
throw e.rethrowAsIOException();
}
}
第二步是对 /data/app/xxxx.tmp/base.apk 进行完整性验证。完整性验证用一句话概括就是:验证apk有没有被改过。这一步肯定是要最先进行的,只有我先确认apk是一个完整的apk才有必要进行后面的安装流程
如果有人改掉了这个apk里的代码,或者字节码,那就会出现危险
那又是如何能验证apk没有被改动过呢?
我先从雏形说起,这样可以更容易理解从雏形到最终方案是如何一步一步形成的。刚开始的验证雏形是这样的:我Session需要从apk内拿到一个信息,这个信息是与apk是一一对应关系,也就是apk内不管发生任何变化,那这个信息也需要发生变化,并且我需要根据apk能推导或计算出这个信息,如果推导或计算的信息与apk内拿到的信息一致就可以证明apk是没有被修改过的。那怎么样可以做到呢?答案是使用摘要算法
什么是摘要算法?有兴趣可以了解
这些摘要信息没有加密,如若改动了apk内的内容,则也可以重新把摘要内容改了,重新打包到apk内。因此需要对摘要信息进行加密。
对摘要信息进行加密需要用到非对称加密(https中就用到它),非对称加密是一种加密算法,分为公钥和私钥,公钥是可以公开的,私钥是不能公开的,用私钥对信息加密,是可以用公钥解密的。需要用私钥对摘要信息进行加密,把加密后的摘要信息和证书(证书存储了公钥和开发者的一些信息)一同打包到apk内。我把这个过程起了一个很好听的名字apk签名,就如人类在合同上签名一样,每个apk也是需要签名的,签了名后这个apk就和开发者绑定了。
基于apk签名,终极apk完整性验证流程如下(下面主要介绍的是签名v1版本的验证流程):
-
从apk中拿到证书信息,拿到加密的摘要信息
-
从证书中用公钥对加密的摘要信息解密,解密出摘要信息
-
对apk的各文件用摘要算法生成摘要,并与解密出的摘要信息进行对比,如若一致则证明没有被改动,否则发生了改动。
一步一张图,时序大概如下
@CheckResult
public static ParseResult<SigningDetails> getSigningDetails(ParseInput input,
String baseCodePath, boolean skipVerify, boolean isStaticSharedLibrary,
@NonNull SigningDetails existingSigningDetails, int targetSdk) {
final ParseResult<SigningDetails> verified;
if (skipVerify) {
} else {
verified = ApkSignatureVerifier.verify(input, baseCodePath, minSignatureScheme);
}
if (verified.isError()) {
return input.error(verified);
}
}
然后下一步解析apk但是不会进行四大组件的解析
这步就先跳过,有兴趣可以看下
然后 提取native libs
在提取native libs的时候,会检测apk中的cpu abi是否与当前设备的cpu abi是否匹配,如果不匹配比如当前设备cpu abi是x86_64的,而apk中的cpu abi只有arm、arm64,那这种情况肯定是不能继续安装的,因为so库是与cpu abi强相关的,arm下面的so库在x86_64上面运行肯定是出问题的。为了考虑性能和方便性,整个提取native libs都是委托给native的代码执行的。
提前native libs可以提前检测当前设备的cpu abi是否与apk中的so库匹配,不匹配则不安装,并且还可以提升app的启动速度,如果不提取的话,每次app启动都需要从apk中解析出这些so库,这速度肯定慢啊,该步的产物是apk中的so库提取到 /data/app/xxxx.tmp/lib/cpuabi/ 目录下面。下一步就来看下版本号验证吧。
private void parseApkAndExtractNativeLibraries() throws PackageManagerException {
synchronized (mLock) {
省略代码......
final PackageLite result;
if (!isApexSession()) {
//走这,解析apk信息
result = getOrParsePackageLiteLocked(stageDir, /* flags */ 0);
} else {
result = getOrParsePackageLiteLocked(mResolvedBaseFile, /* flags */ 0);
}
if (result != null) {
mPackageLite = result;
if (!isApexSession()) {
省略代码......
//提取so库
extractNativeLibraries(
mPackageLite, stageDir, params.abiOverride, mayInheritNativeLibs());
}
}
}
}
到了版本号验证这一步了,但是这步不是必须,如果设备上已经安装了相同包名的apk,则该步是必须的,版本号验证所要做的事情非常简单:正在安装的apk的版本号是否比已经安装的apk的版本号小,小的话就停止安装。正在安装的apk的版本号从解析apk中的PackageLite拿到
//文件路径:frameworks/base/services/core/java/com/android/server/pm/InstallPackageHelper.java
Pair<Integer, String> verifyReplacingVersionCode(PackageInfoLite pkgLite,
long requiredInstalledVersionCode, int installFlags) {
if ((installFlags & PackageManager.INSTALL_APEX) != 0) {
return verifyReplacingVersionCodeForApex(
pkgLite, requiredInstalledVersionCode, installFlags);
}
String packageName = pkgLite.packageName;
synchronized (mPm.mLock) {
省略代码......
//dataOwnerPkg代表设备已经安装对应的apk了
if (dataOwnerPkg != null && !dataOwnerPkg.isSdkLibrary()) {
//只有debug版本才允许版本降级
if (!PackageManagerServiceUtils.isDowngradePermitted(installFlags,
dataOwnerPkg.isDebuggable())) {
try {
//检测是否存在版本降级,是的话会报错
PackageManagerServiceUtils.checkDowngrade(dataOwnerPkg, pkgLite);
} catch (PackageManagerException e) {
String errorMsg = "Downgrade detected: " + e.getMessage();
Slog.w(TAG, errorMsg);
return Pair.create(
PackageManager.INSTALL_FAILED_VERSION_DOWNGRADE, errorMsg);
}
}
}
}
return Pair.create(PackageManager.INSTALL_SUCCEEDED, null);
}
前期准备阶段又划分为拷贝、完整性验证、解析apk、提取native libs、版本号验证五步,每一步都在为后一步做准备。
拷贝会把安装的apk拷贝到/data/app/xxxx.tmp/base.apk。
完整性验证会对/data/app/xxxx.tmp/base.apk进行验证,如果修改过则停止安装,同时还会提取签名信息到SigningDetails对象,如果apk没有签名信息则会停止安装,SigningDetails对象会在后面的安装流程用到。
解析apk会从/data/app/xxxx.tmp/base.apk的AndroidManifest中把包名、版本号、安装路径、是否是debug等信息提取出来放入PackageLite对象,若解析中发生错误也会停止安装。
提取native libs的时候会用到 PackageLite对象,会把/data/app/xxxx.tmp/base.apk中的so库提取到 /data/app/xxxx.tmp/lib/cpuabi/ 目录(若apk存在so库),若发生错误则也会停止安装。
版本号验证的工作内容是正在安装的apk的版本号是否比已经安装的apk的版本号小,小的话就停止安装。这步不是必须的,只有设备上已经安装了相同包名的apk才执行。
前期准备的各步都能正常执行的话,就进入正式的安装阶段,那我们来看下安装阶段的内容。
InstallPackageHelper:安装阶段也可以称为正式安装,在这阶段才真正开始apk的安装工作。
安装阶段可以分为四步:准备 (Prepare) 、扫描 (Scan)、调和 (Reconcile)、提交 (Commit),这四步整体是原子化操作,也就是只要有一个出问题,整体的安装就停止,下面就来介绍下这四步。
准备 (Prepare)
完全解析apk
还记得解析apk那步会把apk的基础信息存放到PackageLite对象吗,这只是解析了比较少的基础信息。完全解析apk就是从/data/app/xxxx.tmp/base.apk的AndroidManifest中把所有的信息都解析出来,包含了声明的四大组件、权限、meta-data、shareLibs等,这些信息会存放在ParsedPackage对象中,如果解析发生错误,则停止安装。解析出ParsedPackage后,后面的工作都是围绕ParsedPackage展开的。
保存签名
在完整性验证那步是保存了签名信息到SigningDetails对象的,如果SigningDetails不为null的话会把SigningDetails存入ParsedPackage中;否则从apk中解析出SigningDetails存入ParsedPackage。
签名验证
签名验证的工作内容是对正在安装的apk的证书信息与设备上已经安装的相同包名的apk的证书信息进行对比,如果不一致,则停止安装。如果设备上不存在相同包名的apk则这一步是不会进行的。比如设备上安装了微信,如果有一个apk它的包名与微信一样,签名肯定不一样的情况下。这时候往设备上安装此apk肯定是安装不上的。
权限验证
权限验证就是根据ParsedPackage里的getPermissions()方法获取的权限,来判断哪些权限是存在问题的,比如声明了只有系统app才能使用的权限,如果存在问题则停止安装。
重命名
还记得拷贝第一步的时候生成的临时目录 /data/app/xxxx.tmp/ 吗?这毕竟是个临时目录,是有必要给它一个正式的名字的,那重命名所做的事情就是把 /data/app/xxxx.tmp/ 重命名为 /data/app/~~[randomStrA]/[packageName]-[randomStrB](其中randomStrA、randomStrB是随机生成的字符串,packageName是包名),这个名字看上去确实不是很正规,但是它确实是一个非常正式的名字。
apk:“那我有个问题啊,为什么重命名的名字没有用包名,而是用一个随机字符串呢?”
InstallPackageHelper:“用随机字符串的原因是,在 /data/app/ 目录下面会存在两个同一包名apk的情况,如果用包名的话会出现问题。比如当前设备上已经安装了一个微信apk,则在 /data/app/com.weixin/ 目录下会存在微信的apk。这时候安装一个高版本的微信apk的,这时候重命名的话就出现问题,因为已经有com.weixin目录存在了。”
如果重命名失败也会停止安装。下面是重命名的例子,可以看到它们的user、group都是system。
如下正式apk父目录的相关代码,有兴趣可以看下。
//文件路径:services/core/java/com/android/server/pm/PackageManagerServiceUtils.java
/**
* 返回的目录结构样子:targetDir/~~[randomStrA]/[packageName]-[randomStrB]
*/
public static File getNextCodePath(File targetDir, String packageName) {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[16];
File firstLevelDir;
do {
random.nextBytes(bytes);
String firstLevelDirName = RANDOM_DIR_PREFIX
+ Base64.encodeToString(bytes, Base64.URL_SAFE | Base64.NO_WRAP);
firstLevelDir = new File(targetDir, firstLevelDirName);
} while (firstLevelDir.exists());
random.nextBytes(bytes);
String dirName = packageName + RANDOM_CODEPATH_PREFIX + Base64.encodeToString(bytes,
Base64.URL_SAFE | Base64.NO_WRAP);
final File result = new File(firstLevelDir, dirName);
if (DEBUG && !Objects.equals(tryParsePackageName(result.getName()), packageName)) {
throw new RuntimeException(
"codepath is off: " + result.getName() + " (" + packageName + ")");
}
return result;
}
终于到了后期收尾阶段,为啥要叫后期收尾呢?是因为这一阶段所做的事情即使出现了错误也不会影响上面apk安装成功的结果,那就来看下后期收尾都做了哪些事情。
创建app data根目录
关于为什么创建app data根目录以及都创建了哪些目录可以参考installd进程,在这篇就不赘述了。创建app data根目录是委托了Installer,Installer在通过binder通信的方式让installd进程帮忙创建的。只有创建app data根目录成功后,apk才可以运行起来。
dex优化
关于dex优化可以参考installd进程,同样dex优化也是委托Installer实现的,最终也是转交由installd进程帮忙实现的。dex优化即使不成功也不会影响apk的运行,但是会影响apk的运行速度。
创建app data根目录和dex优化的源代码如下,有兴趣可以看下:
//文件:InstallPackageHelper.java
private void executePostCommitSteps(CommitRequest commitRequest) {
final ArraySet<IncrementalStorage> incrementalStorages = new ArraySet<>();
for (ReconciledPackage reconciledPkg : commitRequest.mReconciledPackages.values()) {
final boolean instantApp = ((reconciledPkg.mScanResult.mRequest.mScanFlags
& SCAN_AS_INSTANT_APP) != 0);
final AndroidPackage pkg = reconciledPkg.mPkgSetting.getPkg();
final String packageName = pkg.getPackageName();
final String codePath = pkg.getPath();
final boolean onIncremental = mIncrementalManager != null
&& isIncrementalPath(codePath);
省略代码......
//创建app data根目录
mAppDataHelper.prepareAppDataPostCommitLIF(pkg, 0); //niu 创建 data目录
省略代码......
final boolean performDexopt =
(!instantApp || android.provider.Settings.Global.getInt(
mContext.getContentResolver(),
android.provider.Settings.Global.INSTANT_APP_DEXOPT_ENABLED, 0) != 0)
&& !pkg.isDebuggable()
&& (!onIncremental)
&& dexoptOptions.isCompilationEnabled();
//并不是所有的apk都需要dex优化,如果需要优化,进入下面逻辑
if (performDexopt) {
省略代码......
//开始优化
mPackageDexOptimizer.performDexOpt(pkg, realPkgSetting,
null /* instructionSets */,
mPm.getOrCreateCompilerPackageStats(pkg),
mDexManager.getPackageUseInfoOrDefault(packageName),
dexoptOptions);
Trace.traceEnd(TRACE_TAG_PACKAGE_MANAGER);
}
省略代码......
}
PackageManagerServiceUtils.waitForNativeBinariesExtractionForIncremental(
incrementalStorages);
}
移除已有apk
如果设备上已经安装了相同包名的apk(称它为老apk),则在新apk安装成功后是需要把老apk删除的,删除过程也同样是委托Installer,最终转交由installd进程来实现。即使老apk删除失败也不会影响新apk。
发送安装成功广播
既然一个apk安装成功了,那肯定是需要通知关注者的,采用的方式是发广播,比如桌面在收到安装成功的广播后,修改正在安装apk的状态。
//文件:PackageInstallerSession.java
private void dispatchSessionFinished(int returnCode, String msg, Bundle extras) {
sendUpdateToRemoteStatusReceiver(returnCode, msg, extras);
synchronized (mLock) {
mFinalStatus = returnCode;
mFinalMessage = msg;
}
final boolean success = (returnCode == INSTALL_SUCCEEDED);
final boolean isNewInstall = extras == null || !extras.getBoolean(Intent.EXTRA_REPLACING);
if (success && isNewInstall && mPm.mInstallerService.okToSendBroadcasts()) {
//收集apk的信息,把这些信息通过广播发送出去
mPm.sendSessionCommitBroadcast(generateInfoScrubbed(true /*icon*/), userId);
}
mCallback.onSessionFinished(this, success);
if (isDataLoaderInstallation()) {
logDataLoaderInstallationSession(returnCode);
}
}
/ 总结 /
到此apk的安装之谜算是揭开了,apk的安装会经过前期准备、安装、后期收尾这三个阶段,前期准备成功后才会进入安装阶段,安装阶段成功后才会进入后期收尾阶段。除了后期收尾外,前两个阶段只要发生错误就会停止apk的安装。
apk的安装可以总结为下面几步:
-
不管apk是通过adb安装的(apk存储于PC的磁盘)还是应用市场安装的(apk存储于设备),首先apk会被拷贝到 /data/app/xxx.tmp目录下面(xxx是一个随机生成的字符串)
-
在经过重重的验证、校验(签名、版本号),/data/app/xxx.tmp 目录会重命名为 /data/app/[randomStrA]/[packageName]-[randomStrB] 目录,也就是被拷贝的apk最终路径是 /data/app/[randomStrA]/[packageName]-[randomStrB]/base.apk 。同时会为apk生成一个唯一的id又称appid
-
解析apk的AndroidManifest中的内容为ParsedPackage,ParsedPackage中的权限等信息经过验证通过后,ParsedPackage传递给PMS,这样其他使用者比如ActivityManagerService就可以从PMS获取刚安装apk的信息了。
-
刚安装的apk的安装信息比如包名、版本、签名证书、安装时间等会存储到PackageSetting,PackageSetting会传递给Settings,Settings会把它持久化到packages.xml文件。
-
创建app data根目录,app data根目录是apk运行期间数据存储的根目录,并且app data根目录只有当前apk程序有读写执行权,其他不用没有任何权限。
-
对apk的dex进行优化,优化即使不成功也不影响apk的安装,dex优化可以保证app运行性能上的提升。
-
发送安装成功广播。
apk越大包含的so越多,安装apk的时间越长。主要时长体现在拷贝、提取native libs、dex优化这几项工作。
那么大概流程我们都看完了,那么我们说说能学到什么?
主要时长体现在拷贝、提取native libs、dex优化这几项工作。
那么如果我们有源码,提前把apk lib库抽出来 能否加快速度?
提前抽取库文件的影响
理论上的可能性:如果库文件被提前抽取出来,并在APK安装过程中直接引用这些已存在的库文件,而不是从APK包内解压,那么理论上可以减少解压时间,从而加快安装速度。然而,这种优化方式在实际操作中并不常见,因为Android系统默认会处理APK包内的所有文件,包括库文件。
实际操作的复杂性:
- 系统兼容性:不同的Android版本和设备可能对APK的安装过程有不同的处理机制。提前抽取库文件可能需要针对特定设备或系统版本进行适配,增加了开发的复杂性和维护成本。
- 安全性问题:APK的安装过程包含了对签名的验证,以确保APK的完整性和来源的可靠性。如果库文件被提前抽取出来,并绕过了系统的验证机制,可能会引入安全风险。
- 用户体验:对于普通用户来说,APK的安装速度通常不是决定性因素。更重要的是应用的稳定性和功能。因此,为了加快安装速度而牺牲其他方面的体验可能并不划算。
dex优化 在做什么?
APK中的DEX优化主要涉及对Dalvik Executable(DEX)文件的处理,旨在提升Android应用的启动速度、执行效率和整体性能。DEX文件是Android平台上应用程序的核心可执行文件类型,由Java或Kotlin等高级语言编写的源代码编译而来。以下是DEX优化的几个关键方面:
1. DEX布局优化
DEX布局优化通过重新排列DEX文件中的类和方法,以减少应用启动期间的主要页面故障数量,从而提高启动速度和执行效率。核心思想是将启动期间需要执行的代码尽可能地集中在一个或少数几个DEX文件中,以减少加载时间。具体做法可能包括:
- 合并DEX文件:将多个DEX文件合并为一个,以减少文件I/O操作的次数。但这需要考虑到DEX文件的方法数限制(65535个方法)。
- 优先加载关键代码:将应用启动时必须执行的代码放在DEX文件的开始部分,以便在启动时尽快加载。
2. 启动配置文件
启动配置文件提供了一种灵活的方式来控制应用程序的启动流程和加载顺序。通过生成和应用启动配置文件,开发者可以精确地指定哪些类和方法在启动期间被加载和执行,从而进一步优化应用的启动性能。生成启动配置文件通常需要使用专门的工具,如Jetpack Macrobenchmark。
3. 代码混淆与压缩
虽然这不直接属于DEX布局优化的范畴,但代码混淆和压缩也是优化DEX文件的重要手段。混淆通过将类名、方法名等替换为简短的无意义名称,来减少DEX文件的大小并增加反编译的难度。压缩则通过检测和删除未使用的代码、字段和方法来进一步减小DEX文件的体积。
4. 使用D8和R8优化工具
D8和R8是Android官方提供的DEX文件优化工具。它们能够在编译过程中对DEX文件进行优化,包括重排序、内联函数、删除无用代码等,以减小DEX文件的大小并提高执行效率。特别是R8,它在D8的基础上增加了更多的优化策略,如代码收缩、资源收缩和注解处理等。
5. Dex注解优化
Dex注解优化是针对DEX文件中注解的优化。通过去除DEX中的非必要注解(如编译时注解和某些类关系注解),可以减小DEX文件的体积,从而提高应用的加载速度和执行效率。这种优化通常需要使用字节码操作框架来实现。
总结
DEX优化是Android应用性能优化的重要环节之一。通过DEX布局优化、启动配置文件、代码混淆与压缩、使用D8和R8优化工具以及Dex注解优化等手段,可以显著提升Android应用的启动速度、执行效率和整体性能。需要注意的是,不同的优化策略可能需要结合应用的具体情况和需求来选择和应用。