Android性能優(yōu)化 | 把構建布局耗時縮短 20 倍(上)

xml 布局文件是如何變成 View 并填入 View 樹的?帶著這個問題,閱讀源碼穿挨,居然發(fā)現(xiàn)了一個優(yōu)化布局構建時間的方案。

這是 Android 性能優(yōu)化系列文章的第三篇肴盏,文章列表如下:

  1. Android性能優(yōu)化 | 幀動畫OOM科盛?優(yōu)化幀動畫之 SurfaceView逐幀解析
  2. Android性能優(yōu)化 | 大圖做幀動畫卡頓?優(yōu)化幀動畫之 SurfaceView滑動窗口式幀復用
  3. Android性能優(yōu)化 | 把構建布局用時縮短 20 倍(上)
  4. Android性能優(yōu)化 | 把構建布局用時縮短 20 倍(下)

布局構建耗時是優(yōu)化 Activity 啟動速度中不可缺少的一個環(huán)節(jié)菜皂。

欲優(yōu)化贞绵,先度量。有啥辦法可以精確地度量布局耗時恍飘?

讀布局文件

以熟悉的setContentView()為切入點榨崩,看看有沒有突破口:

public class AppCompatActivity
    @Override
    public void setContentView(View view) {
        getDelegate().setContentView(view);
    }
}

點開setContentView()源碼,它的實現(xiàn)交給了一個代理章母,沿著調(diào)用鏈往下追查母蛛,最終的實現(xiàn)代碼在AppCompatDelegateImpl中:

class AppCompatDelegateImpl{
    @Override
    public void setContentView(int resId) {
        ensureSubDecor();
        //'1.從頂層視圖獲得content視圖'
        ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
        //'2.移除所有子視圖'
        contentParent.removeAllViews();
        //'3.解析布局文件并填充到content視圖中'
        LayoutInflater.from(mContext).inflate(resId, contentParent);
        mAppCompatWindowCallback.getWrapped().onContentChanged();
    }
}

這三部中,最耗時操作應該是“解析布局文件”胳施,點進去看看:

public abstract class LayoutInflater {
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();
        ...
        //'獲取布局文件解析器'
        final XmlResourceParser parser = res.getLayout(resource);
        try {
            //'填充布局'
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }
}

先調(diào)用了getLayout()獲取了和布局文件對應的解析器溯祸,沿著調(diào)用鏈繼續(xù)追查:

public class ResourcesImpl {
    XmlResourceParser loadXmlResourceParser(@NonNull String file, @AnyRes int id, int assetCookie,@NonNull String type) throws NotFoundException {
        if (id != 0) {
            try {
                synchronized (mCachedXmlBlocks) {
                    ...
                    //'通過AssetManager獲取布局文件對象'
                    final XmlBlock block = mAssets.openXmlBlockAsset(assetCookie, file);
                    if (block != null) {
                        final int pos = (mLastCachedXmlBlockIndex + 1) % num;
                        mLastCachedXmlBlockIndex = pos;
                        final XmlBlock oldBlock = cachedXmlBlocks[pos];
                        if (oldBlock != null) {
                            oldBlock.close();
                        }
                        cachedXmlBlockCookies[pos] = assetCookie;
                        cachedXmlBlockFiles[pos] = file;
                        cachedXmlBlocks[pos] = block;
                        return block.newParser();
                    }
                }
            } catch (Exception e) {
                ...
            }
        }
        ...
    }
}

沿著調(diào)用鏈,最終走到了ResourcesImpl.loadXmlResourceParser()舞肆,它通過AssetManager.openXmlBlockAsset()將 xml 布局文件轉(zhuǎn)化成 Java 對象XmlBlock

public final class AssetManager implements AutoCloseable {
    @NonNull XmlBlock openXmlBlockAsset(int cookie, @NonNull String fileName) throws IOException {
        Preconditions.checkNotNull(fileName, ”fileName“);
        synchronized (this) {
            ensureOpenLocked();
            //'打開 xml 布局文件'
            final long xmlBlock = nativeOpenXmlAsset(mObject, cookie, fileName);
            if (xmlBlock == 0) {
                //'若打開失敗則拋文件未找到異常'
                throw new FileNotFoundException(“Asset XML file: ” + fileName);
            }
            final XmlBlock block = new XmlBlock(this, xmlBlock);
            incRefsLocked(block.hashCode());
            return block;
        }
    }
}

通過一個 native 方法焦辅,將布局文件讀取到內(nèi)存。走查到這里椿胯,有一件事可以確定筷登,即 “解析 xml 布局文件前需要進行 IO 操作,將其讀取至內(nèi)存中”哩盲。

解析布局文件

讀原碼就好像“遞歸”前方,剛才通過不斷地“遞”狈醉,現(xiàn)在通過“歸”回到那個關鍵方法:

public abstract class LayoutInflater {
    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
        final Resources res = getContext().getResources();
        ...
        //'獲取布局文件解析器'
        final XmlResourceParser parser = res.getLayout(resource);
        try {
            //'填充布局'
            return inflate(parser, root, attachToRoot);
        } finally {
            parser.close();
        }
    }
}

