细数tinker接入的那些坑

3,255 阅读9分钟
原文链接: www.jianshu.com

替换Application

按照TInker官方文档,接入Tinker Patch需要把原来项目中Application的代码移动到ApplicationLike中,然而这可不是件小事情,我们的application可能包含各种初始化,并且很多地方调用了application的public方法。比如


import android.support.multidex.MultiDexApplication;

public class MyApp extends MultiDexApplication {

    private static MyApp sInstance;

    @Override
    public void onCreate() {
        super.onCreate();
        sInstance = this;
        initNetWork();
        initFresco();
        initStetho();
        initXXX1();
        initXXX2();
        ActivityFetcher.init();
    }


    public String getAccount() {
        return "xxx";
    }

    public String getXXX() {
        return "xxx";
    }

    private void initXXX2() {
    }

    private void initXXX1() {
    }

    private void initNetWork() {
    }

    private void initFresco() {
    }

    private void initStetho() {
    }

    public static MyApp getApplication() {
        return sInstance;
    }
}

调用MyApp.getApplication(),注册activity监听

public class ActivityFetcher {
    private static List<WeakReference<Activity>> sActivities = new ArrayList<>();

    public static void init() {
        MyApp.getApplication().registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
            @Override
            public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
                sActivities.add(new WeakReference<Activity>(activity));
            }

            @Override
            public void onActivityDestroyed(Activity activity) {
                for (WeakReference<Activity> reference : sActivities) {
                    if (reference.get() == activity) {
                        sActivities.remove(reference);
                        return;
                    }
                }
            }
        });
    }

    public static List<WeakReference<Activity>> getActivities() {
        return Collections.unmodifiableList(sActivities);
    }
}

再比如需要context的地方直接把 MyApp.getApplication()作为了参数

Toast.makeText(MyApp.getApplication(), "请求失败", Toast.LENGTH_SHORT).show();

还有某些地方调用了application种的各种public方法

String account = MyApp.getApplication().getAccount();
String xxx = MyApp.getApplication().getXXX();

如果把application的代码都搬到ApplicationLike中的话改动量可能会很大,有没有更好的方案呢?肯定是有的。

首选我们看一下Application的源码,发现并没有什么特殊的,只不过是继承自ContextWrapper,并新增了几个public registXXX方法,所以Application其实只是一个代理,真整的context其实是ContextImpl对象,Application继承来得所有方法其实最终都是交给了ContextImpl对象处理。Application对象的创建过程大致如下

相关代码如下 LoadedApk#makeApplication

public Application makeApplication(boolean forceDefaultAppClass,
                                       Instrumentation instrumentation) {
        Application app = null;
        ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
        app = mActivityThread.mInstrumentation.newApplication(
                cl, appClass, appContext);
        appContext.setOuterContext(app);
        instrumentation.callApplicationOnCreate(app);    
        return app;
    }

ContextImpl#createAppContext

    static ContextImpl createAppContext(ActivityThread mainThread, LoadedApk packageInfo) {
        ContextImpl context = new ContextImpl(null, mainThread, packageInfo, null, null, null, 0,
                null);
        context.setResources(packageInfo.getResources());
        return context;
    }

Instrumentation#newApplication

    static public Application newApplication(Class<?> clazz, Context context)
            throws InstantiationException, IllegalAccessException, 
            ClassNotFoundException {
        Application app = (Application)clazz.newInstance();
        app.attach(context);
        return app;
    }

Application#attach

final void attach(Context context) {
      attachBaseContext(context);
}

Instrumentation#callApplicationOnCreate

   public void callApplicationOnCreate(Application app) {
        app.onCreate();
    }

可以看到只不过是先new了一个ContextImpl对象,然后通过反射创建了一个Application对象,并把contextimpl对象设置给了Application,然后调用了Application的onCreate等方法。所以我们完全可以手动new这个MyApp,然后把真正的context attach给MyApp,然后调用相关的方法即可。

所以我们可以这么做


public class MyApp extends TinkerCtxWrap {

    private static MyApp sInstance;

    @Override
    public void onCreate() {
        super.onCreate();
        sInstance = this;
        initNetWork();
        initFresco();
        initStetho();
        initXXX1();
        initXXX2();
        ActivityFetcher.init();
    }


