[关闭]
@linux1s1s 2017-01-22T08:55:13.000000Z 字数 10767 阅读 2115

Android 在线热修复框架 AndFix 初步二

AndroidExtend 2016-03


上文AndFix 初步一 埋下了一个坑,这里准备填一下。

还记得上篇文章中提到的patch文件,其实这个文件是jar文件,因为它生成的时候是使用Attributes,Manifest,jarEntry将数据写入的, 其实依旧是jar格式,修改了扩展名而已,后续会进一步解释。

这里我们分析github提供的patch文件制作工具apkpatch-1.0.3.jar这个文件,看看它是如何完成补丁制作的。

对于Jar文件,我们可以使用jd-gui文件打开,阿里这里并没有对这个jar文件加密,所以我们可以直接看,对于Jar文件,我们从Main()方法开始看起。
在package com.euler.patch;里面,有一个Main类,其中有那个我们常见的main()方法,那么我们就进入看一看。
代码比较长,我们一段一段来看

Main main()

  1. //CommandLineParser,CommandLine, Option, Options,OptionBuilder, PosixParser等等类都是
  2. //org.apache.commons.cli这个包中的类,负责解析命令行传给程序的参数,所以下面的很多内容应该就不用我过多解释了
  3. CommandLineParser parser = new PosixParser();
  4. CommandLine commandLine = null;
  5. //option()方法就是将命令行需要解析的参数加入其中,有三种解析方式
  6. //private static final Options allOptions = new Options();
  7. //private static final Options patchOptions = new Options();
  8. //private static final Options mergeOptions = new Options();
  9. option();
  10. try {
  11. commandLine = parser.parse(allOptions, args);
  12. } catch (ParseException e) {
  13. System.err.println(e.getMessage());
  14. //提示用户如何使用这个jar包
  15. usage(commandLine);
  16. return;
  17. }
  18. if ((!commandLine.hasOption('k')) && (!commandLine.hasOption("keystore"))) {
  19. usage(commandLine);
  20. return;
  21. }
  22. if ((!commandLine.hasOption('p')) && (!commandLine.hasOption("kpassword"))) {
  23. usage(commandLine);
  24. return;
  25. }
  26. if ((!commandLine.hasOption('a')) && (!commandLine.hasOption("alias"))) {
  27. usage(commandLine);
  28. return;
  29. }
  30. if ((!commandLine.hasOption('e')) && (!commandLine.hasOption("epassword"))) {
  31. usage(commandLine);
  32. return;
  33. }

这一部分主要是查看命令行是否有那些参数,如果没有要求的参数,则提示用户需要哪些参数,继续分析代码里面的参数k,p,a,e等.

  1. String keystore = commandLine.getOptionValue('k');
  2. String password = commandLine.getOptionValue('p');
  3. String alias = commandLine.getOptionValue('a');
  4. String entry = commandLine.getOptionValue('e');
  5. String name = "main";
  6. if ((commandLine.hasOption('n')) || (commandLine.hasOption("name"))){
  7. name = commandLine.getOptionValue('n');
  8. }