通過 IO 操作將布局文件讀到內(nèi)存后,調(diào)用了inflate()

public abstract class LayoutInflater {
    public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
        synchronized (mConstructorArgs) {
            ...
            try {
                    //'根據(jù)布局文件的聲明控件的標簽構建 View'
                    final View temp = createViewFromTag(root, name, inflaterContext, attrs);

                    ViewGroup.LayoutParams params = null;

                    //'構建 View 對應的布局參數(shù)'
                    if (root != null) {
                        // Create layout params that match root, if supplied
                        params = root.generateLayoutParams(attrs);
                        if (!attachToRoot) {
                            // Set the layout params for temp if we are not
                            // attaching. (If we are, we use addView, below)
                            temp.setLayoutParams(params);
                        }
                    }

                    ...
                    //'將 View 填充到 View 樹'
                    if (root != null && attachToRoot) {
                        root.addView(temp, params);
                    }
                    ...
            } catch (XmlPullParserException e) {
                ...
            }  finally {
                ...
            }
            return result;
        }
    }

這個方法解析布局文件并根據(jù)其中聲明控件的標簽構建 View實例惠险,然后將其填充到 View 樹中苗傅。解析布局文件的細節(jié)在createViewFromTag()中:

public abstract class LayoutInflater {
    View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,boolean ignoreThemeAttr) {
        ...

        try {
            View view;
            //'通過Factory2.onCreateView()構建 View'
            if (mFactory2 != null) {
                view = mFactory2.onCreateView(parent, name, context, attrs);
            }
            ...
            return view;
        } catch (InflateException e) {
            throw e;

        } 
        ...
    }
}

onCreateView()的具體實現(xiàn)在AppCompatDelegateImpl中:

class AppCompatDelegateImpl{
    @Override
    public final View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
        return createView(parent, name, context, attrs);
    }
    
    @Override
    public View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs) {
        if (mAppCompatViewInflater == null) {
            TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme);
            String viewInflaterClassName =
                    a.getString(R.styleable.AppCompatTheme_viewInflaterClass);
            if ((viewInflaterClassName == null){
                ...
            } else {
                try {
                    //'通過反射獲取AppCompatViewInflater實例'
                    Class<?> viewInflaterClass = Class.forName(viewInflaterClassName);
                    mAppCompatViewInflater =
                            (AppCompatViewInflater) viewInflaterClass.getDeclaredConstructor()
                                    .newInstance();
                } catch (Throwable t) {
                    ...
                }
            }
        }

        boolean inheritContext = false;
        if (IS_PRE_LOLLIPOP) {
            inheritContext = (attrs instanceof XmlPullParser)
                    // If we have a XmlPullParser, we can detect where we are in the layout
                    ? ((XmlPullParser) attrs).getDepth() > 1
                    // Otherwise we have to use the old heuristic
                    : shouldInheritContext((ViewParent) parent);
        }

        //'通過createView()創(chuàng)建View實例'
        return mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext,
                IS_PRE_LOLLIPOP, /* Only read android:theme pre-L (L+ handles this anyway) */
                true, /* Read read app:theme as a fallback at all times for legacy reasons */
                VectorEnabledTintResources.shouldBeUsed() /* Only tint wrap the context if enabled */
        );
    }
}

AppCompatDelegateImpl又把構建 View 委托給了 AppCompatViewInflater.createView()

 final View createView(View parent, final String name, @NonNull Context context,
            @NonNull AttributeSet attrs, boolean inheritContext,
            boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
        final Context originalContext = context;
        ...
        View view = null;

        //'以布局文件中控件的名稱分別創(chuàng)建對應控件實例'
        switch (name) {
            case "TextView":
                view = createTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ImageView":
                view = createImageView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "Button":
                view = createButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "EditText":
                view = createEditText(context, attrs);
                verifyNotNull(view, name);
                break;
            case "Spinner":
                view = createSpinner(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ImageButton":
                view = createImageButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "CheckBox":
                view = createCheckBox(context, attrs);
                verifyNotNull(view, name);
                break;
            case "RadioButton":
                view = createRadioButton(context, attrs);
                verifyNotNull(view, name);
                break;
            case "CheckedTextView":
                view = createCheckedTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "AutoCompleteTextView":
                view = createAutoCompleteTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "MultiAutoCompleteTextView":
                view = createMultiAutoCompleteTextView(context, attrs);
                verifyNotNull(view, name);
                break;
            case "RatingBar":
                view = createRatingBar(context, attrs);
                verifyNotNull(view, name);
                break;
            case "SeekBar":
                view = createSeekBar(context, attrs);
                verifyNotNull(view, name);
                break;
            case "ToggleButton":
                view = createToggleButton(context, attrs);
                verifyNotNull(view, name);
                break;
            default:
                view = createView(context, name, attrs);
        }
        ...
        return view;
    }
    
    //'構建 AppCompatTextView 實例'
    protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
        return new AppCompatTextView(context, attrs);
    }
    ...
}

沒想到,最終居然是通過switch-case的方法來 new View 實例班巩。