    public String getAccount() {
        return "xxx";
    }

    public String getXXX() {
        return "xxx";
    }

    private void initXXX2() {
    }

    private void initXXX1() {
    }

    private void initNetWork() {
    }

    private void initFresco() {
    }

    private void initStetho() {
    }

    public static MyApp getApplication() {
        return sInstance;
    }
}


public class TinkerCtxWrap extends Application {

    @Override
    public void attachBaseContext(Context base) {
        super.attachBaseContext(base);
    }


    @Override
    public void registerComponentCallbacks(ComponentCallbacks callback) {
        Application application = getRealApplication();
        application.registerComponentCallbacks(callback);
    }

    @Override
    public void unregisterComponentCallbacks(ComponentCallbacks callback) {
        Application application = getRealApplication();
        application.unregisterComponentCallbacks(callback);
    }

    public void registerActivityLifecycleCallbacks(Application.ActivityLifecycleCallbacks callback) {
        Application application = getRealApplication();
        application.registerActivityLifecycleCallbacks(callback);
    }

    public void unregisterActivityLifecycleCallbacks(Application.ActivityLifecycleCallbacks callback) {
        Application application = getRealApplication();
        application.unregisterActivityLifecycleCallbacks(callback);
    }

    @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2)
    public void registerOnProvideAssistDataListener(Application.OnProvideAssistDataListener callback) {
        Application application = getRealApplication();
        application.registerOnProvideAssistDataListener(callback);
    }

    @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2)
    public void unregisterOnProvideAssistDataListener(Application.OnProvideAssistDataListener callback) {
        Application application = getRealApplication();
        application.unregisterOnProvideAssistDataListener(callback);
    }


    protected Application getRealApplication() {
        return (Application) getBaseContext().getApplicationContext();
    }
}




public class ApplicationLike extends DefaultApplicationLike {
    private TinkerCtxWrap ctxWrap;

    public ApplicationLike(Application application, int i, boolean b, long l, long l1, Intent intent) {
        super(application, i, b, l, l1, intent);
    }

    public void onCreate() {
        super.onCreate();
        ctxWrap.onCreate();
    }


    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        MultiDex.install(base);
        ctxWrap = new MyApp();
        ctxWrap.attachBaseContext(base);
    }


    @Override
    public void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        ctxWrap.onConfigurationChanged(newConfig);
    }

    @Override
    public void onLowMemory() {
        super.onLowMemory();
        ctxWrap.onLowMemory();
    }

    @Override
    public void onTerminate() {
        super.onTerminate();
        ctxWrap.onTerminate();
    }

    @Override
    public void onTrimMemory(int level) {
        super.onTrimMemory(level);
        ctxWrap.onTrimMemory(level);
    }
}



我们只是把MyApp的父类改成了TinkerCtxWrap,TinkerCtxWrap其实是继承自Application,并把attachBaseContext改成了public,然后新增了几个registerXXX等方法。 然后我们在ApplicationLike的onBaseContextAttached中先调用Multidex.install加载了所有dex文件,然后new了MyApp,并把context attach了进去,然后在ApplicationLike的生命周期方法中调用了MyApp的对应方法。这样我们就不需要把原来MyApp中的代码搬到ApplicationLike中了,并且MyApp还是继承自Application,需要application对象的地方仍旧可以使用MyApp.getApplication()获取。不过要注意,MyApp其实只是一个代理了,真正的Application其实是TinkerApplication,所以activity等中通过getApplicationContext得到的context就不能强转成MyApp了。

打包失败

Too many classes in --main-dex-list, main dex capacity exceeded

用了一年tinker,最近打包突然一直失败,不开启tinker打包可以成功,开启打包就提示Too many classes in --main-dex-list, main dex capacity exceeded。意思是主dex中类太多了,我们知道Application及引用的类会被打倒主dex中,没办法,只能精简一下,由于我们的业务代码最开始执行的地方是MyApp,所以理论上所有的业务代码都可以不在主dex中,所以我们把ApplicationLike的onBaseContextAttached改成了通过反射方式加载

 @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        MultiDex.install(base);
        try {
            ctxWrap = (TinkerCtxWrap) Class.forName("xx.xx.MyApp").newInstance();
        } catch (Exception e) {
            throw new RuntimeException("创建MyApp失败");
        }
        ctxWrap.attachBaseContext(base);
    }