对于我们的release版应用来说,我们会指定storeFile, storePassword, keyAlias, keyPassword, 这些即是上面keystore, password, alias, entry对应的值。至于name,这是最后生成的文件的名称,基本可以不用管。
下面,我们看一下main()函数中最后的一个if-else

  1. if ((commandLine.hasOption('m')) || (commandLine.hasOption("merge"))) {
  2. String[] merges = commandLine.getOptionValues('m');
  3. File[] files = new File[merges.length];
  4. for (int i = 0; i < merges.length; i++) {
  5. files[i] = new File(merges[i]);
  6. }
  7. MergePatch mergePatch = new MergePatch(files, name, out, keystore,
  8. password, alias, entry);
  9. mergePatch.doMerge();
  10. } else {
  11. if ((!commandLine.hasOption('f')) && (!commandLine.hasOption("from"))) {
  12. usage(commandLine);
  13. return;
  14. }
  15. if ((!commandLine.hasOption('t')) && (!commandLine.hasOption("to"))) {
  16. usage(commandLine);
  17. return;
  18. }
  19. File from = new File(commandLine.getOptionValue("f"));
  20. File to = new File(commandLine.getOptionValue('t'));
  21. if ((!commandLine.hasOption('n')) && (!commandLine.hasOption("name"))) {
  22. name = from.getName().split("\\.")[0];
  23. }

在此,我选择不分析MergePatch这个部分,毕竟,你只有先生成了,后面才可能需要做merge操作。 from指修复bug后的apk,to指之前有bug的apk。main()函数里最后一部分内容

  1. ApkPatch apkPatch = new ApkPatch(from, to, name, out, keystore,
  2. password, alias, entry);
  3. apkPatch.doPatch();

将那些参数传给ApkPatch去初始化,然后调用doPatch()方法,接下来继续跟进ApkPatch
首先是调用构造函数

  1. public ApkPatch(File from, File to, String name, File out, String keystore, String password, String alias, String entry){
  2. super(name, out, keystore, password, alias, entry);
  3. this.from = from;
  4. this.to = to;
  5. }

父类构造器做了啥,我们暂时省略了,接着重点看doPatch()方法

  1. public void doPatch() {
  2. File smaliDir = new File(this.out, "smali");
  3. smaliDir.mkdir();
  4. File dexFile = new File(this.out, "diff.dex");
  5. File outFile = new File(this.out, "diff.apatch");
  6. //DiffInfo是一个容器类,主要保存了新加的和修改的 类,方法和字段。
  7. DiffInfo info = new DexDiffer().diff(this.from, this.to);
  8. this.classes = buildCode(smaliDir, dexFile, info);
  9. build(outFile, dexFile);
  10. release(this.out, dexFile, outFile);
  11. }

在out文件夹内生成了一个smali文件夹,还有diff.dex, diff.apatch文件。 看到diff()方法,应该能想到就是比较两个文件的不同,所以DiffInfo就是储存两个文件不同的一个容器类, 由于篇幅原因,这里就不深入其中了,有兴趣的同学可以深入其中看一下。

但是,在这个diff()方法中,有一个重要的问题需要大家注意,就是其中只针对classes.dex做了diff,如果你使用了Google的Multidex,那么结果就是你其它dex文件中的任何bug,依旧无法修复,因为这个生成的DiffInfo中没有其它dex的信息,这个时候就需要大家使用JavaAssist之类的工具修改阿里的这个jar文件,然后达到你自己的修复非classes.dex文件中bug的目的,问题出在DexFileFactory.class类中。

接下来,我们可以看到调用了三个方法,buildCode(smaliDir, dexFile, info); build(outFile, dexFile);release(this.out, dexFile, outFile); 一个个的跟进去看一看。

  1. private static Set<String> buildCode(File smaliDir, File dexFile, DiffInfo info)
  2. throws IOException, RecognitionException, FileNotFoundException{
  3. Set classes = new HashSet();
  4. Set list = new HashSet();
  5. //从这里可以看出,list保存了DiffInfo容器中的新添加的classes和被修改过的classes
  6. list.addAll(info.getAddedClasses());
  7. list.addAll(info.getModifiedClasses());
  8. baksmaliOptions options = new baksmaliOptions();
  9. options.deodex = false;
  10. options.noParameterRegisters = false;
  11. options.useLocalsDirective = true;
  12. options.useSequentialLabels = true;
  13. options.outputDebugInfo = true;
  14. options.addCodeOffsets = false;
  15. options.jobs = -1;
  16. options.noAccessorComments = false;
  17. options.registerInfo = 0;
  18. options.ignoreErrors = false;
  19. options.inlineResolver = null;
  20. options.checkPackagePrivateAccess = false;
  21. if (!options.noAccessorComments) {
  22. options.syntheticAccessorResolver = new SyntheticAccessorResolver(list);
  23. }
  24. ClassFileNameHandler outFileNameHandler = new ClassFileNameHandler(
  25. smaliDir, ".smali");
  26. ClassFileNameHandler inFileNameHandler = new ClassFileNameHandler(
  27. smaliDir, ".smali");
  28. DexBuilder dexBuilder = DexBuilder.makeDexBuilder();
  29. for (DexBackedClassDef classDef : list) {
  30. String className = classDef.getType();
  31. //将相关的类信息写入outFileNameHandler
  32. baksmali.disassembleClass(classDef, outFileNameHandler, options);
  33. File smaliFile = inFileNameHandler.getUniqueFilenameForClass(
  34. TypeGenUtil.newType(className));
  35. classes.add(TypeGenUtil.newType(className)
  36. .substring(1, TypeGenUtil.newType(className).length() - 1)
  37. .replace('/', '.'));
  38. SmaliMod.assembleSmaliFile(smaliFile, dexBuilder, true, true);
  39. }
  40. dexBuilder.writeTo(new FileDataStore(dexFile));
  41. return classes;
  42. }

看到baksmali,反编译过apk的同学一定不陌生,这就是dex的打包工具,还有对应的解包工具smali,就到这里,这方面不继续深入了。 如果想深入了解dex打包解包工具的源码,参见This Blog

可以看到,这个方法的返回值将DiffInfo中新添加的classes和修改过的classes做了一个重命名,然后保存了起来,同时,将相关内容写入smali文件中。 这个重命名是一个怎样的重命名呢,看一下生成的smali文件夹里任意一个smali文件,我就拿Demo里的MainActivity.smali来说明 这个类在文件中的类名是Lcom/euler/andfix/MainActivity;,看到这个名字,再看下面这个方法就很清晰了

  1. classes.add(TypeGenUtil.newType(className)
  2. .substring(1, TypeGenUtil.newType(className).length() - 1)
  3. .replace('/', '.'));

就是把Lcom/euler/andfix/MainActivity;替换成com.euler.andfix.MainActivity_CF; 那个_CF哪里来的呢,就是那个TypeGenUtil类做的操作了。 这个类就一个方法,该方法进对String进行了操作,我们来看一看源码

  1. public static String newType(String type){
  2. return type.substring(0, type.length() - 1) + "_CF;";
  3. }

可以看到,去掉了类名多余的那个’;’,然后在后面加了个_CF后缀。重命名应该是为了不合之前安装的dex文件的名字冲突。

(new FileDataStore(file) 作用就是清空file里的内容) 最后,将dexFile文件清空,把dexBuilder的内容写入其中。

到这里,buildCode(smaliDir, dexFile, info)方法就结束了, 看看下一个方法build(outFile, dexFile);

上一个方法已经把内容填充到dexFile内了,我们来看看它的源码。

  1. protected void build(File outFile, File dexFile)
  2. throws KeyStoreException, FileNotFoundException, IOException, NoSuchAlgorithmException, CertificateException, UnrecoverableEntryException{
  3. //获取应用签名相关信息
  4. KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
  5. KeyStore.PrivateKeyEntry privateKeyEntry = null;
  6. InputStream is = new FileInputStream(this.keystore);
  7. keyStore.load(is, this.password.toCharArray());
  8. privateKeyEntry = (KeyStore.PrivateKeyEntry)keyStore.getEntry(this.alias,
  9. new KeyStore.PasswordProtection(this.entry.toCharArray()));
  10. PatchBuilder builder = new PatchBuilder(outFile, dexFile,
  11. privateKeyEntry, System.out);
  12. //将getMeta()中获取的Manifest内容写入"META-INF/PATCH.MF"文件中
  13. builder.writeMeta(getMeta());
  14. //单纯调用了close()命令, 将异常处理放在子函数中。
  15. //这里做了一个异常分层,即不同抽象层次的子函数需要处理的异常不一样
  16. //具体请参阅《Code Complete》(代码大全)在恰当的抽象层次抛出异常
  17. builder.sealPatch();
  18. }

要打包了,打包完是要签名的,所以需要获取签名的相关信息,这一部分就不详细讲解了。 这里提一下getMeta()函数,因为开头我们提到的Patch init() 函数内 这句List strings = Arrays.asList(main.getValue(attrName).split(","));中的那个strings就是从这里写入的

  1. protected Manifest getMeta()
  2. {
  3. Manifest manifest = new Manifest();
  4. Attributes main = manifest.getMainAttributes();
  5. main.putValue("Manifest-Version", "1.0");
  6. main.putValue("Created-By", "1.0 (ApkPatch)");
  7. main.putValue("Created-Time",
  8. new Date(System.currentTimeMillis()).toGMTString());
  9. main.putValue("From-File", this.from.getName());
  10. main.putValue("To-File", this.to.getName());
  11. main.putValue("Patch-Name", this.name);
  12. main.putValue("Patch-Classes", Formater.dotStringList(this.classes));
  13. return manifest;
  14. }

那个classes就是我们那个将类名修改后保存起来的Set, Formater.dotStringList(this.classes));这个方法就是遍历Set,并将其中用','作为分隔符,将整个Set中保存的String拼接 所以在Patch init()方法内,就可以反过来组成一个List。

