Android “退一步”的佈局加載優化

本文以授權個人公衆號「鴻洋」原創首發。

1.概述

在Android開發過程中,我們基本每天都在寫各種各樣的xml佈局文件,然後app會在運行時,將我們的佈局文件轉化成View顯示在界面上。

這個轉化,主要就是解析xml佈局文件,然後根據xml的中每個View標籤,將:

  1. 標籤名-> View的名稱
  2. 各種屬性 -> AttributeSet對象

然後反射調用View兩個參數的構造方法。

這也是爲什麼,我們在自定義控件的時候,如果需要在xml使用,需要複寫其兩參的構造函數。

這個設計確實極具擴展性,但是也引入了一定的性能問題。

可以很明顯的看到xml文件到View這個過程中,涉及到一些耗時操作:

  1. io 操作,xml解析;
  2. 反射;

尤其是真實項目中,一些頁面佈局元素非常多,那麼整個頁面幾十個控件可能都需要去反射生成。

所以很多時候,一些核心頁面,爲了提升構建速度,我們會考慮直接用代碼生成,來替代xml寫法,這樣做帶來一個最大的問題就是可維護性急劇下降。

在既想要可維護性又想要運行時效率的情況下,很多開發者想到,xml畢竟是非常有規律的文件,我們可以在編譯時解析成View,運行時直接拿到View,就能避免IO操作以及反射操作了。

確實,想法非常完美,github上也有一個由掌閱發佈的開源庫:

https://github.com/iReaderAndroid/X2C

x2c的想法非常好,基本上徹底解決了我上面提出的兩個耗時問題,但是引入了新的問題,就是兼容性和穩定性。

而且x2c生成代碼使用了apt,apt一個都是針對本module去做一些事情,涉及到複雜的module間依賴,就會遇到很多問題,x2c在apt這方面應該也做了很多處理,但是這些處理在遇到很多項目在編譯期做各種編譯優化的時候,就會摩擦出一些火花。

本文也會涉及到apt,因爲不涉及資源,也遇到一些問題,下文會說。

當然如果能夠引入x2c,並可自維護的情況下,其實是挺好的,我非常支持這個方案,就是有一定風險。

注:本文不討論到底哪個方案牛逼,博客更多的還是爲了學習,重點還是吸收每個方案包含的知識點,擴充自己的可用知識庫。

2.退一步

剛纔我們說了,完全託管xml->View這一過程具有一定的風險,那麼我們是否可退一步來看這個問題呢?

既然xml文件到View這個過程中,涉及到兩個耗時點:

  1. io 操作,xml解析;
  2. 反射;

xml解析我們不太好乾涉,這種看起來風險就高的東西還是交給Google自己吧,而且底層還涉及到有一些xmlblock的緩存邏輯。

那隻剩下一個反射操作了,這是個軟柿子嗎?

我們有辦法去除發射邏輯嗎?

當然有,大家肯定都再熟悉不過了。

如果關注本號,我們在16年就寫過:

探究 LayoutInflater setFactory

通過setFactory,我們不僅能夠控制View的生成,甚至可以把一個View變成另一個View,比如文中,我們把TextView變成了Button。

後續換膚、黑白化一些方案都基於此。

也就說我們現在可以:

運行時,接管某個View的生成,即針對單個View我們可以去除反射的邏輯了

類似代碼:

if ("LinearLayout".equals(name)){
    View view = new LinearLayout(context, attrs);
    return view;
}

但是,一般線上的項目都非常大,可能有各種各樣的自定義View,類似上面的if else,怎麼寫呢?

先收集起來,然後手寫?

怎麼收集項目中用到的所有的View的呢?

假設我們收集到了,手寫的話,項目一般都是增量的,後續新增的View怎麼辦呢?

可以看到我們面臨兩個問題:

  1. 如何收集項目中在xml中使用到的View;
  2. 如何保證寫出的View生成代碼,能夠兼容項目的正常迭代;

3. 確定方案

到這裏目標已經確定了。

在 xml -> View的過程中,去除反射相關邏輯

來說說我們面臨的兩個問題如何解決:

1. 如何收集項目中在xml中使用到的View;