本以为这样就不会把MyApp打到主dex中,从而所有的业务代码都不会打到主dex中,不过试了下还是打包失败。于是我们又改成了这样,唯一的区别就是new了一个String,同时我们还把stetho等线上包用不到的库去掉,终于打包成功了。


    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
        MultiDex.install(base);
        try {
            ctxWrap = (TinkerCtxWrap) Class.forName(new String("xx.xx.MyApp")).newInstance();
        } catch (Exception e) {
            throw new RuntimeException("创建MyApp失败");
        }
        ctxWrap.attachBaseContext(base);
    }

然后我们看了下编译时生成的maindexlist文件,里面列出了将近8000条!不开启tinker时这个文件中只有不到1000条,经过多次尝试,发现8000大概是上限,这就意味着我们下次发版时可能又无法打包了。

反编译了下勉强打包成功的apk包,发现主dex中有好多kotlin的类,貌似所有打了@SerializedName,@Deprecated等注解的类也都打到了主dex中,另外kotlin类上也打了一个@Metadata注解,这些注解有个共同特点,都是RUNTIME的,比如

package com.google.gson.annotations; 
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface SerializedName {
    String value();
    String[] alternate() default {};
}

怀疑所有打了这种运行时注解的类都会打到主dex中,于是下载了 tinker-patch-sample工程,通过gradle命令生成了2000个类,每个中30个方法,所有类上都打上 @Anno这个注解,@Anno这个注解是我们自定义的,定义如下

@Retention(RUNTIME)
@Target({TYPE})
public @interface Anno {
}

这时开起tinker打release包时问题复现了!一直打包失败,提示 Too many classes in --main-dex-list, main dex capacity exceeded ,当我们把Anno上的RUNTIME改成CLASS时,即

@Retention(CLASS)
@Target({TYPE})
public @interface Anno {
}

这时就可以打包成功!所以问题最终出在了tinker上,tinker会把所有打了RUNTIME注解的类打到主dex中,tinker官方一直没有解决方案。没办法,我们只能自己解决

分包

当然你可以把最低兼容版本改成api21,这时打包就没问了,不过如果不想丢弃5.0以下用户的话只能分包解决了。 gradle2.x可以使用dex-knife分包,不过我们是3.1.4,dex-knife分包可能存在兼容问题,gradle3.1.4打包时会生成一个maindexlist文件,这个文件决定了主dex中的类,我们只需要在生成这个文件后把不需要打包到主dex的类移除掉就可以了。maindexlist文件会在执行transformClassesWithMultidexlistForXXX task时生成,我们只需要在这个task执行后把maindexlist的文件内容换掉即可。

我们在app module下创建了一个main_dex_split_for_tinker.gradle文件,里面代码如下

project.afterEvaluate {
    //解决开启tinker时打包失败问题  Too many classes in --main-dex-list, main dex capacity exceeded。 exclude_class.txt中配置排除的类
    //一定要验证5.0以下android启动时是否崩溃!
    if (android.defaultConfig.minSdkVersion.getApiLevel() >= 21) {
        return
    }
    if (project.hasProperty("tinkerPatch") == false) {
        return
    }
    def configuration = project.tinkerPatch

    if (!configuration.tinkerEnable) {
        return
    }

    android.applicationVariants.all { variant ->

        def variantName = variant.name.capitalize()

        def multidexTask = project.tasks.findByName("transformClassesWithMultidexlistFor${variantName}")
        if (multidexTask != null) {
            def splitTask = createSplitDexTask(variant);
            multidexTask.finalizedBy splitTask
        }
    }


}