那么,我们来看看中间那条没注释的语句,通过PatchBuilder()的构造函数生成PatchBuilder,我们来看看PatchBuilder的构造函数

  1. public PatchBuilder(File outFile, File dexFile, KeyStore.PrivateKeyEntry key, PrintStream verboseStream){
  2. this.mBuilder = new SignedJarBuilder(new FileOutputStream(outFile, false), key.getPrivateKey(),
  3. (X509Certificate)key.getCertificate());
  4. this.mBuilder.writeFile(dexFile, "classes.dex");
  5. }

这个就是把dexFile里的内容,经过修改,加上签名,写入classes.dex文件中,但是这里实在看不出来其中的细节, 所以,我们要进入SignedJarBuilder类一探究竟

先看它的构造函数。

  1. public SignedJarBuilder(OutputStream out, PrivateKey key, X509Certificate certificate)
  2. throws IOException, NoSuchAlgorithmException{
  3. this.mOutputJar = new JarOutputStream(new BufferedOutputStream(out));
  4. //设置压缩级别
  5. this.mOutputJar.setLevel(9);
  6. this.mKey = key;
  7. this.mCertificate = certificate;
  8. if ((this.mKey != null) && (this.mCertificate != null)) {
  9. this.mManifest = new Manifest();
  10. Attributes main = this.mManifest.getMainAttributes();
  11. main.putValue("Manifest-Version", "1.0");
  12. main.putValue("Created-By", "1.0 (ApkPatch)");
  13. this.mBase64Encoder = new BASE64Encoder();
  14. this.mMessageDigest = MessageDigest.getInstance("SHA1");
  15. }
  16. }