收集所有在xml中用到的View,有個簡單的想法,我們可以解析項目中所有的layout.xml文件,不過項目中layout.xml文件每個模塊都有,而且有些依賴的aar,還需要解壓太難了。

細想一下,我們apk在生成過程中,資源應該需要merger吧,是不是解析某個Task merger後的產物即可。

確實有,後面詳細實施會提到。

下面看第二個問題:

2. 如何保證寫出的View生成代碼,能夠兼容項目的正常迭代;

我們已經能夠收集到所使用的,所有的View列表了,那麼針對這種:

if ("LinearLayout".equals(name)){
    View view = new LinearLayout(context, attrs);
    return view;
}

有規律又簡單的邏輯,完全可以在編譯時生成一個代碼類,完成相關轉化代碼生成,這裏選擇了apt。

有了xml -> View轉化邏輯的代碼類,最後只要在運行時,利用LayoutFactory注入即可。

3. 找一個穩妥的注入邏輯

大家都知道我們的View生成相關邏輯在LayoutInflater下面的代碼中:

View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
        boolean ignoreThemeAttr) {
  	 // ...
    View view;
    if (mFactory2 != null) {
        view = mFactory2.onCreateView(parent, name, context, attrs);
    } else if (mFactory != null) {
        view = mFactory.onCreateView(name, context, attrs);
    } else {
        view = null;
    }

    if (view == null && mPrivateFactory != null) {
        view = mPrivateFactory.onCreateView(parent, name, context, attrs);
    }

    if (view == null) {
        final Object lastContext = mConstructorArgs[0];
        mConstructorArgs[0] = context;
        try {
            if (-1 == name.indexOf('.')) {
                view = onCreateView(parent, name, attrs);
            } else {
                view = createView(name, null, attrs);
            }
        } finally {
            mConstructorArgs[0] = lastContext;
        }
    }

    return view;
   
}

View經過mFactory2,mFactory,mPrivateFactory,如果還不能完成構建,後面等它的就是反射了。

而前兩個factory,support包一般擴展功能會用,例如 TextView-> AppCompatTextView。

我們考慮利用mPrivateFactory,利用mPrivateFactory的好處就是,在目前的版本中mPrivateFactory就是Activity,所以我們只要複寫Activivity的onCreateView即可:

這樣完全不需要hook,也不干涉appcompat相關生成邏輯,可謂是0風險了。

4. 開始實施

1. 獲取項目中使用的控件名列表

我新建了一個項目,隨便寫了一些自定義控件叫MyMainView1,MyMainView,MyMainView3,MyMainView4都在layout文件中聲明瞭,就不貼布局文件了。

之前我們說了,我們要在apk的構建過程中去尋找合適的注入點完成這個事情。

那麼apk過程中,什麼時候會merge資源呢?

我們打印下構建過程中所有的task,輸入命令:

./gradlew  app:assembleDebug --console=plain

輸出:

>Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugManifest UP-TO-DATE
> Task :app:generateDebugBuildConfig UP-TO-DATE
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:mainApkListPersistenceDebug UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:mergeDebugShaders UP-TO-DATE
> Task :app:compileDebugShaders UP-TO-DATE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:compileDebugAidl NO-SOURCE
> Task :app:compileDebugRenderscript NO-SOURCE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:compileDebugSources UP-TO-DATE
> Task :app:mergeDebugAssets UP-TO-DATE
> Task :app:processDebugJavaRes NO-SOURCE
> Task :app:mergeDebugJavaResource UP-TO-DATE
> Task :app:transformClassesWithDexBuilderForDebug UP-TO-DATE
> Task :app:checkDebugDuplicateClasses UP-TO-DATE
> Task :app:validateSigningDebug UP-TO-DATE
> Task :app:mergeExtDexDebug UP-TO-DATE
> Task :app:mergeDexDebug UP-TO-DATE
> Task :app:signingConfigWriterDebug UP-TO-DATE
> Task :app:mergeDebugJniLibFolders UP-TO-DATE
> Task :app:mergeDebugNativeLibs UP-TO-DATE
> Task :app:stripDebugDebugSymbols UP-TO-DATE
> Task :app:packageDebug UP-TO-DATE
> Task :app:assembleDebug UP-TO-DATE