def createSplitDexTask(variant) {
    def variantName = variant.name.capitalize()

    return task("replace${variantName}MainDexClassList").doLast {

        //从主dex移除的列表
        def excludeClassList = []
        File excludeClassFile = new File("${project.projectDir}/exclude_class.txt")
        if (excludeClassFile.exists()) {
            excludeClassFile.eachLine { line ->
                if (!line.trim().isEmpty() && line.startsWith("#") == false) {
                    excludeClassList.add(line.trim())
                }
            }
            excludeClassList.unique()
        }

        def mainDexList = []
        File mainDexFile = new File("${project.buildDir}/intermediates/multi-dex/${variant.dirName}/maindexlist.txt")
        println "${project.buildDir}/intermediates/multi-dex/${variant.dirName}/maindexlist.txt exist: ${mainDexFile.exists()}"
        if (mainDexFile.exists()) {
            mainDexFile.eachLine { line ->
                if (!line.isEmpty()) {
                    mainDexList.add(line.trim())
                }
            }
            mainDexList.unique()
            if (!excludeClassList.isEmpty()) {
                def newMainDexList = mainDexList.findResults { mainDexItem ->
                    def isKeepMainDexItem = true
                    for (excludeClassItem in excludeClassList) {
                        if (mainDexItem.contains(excludeClassItem)) {
                            isKeepMainDexItem = false
                            break
                        }
                    }
                    if (isKeepMainDexItem) mainDexItem else null
                }
                if (newMainDexList.size() < mainDexList.size()) {
                    mainDexFile.delete()
                    mainDexFile.createNewFile()
                    mainDexFile.withWriterAppend { writer ->
                        newMainDexList.each {
                            writer << it << '\n'
                            writer.flush()
                        }
                    }
                }
            }
        }

    }
}


然后再app module下的build.gradle文件中引入上面的文件

apply from: "main_dex_split_for_tinker.gradle"

然后我们在app module下新建exclude_class.txt文件,用于配置需要从maindexlist中移除的类,例如

#类路径包含如下的,都会建议打包工具不要打到主dex中,但可能还会被打到主dex中。由于所有的业务代码都会在multidex.install后执行,理论上所有的业务代码都可以不在主dex中
com/facebook/fresco
com/facebook/drawee
com/facebook/imageformat
com/facebook/imagepipeline
com/alibaba
com/taobao
com/eclipsesource/v8

最终我们打包成功了,maindexlist中只剩了3000多个类,当然还可以更少,这样很长一段时间内我们不用担心打包时主dex超限了。测试了下兼容性良好,5.0以下也没有启动崩溃。

最近几天研究了下build gradle源码,发现根本不需要上面的分包代码,只要配置下就可以了 。默认会把所有打了运行时注解的类全部打到主dex中,可以通过如下配置禁止掉

android{
    dexOptions {
        keepRuntimeAnnotatedClasses false
    }
}

相关源码如下:


public class MainDexListBuilder {
    private static final String CLASS_EXTENSION = ".class";

    private static final int STATUS_ERROR = 1;

    private static final String EOL = System.getProperty("line.separator");

    private static final String USAGE_MESSAGE =
            "Usage:" + EOL + EOL +
            "Short version: Don't use this." + EOL + EOL +
            "Slightly longer version: This tool is used by mainDexClasses script to build" + EOL +
            "the main dex list." + EOL;

    /**
     By default we force all classes annotated with runtime annotation to be kept in the
      main dex list. This option disable the workaround, limiting the index pressure in the main
      dex but exposing to the Dalvik resolution bug. The resolution bug occurs when accessing
      annotations of a class that is not in the main dex and one of the annotations as an enum
      parameter.
     
     * @see <a href="https://code.google.com/p/android/issues/detail?id=78144">bug discussion</a>
     *
     */
    private static final String DISABLE_ANNOTATION_RESOLUTION_WORKAROUND =
            "--disable-annotation-resolution-workaround";

    private Set<String> filesToKeep = new HashSet<String>();

    public static void main(String[] args) {

        int argIndex = 0;
        boolean keepAnnotated = true;
        while (argIndex < args.length -2) {
            if (args[argIndex].equals(DISABLE_ANNOTATION_RESOLUTION_WORKAROUND)) {
                keepAnnotated = false;
            } else {
                System.err.println("Invalid option " + args[argIndex]);
                printUsage();
                System.exit(STATUS_ERROR);
            }
            argIndex++;
        }
        if (args.length - argIndex != 2) {
            printUsage();
            System.exit(STATUS_ERROR);
        }

        try {
            MainDexListBuilder builder = new MainDexListBuilder(keepAnnotated, args[argIndex],
                    args[argIndex + 1]);
            Set<String> toKeep = builder.getMainDexList();
            printList(toKeep);
        } catch (IOException e) {
            System.err.println("A fatal error occured: " + e.getMessage());
            System.exit(STATUS_ERROR);
            return;
        }
    }