而且我們沒有必要手動將布局文件中的TextView都換成AppCompatTextView渣慕,只要使用AppCompatActivity,它在Factory2.onCreateView()接口中完成了控件轉(zhuǎn)換抱慌。

測量構建布局耗時

通過上面的分析逊桦,可以得出兩條結論:

1. Activity 構建布局時,需要先進行 IO 操作抑进,將布局文件讀取至內(nèi)存中强经。

2. 遍歷內(nèi)存布局文件中每一個標簽,并根據(jù)標簽名 new 出對應視圖實例寺渗,再把它們 addView 到 View 樹中匿情。

這兩個步驟都是耗時的!到底有多耗時呢信殊?

LayoutInflaterCompat提供了setFactory2()码秉,可以攔截布局文件中每一個 View 的創(chuàng)建過程:

class Factory2Activity : AppCompatActivity() {
    private var sum: Double = 0.0

    @ExperimentalTime
    override fun onCreate(savedInstanceState: Bundle?) {
        LayoutInflaterCompat.setFactory2(LayoutInflater.from(this@Factory2Activity), object : LayoutInflater.Factory2 {
            
            override fun onCreateView(parent: View?, name: String?, context: Context?, attrs: AttributeSet?): View? {
                //'測量構建單個View耗時'
                val (view, duration) = measureTimedValue { delegate.createView(parent, name, context!!, attrs!!) }
                //'累加構建視圖耗時'
                sum += duration.inMilliseconds
                Log.v(“test”, “view=${view?.let { it::class.simpleName }} duration=${duration}  sum=${sum}”)
                return view
            }

            //'該方法用于兼容Factory,直接返回null就好'
            override fun onCreateView(name: String?, context: Context?, attrs: AttributeSet?): View? {
                return null
            }
        })
        super.onCreate(savedInstanceState)
        setContentView(R.layout.factory2_activity2)
    }
}

super.onCreate(savedInstanceState)之前鸡号,將自定義的Factory2接口注入到LayoutInflaterCompat中。

調(diào)用delegate.createView(parent, name, context!!, attrs!!)须鼎,就是手動觸發(fā)源碼中構建布局的邏輯。

measureTimedValue()是 Kotlin 提供的庫方法,用于測量一個方法的耗時摇邦,定義如下:

public inline fun <T> measureTimedValue(block: () -> T): TimedValue<T> {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    //'委托給MonoClock'
    return MonoClock.measureTimedValue(block)
}

public inline fun <T> Clock.measureTimedValue(block: () -> T): TimedValue<T> {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }

    val mark = markNow()
    //'執(zhí)行原方法'
    val result = block()
    return TimedValue(result, mark.elapsedNow())
}

public data class TimedValue<T>(val value: T, val duration: Duration)

方法返回一個TimedValue對象茫虽,其第一個屬性是原方法的返回值,第二個是執(zhí)行原方法的耗時赡译。測試代碼中通過解構聲明分別將返回值和耗時賦值給viewduration仲吏。然后把構建每個視圖的耗時累加打印。

了解了構建布局的過程蝌焚,就有了對癥下藥優(yōu)化的方向裹唆。

有了測量構建布局耗時的方法,就有了對比優(yōu)化效果的工具只洒。

限于篇幅许帐,構建布局耗時縮短 20 倍的方法只能放到下一篇了。

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末毕谴,一起剝皮案震驚了整個濱河市成畦,隨后出現(xiàn)的幾起案子距芬,更是在濱河造成了極大的恐慌,老刑警劉巖循帐,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件框仔,死亡現(xiàn)場離奇詭異,居然都是意外死亡拄养,警方通過查閱死者的電腦和手機离斩,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來衷旅,“玉大人捐腿,你說我怎么就攤上這事∈炼ィ” “怎么了茄袖?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長嘁锯。 經(jīng)常有香客問我宪祥,道長,這世上最難降的妖魔是什么家乘? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任蝗羊,我火速辦了婚禮,結果婚禮上仁锯,老公的妹妹穿的比我還像新娘耀找。我一直安慰自己,他們只是感情好业崖,可當我...
    茶點故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布野芒。 她就那樣靜靜地躺著,像睡著了一般双炕。 火紅的嫁衣襯著肌膚如雪狞悲。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天妇斤,我揣著相機與錄音摇锋,去河邊找鬼。 笑死站超,一個胖子當著我的面吹牛荸恕,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播顷编,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼戚炫,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了媳纬?” 一聲冷哼從身側(cè)響起双肤,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤施掏,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后茅糜,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體七芭,經(jīng)...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年蔑赘,在試婚紗的時候發(fā)現(xiàn)自己被綠了狸驳。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,161評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡缩赛,死狀恐怖耙箍,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情酥馍,我是刑警寧澤辩昆,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站旨袒,受9級特大地震影響汁针,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜砚尽,卻給世界環(huán)境...
    茶點故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一施无、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧必孤,春花似錦猾骡、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至购啄,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間嘱么,已是汗流浹背狮含。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留曼振,地道東北人几迄。 一個月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像冰评,于是被迫代替她去往敵國和親映胁。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,916評論 2 344