哪個最像呢? 一眼看有個叫:mergeDebugResources的Task,就它了。

與build目錄對應,也有個mergeDebugResources的目錄:

注意裏面有個merger.xml,其中就包含了整個項目所有資源合併後的內容。

我們打開看一眼:

重點關注裏面的type=layout的相關標籤。

<file name="activity_main1"
                path="/Users/zhanghongyang/work/TestViewOpt/app/src/main/res/layout/activity_main1.xml"
                qualifiers="" type="layout" />

可以看到包含了我們layout文件的路勁,那麼我們只要解析這個merger.xml,然後找到裏面所有type=layout的標籤,再解析出layout文件的實際路勁,再解析對應的layout xml就能拿到控件名了。

對了,這個任務要注入到mergeDebugResources後面執行。

怎麼注入一個任務呢?

非常簡單:

project.afterEvaluate {
    def mergeDebugResourcesTask = project.tasks.findByName("mergeDebugResources")
    if (mergeDebugResourcesTask != null) {
        def resParseDebugTask = project.tasks.create("ResParseDebugTask", ResParseTask.class)
        resParseDebugTask.isDebug = true
        mergeDebugResourcesTask.finalizedBy(resParseDebugTask);
    }

}

根目錄:view_opt.gradle

我們首先找到mergeDebugResources這個task,再其之後,注入一個ResParseTask的任務。

然後在ResParseTask中完成文件解析:



class ResParseTask extends DefaultTask {
    File viewNameListFile
    boolean isDebug
    HashSet<String> viewSet = new HashSet<>()
    // 自己根據輸出幾個添加
    List<String> ignoreViewNameList = Arrays.asList("include", "fragment", "merge", "view","DateTimeView")

    @TaskAction
    void doTask() {

        File distDir = new File(project.buildDir, "tmp_custom_views")
        if (!distDir.exists()) {
            distDir.mkdirs()
        }
        viewNameListFile = new File(distDir, "custom_view_final.txt")
        if (viewNameListFile.exists()) {
            viewNameListFile.delete()
        }
        viewNameListFile.createNewFile()
        viewSet.clear()
        viewSet.addAll(ignoreViewNameList)

        try {
            File resMergeFile = new File(project.buildDir, "/intermediates/incremental/merge" + (isDebug ? "Debug" : "Release") + "Resources/merger.xml")

            println("resMergeFile: ${resMergeFile.getAbsolutePath()} === ${resMergeFile.exists()}")

            if (!resMergeFile.exists()) {
                return
            }

            XmlSlurper slurper = new XmlSlurper()
            GPathResult result = slurper.parse(resMergeFile)
            if (result.children() != null) {
                result.childNodes().forEachRemaining({ o ->
                    if (o instanceof Node) {
                        parseNode(o)
                    }
                })
            }


        } catch (Throwable e) {
            e.printStackTrace()
        }

    }

    void parseNode(Node node) {
        if (node == null) {
            return
        }
        if (node.name() == "file" && node.attributes.get("type") == "layout") {
            String layoutPath = node.attributes.get("path")
            try {
                XmlSlurper slurper = new XmlSlurper()
                GPathResult result = slurper.parse(layoutPath)

                String viewName = result.name();
                if (viewSet.add(viewName)) {
                    viewNameListFile.append("${viewName}\n")
                }
                if (result.children() != null) {
                    result.childNodes().forEachRemaining({ o ->
                        if (o instanceof Node) {
                            parseLayoutNode(o)
                        }
                    })
                }
            } catch (Throwable e) {
                e.printStackTrace();
            }

        } else {
            node.childNodes().forEachRemaining({ o ->
                if (o instanceof Node) {
                    parseNode(o)
                }
            })
        }

    }

    void parseLayoutNode(Node node) {
        if (node == null) {
            return
        }
        String viewName = node.name()
        if (viewSet.add(viewName)) {
            viewNameListFile.append("${viewName}\n")
        }
        if (node.childNodes().size() <= 0) {
            return
        }
        node.childNodes().forEachRemaining({ o ->
            if (o instanceof Node) {
                parseLayoutNode(o)
            }
        })
    }

}