    public MainDexListBuilder(boolean keepAnnotated, String rootJar, String pathString)
            throws IOException {
        ZipFile jarOfRoots = null;
        Path path = null;
        try {
            try {
                jarOfRoots = new ZipFile(rootJar);
            } catch (IOException e) {
                throw new IOException("\"" + rootJar + "\" can not be read as a zip archive. ("
                        + e.getMessage() + ")", e);
            }
            path = new Path(pathString);

            ClassReferenceListBuilder mainListBuilder = new ClassReferenceListBuilder(path);
            mainListBuilder.addRoots(jarOfRoots);
            for (String className : mainListBuilder.getClassNames()) {
                filesToKeep.add(className + CLASS_EXTENSION);
            }
            if (keepAnnotated) {
                keepAnnotated(path);
            }
        } finally {
            try {
                jarOfRoots.close();
            } catch (IOException e) {
                // ignore
            }
            if (path != null) {
                for (ClassPathElement element : path.elements) {
                    try {
                        element.close();
                    } catch (IOException e) {
                        // keep going, lets do our best.
                    }
                }
            }
        }
    }

    /**
     * Returns a list of classes to keep. This can be passed to dx as a file with --main-dex-list.
     */
    public Set<String> getMainDexList() {
        return filesToKeep;
    }

    private static void printUsage() {
        System.err.print(USAGE_MESSAGE);
    }

    private static void printList(Set<String> fileNames) {
        for (String fileName : fileNames) {
            System.out.println(fileName);
        }
    }

    /**
     * Keep classes annotated with runtime annotations.
     */
    private void keepAnnotated(Path path) throws FileNotFoundException {
        for (ClassPathElement element : path.getElements()) {
            forClazz:
                for (String name : element.list()) {
                    if (name.endsWith(CLASS_EXTENSION)) {
                        DirectClassFile clazz = path.getClass(name);
                        if (hasRuntimeVisibleAnnotation(clazz)) {
                            filesToKeep.add(name);
                        } else {
                            MethodList methods = clazz.getMethods();
                            for (int i = 0; i<methods.size(); i++) {
                                if (hasRuntimeVisibleAnnotation(methods.get(i))) {
                                    filesToKeep.add(name);
                                    continue forClazz;
                                }
                            }
                            FieldList fields = clazz.getFields();
                            for (int i = 0; i<fields.size(); i++) {
                                if (hasRuntimeVisibleAnnotation(fields.get(i))) {
                                    filesToKeep.add(name);
                                    continue forClazz;
                                }
                            }
                        }
                    }
                }
        }
    }

    private boolean hasRuntimeVisibleAnnotation(HasAttribute element) {
        Attribute att = element.getAttributes().findFirst(
                AttRuntimeVisibleAnnotations.ATTRIBUTE_NAME);
        return (att != null && ((AttRuntimeVisibleAnnotations)att).getAnnotations().size()>0);
    }
}

相关注释

 /**
     * Keep all classes with runtime annotations in the main dex in legacy multidex.
     *
     * <p>This is enabled by default and works around an issue that will cause the app to crash
     * when using java.lang.reflect.Field.getDeclaredAnnotations on older android versions.
     *
     * <p>This can be disabled for for apps that do not use reflection and need more space in their
     * main dex.
     *
     * <p>See <a href="http://b.android.com/78144">http://b.android.com/78144</a>.
     */
    @Override
    public boolean getKeepRuntimeAnnotatedClasses() {
        return keepRuntimeAnnotatedClasses;
    }

    public void setKeepRuntimeAnnotatedClasses(
            boolean keepRuntimeAnnotatedClasses) {
        this.keepRuntimeAnnotatedClasses = keepRuntimeAnnotatedClasses;
    }

关于