这个方法做了一些初始化,做了一些赋值操作,大家经常写类似的代码,就不进行分析了,只是为了让大家看writeFile(File, String)的时候容易理解一些。

  1. public void writeFile(File inputFile, String jarPath){
  2. FileInputStream fis = new FileInputStream(inputFile);
  3. JarEntry entry = new JarEntry(jarPath);
  4. entry.setTime(inputFile.lastModified());
  5. writeEntry(fis, entry);
  6. }

这个方法调用了writeEntry(InputStream, JarEntry)方法, 来看一看它的源码

  1. private void writeEntry(InputStream input, JarEntry entry)
  2. throws IOException{
  3. this.mOutputJar.putNextEntry(entry);
  4. int count;
  5. while ((count = input.read(this.mBuffer)) != -1)
  6. {
  7. int count;
  8. this.mOutputJar.write(this.mBuffer, 0, count);
  9. if (this.mMessageDigest != null) {
  10. //将mBuffer中的内容放入mMessageDigest内部数组
  11. this.mMessageDigest.update(this.mBuffer, 0, count);
  12. }
  13. }
  14. this.mOutputJar.closeEntry();
  15. if (this.mManifest != null)
  16. {
  17. Attributes attr = this.mManifest.getAttributes(entry.getName());
  18. if (attr == null) {
  19. attr = new Attributes();
  20. this.mManifest.getEntries().put(entry.getName(), attr);
  21. }
  22. attr.putValue("SHA1-Digest", this.mBase64Encoder.encode(this.mMessageDigest.digest()));
  23. }
  24. }

mBuffer是4096 bytes的数组。 这段代码主要从input中读取一个buffer的数据,然后写入entry中,这里应该就可以理解我上面说的dexFile里的内容,写入classes.dex文件中了吧。

build(outFile, dexFile);结束了,看看最后一个方法release(this.out, dexFile, outFile)

  1. protected void release(File outDir, File dexFile, File outFile) throws NoSuchAlgorithmException, FileNotFoundException, IOException
  2. {
  3. MessageDigest messageDigest = MessageDigest.getInstance("md5");
  4. FileInputStream fileInputStream = new FileInputStream(dexFile);
  5. byte[] buffer = new byte[8192];
  6. int len = 0;
  7. while ((len = fileInputStream.read(buffer)) > 0) {
  8. messageDigest.update(buffer, 0, len);
  9. }
  10. String md5 = HexUtil.hex(messageDigest.digest());
  11. fileInputStream.close();
  12. outFile.renameTo(new File(outDir, this.name + "-" + md5 + ".apatch"));
  13. }

最后就是把dexFile进行了md5加密,并把build(outFile, dexFile);函数中生成的outFile重命名。这样,AndFix框架所需要的补丁文件就生成了。

还记得上面提到的Patch类中读取Manifest的那些属性吗?在build(outFile, dexFile);函数中,那个getMeta()函数把它读取的属性写到了文件中。

下文我们将从Demo源码分析入手,深入源码分析补丁是如何生效的。

附注: 此文章转载并微修改自AndFix解析——二,这里大赞一下博主的钻研精神。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注