根目錄:view_opt.gradle

代碼很簡單,主要就是解析merger.xml,找到所有的layout文件,然後解析xml,最後輸出到build目錄中。

代碼我們都寫在view_opt.gradle,位於項目的根目錄,在app的build.gradle中apply即可:

apply from: rootProject.file('view_opt.gradle')

然後我們再次運行assembleDebug,輸出:

注意,上面我們還有個ignoreViewNameList對象,我們過濾了一些特殊標籤,例如:“include”, “fragment”, “merge”, “view”,你可以根據輸出結果自行添加。

輸出結果爲:

可以看到是去重後的View的名稱。

這裏提一下,有很多同學看到寫gradle腳本就感覺恐懼,其實很簡單,你就當寫Java就行了,不熟悉的語法就用Java寫就好了,沒什麼特殊的。

到這裏我們就有了所有使用到的View的名稱。

2. apt 生成代理類

有了所有用到的View的名稱,接下來我們利用apt生成一個代理類,以及代理方法。

要用到apt,那麼我們需要新建3個模塊:

  1. ViewOptAnnotation: 存放註解;
  2. ViewOptProcessor:放註解處理器相關代碼;
  3. ViewOptApi:放相關使用API的。

關於Apt的相關基礎知識就不提了哈,這塊知識太雜了,大家自己查閱下,後面我把demo傳到github大家自己看。

我們就直接看我們最核心的Processor類了:

@AutoService(Processor.class)
public class ViewCreatorProcessor extends AbstractProcessor {

    private Messager mMessager;


    @Override
    public synchronized void init(ProcessingEnvironment processingEnvironment) {
        super.init(processingEnvironment);
        mMessager = processingEnv.getMessager();
    }

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {

        Set<? extends Element> classElements = roundEnvironment.getElementsAnnotatedWith(ViewOptHost.class);

        for (Element element : classElements) {
            TypeElement classElement = (TypeElement) element;
            ViewCreatorClassGenerator viewCreatorClassGenerator = new ViewCreatorClassGenerator(processingEnv, classElement, mMessager);
            viewCreatorClassGenerator.getJavaClassFile();
            break;
        }
        return true;

    }

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> types = new LinkedHashSet<>();
        types.add(ViewOptHost.class.getCanonicalName());
        return types;
    }

}

核心方法就是process了,直接交給了ViewCreatorClassGenerator去生成我們的Java類了。

看之前我們思考下我們的邏輯,其實我們這個代理類非常簡單,我們只要構建好我們的類名,方法名,方法內部,根據View名稱的列表去寫swicth就可以了。

看代碼:

定義類名:

public ViewCreatorClassGenerator(ProcessingEnvironment processingEnv, TypeElement classElement, Messager messager) {
        mProcessingEnv = processingEnv;
        mMessager = messager;
        mTypeElement = classElement;
        PackageElement packageElement = processingEnv.getElementUtils().getPackageOf(classElement);
        String packageName = packageElement.getQualifiedName().toString();
        //classname
        String className = ClassValidator.getClassName(classElement, packageName);

        mPackageName = packageName;
        mClassName = className + "__ViewCreator__Proxy";
    }

我們類名就是使用註解的類名後拼接__ViewCreator__Proxy

生成類主體結構:

public void getJavaClassFile() {

    Writer writer = null;
    try {
        JavaFileObject jfo = mProcessingEnv.getFiler().createSourceFile(
                mClassName,
                mTypeElement);

        String classPath = jfo.toUri().getPath();

        String buildDirStr = "/app/build/";
        String buildDirFullPath = classPath.substring(0, classPath.indexOf(buildDirStr) + buildDirStr.length());
        File customViewFile = new File(buildDirFullPath + "tmp_custom_views/custom_view_final.txt");

        HashSet<String> customViewClassNameSet = new HashSet<>();
        putClassListData(customViewClassNameSet, customViewFile);

        String generateClassInfoStr = generateClassInfoStr(customViewClassNameSet);

        writer = jfo.openWriter();
        writer.write(generateClassInfoStr);
        writer.flush();

        mMessager.printMessage(Diagnostic.Kind.NOTE, "generate file path : " + classPath);

    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        if (writer != null) {
            try {
                writer.close();
            } catch (IOException e) {
                // ignore
            }
        }
    }
}

這裏首先我們讀取了,我們剛纔生成的tmp_custom_views/custom_view_final.txt,存放到了一個hashSet中。

然後交給了generateClassInfoStr方法:

private String generateClassInfoStr(HashSet<String> customViewClassNameSet) {

    StringBuilder builder = new StringBuilder();
    builder.append("// Generated code. Do not modify!\n");
    builder.append("package ").append(mPackageName).append(";\n\n");
    builder.append("import com.zhy.demo.viewopt.*;\n");
    builder.append("import android.content.Context;\n");
    builder.append("import android.util.AttributeSet;\n");
    builder.append("import android.view.View;\n");


    builder.append('\n');

    builder.append("public class ").append(mClassName).append(" implements " + sProxyInterfaceName);
    builder.append(" {\n");

    generateMethodStr(builder, customViewClassNameSet);
    builder.append('\n');

    builder.append("}\n");
    return builder.toString();

}

可以看到這裏其實就是拼接了類的主體結構。

詳細的方法生成邏輯:

private void generateMethodStr(StringBuilder builder, HashSet<String> customViewClassNameSet) {

    builder.append("@Override\n ");
    builder.append("public View createView(String name, Context context, AttributeSet attrs ) {\n");


    builder.append("switch(name)");
    builder.append("{\n"); // switch start

    for (String className : customViewClassNameSet) {
        if (className == null || className.trim().length() == 0) {
            continue;
        }
        builder.append("case \"" + className + "\" :\n");
        builder.append("return new " + className + "(context,attrs);\n");
    }

    builder.append("}\n"); //switch end

    builder.append("return null;\n");
    builder.append("  }\n"); // method end

}

一個for循環就搞定了。

我們現在運行下:

會在項目的如下目錄生成代理類:

類內容:

// Generated code. Do not modify!
package com.zhy.demo.viewopt;

import com.zhy.demo.viewopt.*;

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;

public class ViewOpt__ViewCreator__Proxy implements IViewCreator {
    @Override
    public View createView(String name, Context context, AttributeSet attrs) {
        switch (name) {
            case "androidx.appcompat.widget.FitWindowsLinearLayout":
                return new androidx.appcompat.widget.FitWindowsLinearLayout(context, attrs);
            case "androidx.appcompat.widget.AlertDialogLayout":
                return new androidx.appcompat.widget.AlertDialogLayout(context, attrs);
            case "androidx.core.widget.NestedScrollView":
                return new androidx.core.widget.NestedScrollView(context, attrs);
            case "android.widget.Space":
                return new android.widget.Space(context, attrs);
            case "androidx.appcompat.widget.DialogTitle":
                return new androidx.appcompat.widget.DialogTitle(context, attrs);
            case "androidx.appcompat.widget.ButtonBarLayout":
                return new androidx.appcompat.widget.ButtonBarLayout(context, attrs);
            case "androidx.appcompat.widget.ActionMenuView":
                return new androidx.appcompat.widget.ActionMenuView(context, attrs);
            case "androidx.appcompat.view.menu.ExpandedMenuView":
                return new androidx.appcompat.view.menu.ExpandedMenuView(context, attrs);
            case "Button":
                return new Button(context, attrs);
            case "androidx.appcompat.widget.ActionBarContainer":
                return new androidx.appcompat.widget.ActionBarContainer(context, attrs);
            case "TextView":
                return new TextView(context, attrs);
            case "ImageView":
                return new ImageView(context, attrs);
            case "Space":
                return new Space(context, attrs);
            case "androidx.appcompat.widget.FitWindowsFrameLayout":
                return new androidx.appcompat.widget.FitWindowsFrameLayout(context, attrs);
            case "androidx.appcompat.widget.ContentFrameLayout":
                return new androidx.appcompat.widget.ContentFrameLayout(context, attrs);
            case "CheckedTextView":
                return new CheckedTextView(context, attrs);
            case "DateTimeView":
                return new DateTimeView(context, attrs);
            case "androidx.appcompat.widget.ActionBarOverlayLayout":
                return new androidx.appcompat.widget.ActionBarOverlayLayout(context, attrs);
            case "androidx.appcompat.view.menu.ListMenuItemView":
                return new androidx.appcompat.view.menu.ListMenuItemView(context, attrs);
            case "androidx.appcompat.widget.ViewStubCompat":
                return new androidx.appcompat.widget.ViewStubCompat(context, attrs);
            case "RadioButton":
                return new RadioButton(context, attrs);
            case "com.example.testviewopt.view.MyMainView4":
                return new com.example.testviewopt.view.MyMainView4(context, attrs);
            case "com.example.testviewopt.view.MyMainView3":
                return new com.example.testviewopt.view.MyMainView3(context, attrs);
            case "View":
                return new View(context, attrs);
            case "com.example.testviewopt.view.MyMainView2":
                return new com.example.testviewopt.view.MyMainView2(context, attrs);
            case "androidx.appcompat.widget.ActionBarContextView":
                return new androidx.appcompat.widget.ActionBarContextView(context, attrs);
            case "com.example.testviewopt.view.MyMainView1":
                return new com.example.testviewopt.view.MyMainView1(context, attrs);
            case "ViewStub":
                return new ViewStub(context, attrs);
            case "ScrollView":
                return new ScrollView(context, attrs);
            case "Chronometer":
                return new Chronometer(context, attrs);
            case "androidx.constraintlayout.widget.ConstraintLayout":
                return new androidx.constraintlayout.widget.ConstraintLayout(context, attrs);
            case "CheckBox":
                return new CheckBox(context, attrs);
            case "androidx.appcompat.view.menu.ActionMenuItemView":
                return new androidx.appcompat.view.menu.ActionMenuItemView(context, attrs);
            case "FrameLayout":
                return new FrameLayout(context, attrs);
            case "RelativeLayout":
                return new RelativeLayout(context, attrs);
            case "androidx.appcompat.widget.Toolbar":
                return new androidx.appcompat.widget.Toolbar(context, attrs);
            case "LinearLayout":
                return new LinearLayout(context, attrs);
        }
        return null;
    }

}

看起來很完美…

不過目前是報錯狀態,報什麼錯呢?

錯誤: 找不到符號
return new Button(context,attrs);
           ^
  符號:   類 Button
  位置: 類 ViewOpt__ViewCreator__Proxy

我們注意到這些系統控件沒有導包。

比如Button,應該是:android.widget.Button。

那麼我們可以選擇

import android.widget.*

不過有個問題,你會發現,android的View並不是都在android.widget下,例如View在android.view下,WebView在android.webkit下面。

所以我們要把這三個包都導入。

這個時候,你會不會有疑問,系統也只能通過xml拿到TextView,他咋知道是android.widget.LinearLayout還是android.view.LinearLayout?

難不成一個個嘗試反射?

是的,你沒猜錯,LayoutInflater運行時的對象爲:PhoneLayoutInflater,你看源碼就知道了:

public class PhoneLayoutInflater extends LayoutInflater {
    private static final String[] sClassPrefixList = {
        "android.widget.",
        "android.webkit.",
        "android.app."
    };

 
    public PhoneLayoutInflater(Context context) {
        super(context);
    }

    protected PhoneLayoutInflater(LayoutInflater original, Context newContext) {
        super(original, newContext);
    }


    @Override protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
        for (String prefix : sClassPrefixList) {
            try {
                View view = createView(name, prefix, attrs);
                if (view != null) {
                    return view;
                }
            } catch (ClassNotFoundException e) {
                // In this case we want to let the base class take a crack
                // at it.
            }
        }

        return super.onCreateView(name, attrs);
    }

    public LayoutInflater cloneInContext(Context newContext) {
        return new PhoneLayoutInflater(this, newContext);
    }
}

循環拼接前綴遍歷…

不過怎麼沒看到android.view.這個前綴,嗯,在super.onCreateView裏面:

#LayoutInflater
protected View onCreateView(String name, AttributeSet attrs)
        throws ClassNotFoundException {
    return createView(name, "android.view.", attrs);
}

ok,這個時候,你可能還會遇到一些系統hide View找不到的情況,主要是因爲你本地的android.jar裏面沒有那些hide View對應的class,所以編譯不過,這種極少數,你可以選擇在剛纔過濾的List裏面添加一下。

好了,到這裏我們的代理類:

ViewOpt__ViewCreator__Proxy

生成了。

3. 編寫生成View的代碼

@ViewOptHost
public class ViewOpt {

    private static volatile IViewCreator sIViewCreator;

    static {
        try {
            String ifsName = ViewOpt.class.getName();
            String proxyClassName = String.format("%s__ViewCreator__Proxy", ifsName);
            Class proxyClass = Class.forName(proxyClassName);
            Object proxyInstance = proxyClass.newInstance();
            if (proxyInstance instanceof IViewCreator) {
                sIViewCreator = (IViewCreator) proxyInstance;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }

    public static View createView(String name, Context context, AttributeSet attrs) {


        try {
            if (sIViewCreator != null) {
                View view = sIViewCreator.createView(name, context, attrs);
                if (view != null) {
                    Log.d("lmj", name + " 攔截生成");
                }
                return view;
            }
        } catch (Throwable ex) {
            ex.printStackTrace();
        }

        return null;
    }

}

其實就是反射我們剛纔的生成的代理類對象,拿到它的實例。

然後強轉爲IViewCreator對象,這樣我們後續直接 sIViewCreator.createView 調用就可以了。

這裏大家有沒有看到一個知識點:

就是爲什麼apt生成的代理類,總會讓它去繼承某個類或者實現每個接口?

這樣在後續調用代碼的時候就不需要反射了。

有了生成View的邏輯,然後注入到mPrivaryFactory就可以了,其實就是我們的Activity,找到你項目中的BaseActivity:

public class BaseActivity extends AppCompatActivity {

    @Override
    public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        View view = ViewOpt.createView(name, context, attrs);
        if (view != null) {
            return view;
        }
        return super.onCreateView(parent, name, context, attrs);
    }
}

流程結束。

運行下,可以看下log:

2020-05-31 18:07:26.300 31454-31454/? D/lmj: LinearLayout 攔截生成
2020-05-31 18:07:26.300 31454-31454/? D/lmj: ViewStub 攔截生成
2020-05-31 18:07:26.300 31454-31454/? D/lmj: FrameLayout 攔截生成
2020-05-31 18:07:26.305 31454-31454/? D/lmj: androidx.appcompat.widget.ActionBarOverlayLayout 攔截生成
2020-05-31 18:07:26.306 31454-31454/? D/lmj: androidx.appcompat.widget.ContentFrameLayout 攔截生成
2020-05-31 18:07:26.311 31454-31454/? D/lmj: androidx.appcompat.widget.ActionBarContainer 攔截生成
2020-05-31 18:07:26.318 31454-31454/? D/lmj: androidx.appcompat.widget.Toolbar 攔截生成
2020-05-31 18:07:26.321 31454-31454/? D/lmj: androidx.appcompat.widget.ActionBarContextView 攔截生成
2020-05-31 18:07:26.347 31454-31454/? D/lmj: androidx.constraintlayout.widget.ConstraintLayout 攔截生成

對應的佈局:

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

有沒有很奇怪…

哪來的LinearLayout,ViewStub這些?

其實就是我們Activity的decorView對應的佈局文件裏面的。

爲啥沒有TextView?

因爲TextView並support庫攔截了,生成了AppcompatTextView,也是new的,早不需要走反射邏輯了。

ok,初步完工。

5. 一個潛在的問題

經過gradle,apt,以及對於LayoutInflater流程的瞭解,我們把相關知識拼接在一起,完成了這次佈局優化。

是不是還挺有成就感的。

不過,如果大家有對apt特別熟悉的,應該會發現一個潛在的問題。

什麼問題呢?

我們現在新建兩個library的module,讓:

app implementation lib1
lib1 implementation lib2

在lib2裏面寫個自定義控件。

我們在lib2中自定義一個控件Lib2View,然後在lib2的layout中引用。

lib2的xml:
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".Lib2Activity">

    <com.example.lib2.Lib2View
        android:layout_width="match_parent"
        android:layout_height="match_parent"></com.example.lib2.Lib2View>

</androidx.constraintlayout.widget.ConstraintLayout>

然後我們再次執行app:assembleDebug:

你會發現,報錯了:

ViewOpt__ViewCreator__Proxy.java:47: 錯誤: 找不到符號
return new com.example.lib2.Lib2View(context,attrs);
                           ^
  符號:   類 Lib2View
  位置: 程序包 com.example.lib2

錯誤的原因是,雖然我們收集到了Lib2View,也生成了相關方法,代碼我們訪問不到這個類。

爲什麼訪問不到呢?

因爲我們用了implementation,再看一眼:

app implementation lib1
lib1 implementation lib2

implementation隔離了app對lib2的類引用,雖然打包後大家都能正常訪問,但是在編譯期間是訪問不到的。

這就是我說的apt存在的問題,apt主要是針對單個module的,對於這種多module並不是很合適。

所以,如果用Transfrom來做相關類生成,就不會有類似問題。

但是,博客我都寫到這了,你讓我換方案?

如果我們改成api,或者之前的compile,都能編譯通過。

那麼能不能在打包到時候,把implementation動態的換成api呢?

經過一頓對gradle API的摸索,發現是支持的:

project.afterEvaluate {
    android.libraryVariants.all { variant ->
        def variantName = variant.name
        def depSet = new HashSet()
        tasks.all {
            if ("assemble${variantName.capitalize()}".equalsIgnoreCase(it.name)) {
                project.configurations.each { conf ->
                    if (conf.name == "implementation") {
                        conf.dependencies.each { dep ->
                            depSet.add(dep)
                            project.dependencies.add("api", dep)
                        }
                        depSet.each {
                            conf.dependencies.remove(it)
                        }
                    }
                }
            }
        }
    }
}

我們可以將implementation,添加到api中。

將上述腳本apply到你的library的build.gradle中即可。

還留了一些問題

文中我們是以assembleDebug來演示的,那麼release打包怎麼辦呢?

release只需要修改一個地方,就是merger.xml,不在mergeDebugResources下了,而在mergeReleaseResources下面了。

其次因爲你的代理類需要反射,注意keep相關類就好了。

這個我就不特別幫大家處理了,如果release你搞不定,我建議你別實施這個方案了,先學習文章中相關知識點吧。

6. Google也在做類似的事情

看來Google也意識到layout構建的耗時了。

https://cs.android.com/android/platform/superproject/+/master:frameworks/base/startop/view_compiler/README.md;bpv=0;bpt=0

可以看到Google在做View Compiler的相關事情,不過目前尚未開啓,對應到運行時源碼中,應該是:

public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
    final Resources res = getContext().getResources();
    if (DEBUG) {
        Log.d(TAG, "INFLATING from resource: \"" + res.getResourceName(resource) + "\" ("
              + Integer.toHexString(resource) + ")");
    }

    View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
    if (view != null) {
        return view;
    }
    XmlResourceParser parser = res.getLayout(resource);
    try {
        return inflate(parser, root, attachToRoot);
    } finally {
        parser.close();
    }
}

可以看到inflate中多了一個tryInflatePrecompiled方法,看起來是可以直接給一個layout id,返回一個構建好的View。

期待後續該方案上線。

7. 總結

最近推送了非常多的性能優化的文案,大家也吐槽都不是實戰,感覺像理論。

於是花了一點時間分享一個View構建這一塊的優化給大家,可以看到就這麼點功能,其實我們涉及到了:

  1. gradle 構建相關知識;
  2. apt 相關知識;
  3. LayooutFactory相關知識;

雖然我們遇到了一些挫折,比如文章最後說到的apt生成的類,無法訪問非傳遞依賴模塊中的類,不過我們還是解決了。

有時候遇到問題,我就安慰自己:

感覺又能學到一點東西了

沒什麼一直順利的事情,正確面對問題,只有不斷的遇到問題、解決問題,才能成長。

另外,建議大家日常積累知識點,不要看到文章,發現自己不熟悉就不想看,看到自己其實早就清楚的,看的津津有味…

demo地址:
https://github.com/hongyangAndroid/ViewOptDemo

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章