深入探索Android佈局優化(下)

前言

成爲一名優秀的Android開發,需要一份完備的知識體系,在這裏,讓我們一起成長爲自己所想的那樣~。

在上篇文章中,筆者帶領大家學習了佈局優化涉及到的繪製原理、優化工具、監測手段等等知識。如果對這塊內容還不瞭解的建議先看看《深入探索Android佈局優化(上)》。本篇,爲深入探索Android佈局優化的下篇。這篇文章包含的主要內容如下所示:

  • 6、佈局優化常規方案
  • 7、佈局優化的進階方案
  • 8、佈局優化的常見問題

下面,筆者將與大家一起進入進行佈局優化的實操環節。

六、佈局優化常規方案

佈局優化的方法有很多,大部分主流的方案筆者已經在Android性能優化之繪製優化裏講解過了。下面,我將介紹一些其它的優化方案。

1、佈局Inflate優化方案演進

1、代碼動態創建View

使用Java代碼動態添加控件的簡單示例如下:

Button button=new Button(this);        
button.setBackgroundColor(Color.RED);
button.setText("Hello World");
ViewGroup viewGroup = (ViewGroup) LayoutInflater.from(this).inflate(R.layout.activity_main, null);
viewGroup.addView(button);
2、替換MessageQueue來實現異步創建View

在使用子線程創建視圖控件的時候,我們可以把子線程Looper的MessageQueue替換成主線程的MessageQueue,在創建完需要的視圖控件後記得將子線程Looper中的MessageQueue恢復爲原來的。在Awesome-WanAndroid項目下的UiUtils的Ui優化工具類中,提供了相應的實現,代碼如下所示:

 /**
 * 實現將子線程Looper中的MessageQueue替換爲主線程中Looper的
 * MessageQueue,這樣就能夠在子線程中異步創建UI。
 *
 * 注意:需要在子線程中調用。
 *
 * @param reset 是否將子線程中的MessageQueue重置爲原來的,false則表示需要進行替換
 * @return 替換是否成功
 */
public static boolean replaceLooperWithMainThreadQueue(boolean reset) {
    if (CommonUtils.isMainThread()) {
        return true;
    } else {
        // 1、獲取子線程的ThreadLocal實例
        ThreadLocal<Looper> threadLocal = ReflectUtils.reflect(Looper.class).field("sThreadLocal").get();
        if (threadLocal == null) {
            return false;
        } else {
            Looper looper = null;
            if (!reset) {
                Looper.prepare();
                looper = Looper.myLooper();
                // 2、通過調用MainLooper的getQueue方法區獲取主線程Looper中的MessageQueue實例
                Object queue = ReflectUtils.reflect(Looper.getMainLooper()).method("getQueue").get();
                if (!(queue instanceof MessageQueue)) {
                    return false;
                }
                // 3、將子線程中的MessageQueue字段的值設置爲主線的MessageQueue實例
                ReflectUtils.reflect(looper).field("mQueue", queue);
            }

            // 4、reset爲false,表示需要將子線程Looper中的MessageQueue重置爲原來的。
            ReflectUtils.reflect(threadLocal).method("set", looper);
            return true;
        }
    }
}
3、AsynclayoutInflater異步創建View

在第三小節中,我們對Android的佈局加載原理進行了深入地分析,從中我們得出了佈局加載過程中的兩個耗時點:

  • 1、佈局文件讀取慢:IO過程。
  • 2、創建View慢:使用反射,比直接new的方式要慢3倍。佈局嵌套層級越多,控件個數越多,反射的次數就會越頻繁。

很明顯,我們無法從根本上去解決這兩個問題,但是Google提供了一個從側面解決的方案:使用AsyncLayoutInflater去異步加載對應的佈局,它的特點如下:

  • 1、工作線程加載佈局。
  • 2、回調主線程。
  • 3、節省主線程時間。

接下來,我將詳細地介紹AsynclayoutInflater的使用。

首先,在項目的build.gradle中進行配置:

implementation 'com.android.support:asynclayoutinflater:28.0.0'

然後,在Activity中的onCreate方法中將setContentView註釋:

super.onCreate(savedInstanceState);
// 內部分別使用了IO和反射的方式去加載佈局解析器和創建對應的View
// setContentView(R.layout.activity_main);

接着,在super.onCreate方法前繼續佈局的異步加載:

// 使用AsyncLayoutInflater進行佈局的加載
new AsyncLayoutInflater(MainActivity.this).inflate(R.layout.activity_main, null, new AsyncLayoutInflater.OnInflateFinishedListener() {
        @Override
        public void onInflateFinished(@NonNull View view, int i, @Nullable ViewGroup viewGroup) {
            setContentView(view);
            // findViewById、視圖操作等
    }
});
super.onCreate(savedInstanceState);

接下來,我們來分析下AsyncLayoutInflater的實現原理與工作流程。

由於我們是使用new的方式創建的AsyncLayoutInflater,所以我們先來看看它的構造函數:

 public AsyncLayoutInflater(@NonNull Context context) {
    // 1
    this.mInflater = new AsyncLayoutInflater.BasicInflater(context);
    // 2
    this.mHandler = new Handler(this.mHandlerCallback);
    // 3
    this.mInflateThread = AsyncLayoutInflater.InflateThread.getInstance();
}

在註釋1處,創建了一個BasicInflater,它內部的onCreateView並沒有使用Factory做AppCompat控件兼容的處理:

protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
    String[] var3 = sClassPrefixList;
    int var4 = var3.length;

    for(int var5 = 0; var5 < var4; ++var5) {
        String prefix = var3[var5];

        try {
            View view = this.createView(name, prefix, attrs);
            if (view != null) {
                return view;
            }
        } catch (ClassNotFoundException var8) {
        }
    }

    return super.onCreateView(name, attrs);
}

由前面的分析可知,在createView方法中僅僅是做了反射創建出對應View的處理。

接着,在註釋2處,創建了一個全局的Handler對象,主要是用於將異步線程創建好的View實例及其相關信息回調到主線程。

最後,在註釋3處,獲取了一個用於異步加載View的線程實例。

接着,我們繼續跟蹤AsyncLayoutInflater實例的inflate方法:

@UiThread
public void inflate(@LayoutRes int resid, @Nullable ViewGroup parent, @NonNull AsyncLayoutInflater.OnInflateFinishedListener callback) {
    if (callback == null) {
        throw new NullPointerException("callback argument may not be null!");
    } else {
        // 1
        AsyncLayoutInflater.InflateRequest request = this.mInflateThread.obtainRequest();
        request.inflater = this;
        request.resid = resid;
        request.parent = parent;
        request.callback = callback;
        this.mInflateThread.enqueue(request);
    }
}

在註釋1處,這裏使用InflateRequest對象將我們傳進來的三個參數進行了包裝,並最終將這個InflateRequest對象加入了mInflateThread線程中的一個ArrayBlockingQueue中:

public void enqueue(AsyncLayoutInflater.InflateRequest request) {
    try {
        this.mQueue.put(request);
      } catch (InterruptedException var3) {
        throw new RuntimeException("Failed to enqueue async inflate request", var3);
    }
}

並且,在InflateThread這個靜態內部類的靜態代碼塊中調用了其自身實例的start方法以啓動線程:

static {
    sInstance.start();
}

public void run() {
    while(true) {
        this.runInner();
    }
}

public void runInner() {
    AsyncLayoutInflater.InflateRequest request;
    try {
        // 1
        request = (AsyncLayoutInflater.InflateRequest)this.mQueue.take();
    } catch (InterruptedException var4) {
        Log.w("AsyncLayoutInflater", var4);
        return;
    }

    try {
        // 2
        request.view = request.inflater.mInflater.inflate(request.resid, request.parent, false);
    } catch (RuntimeException var3) {
        Log.w("AsyncLayoutInflater", "Failed to inflate resource in the background! Retrying on the UI thread", var3);
    }

    // 3
    Message.obtain(request.inflater.mHandler, 0, request).sendToTarget();
}

在run方法中,使用了死循環的方式去不斷地調用runInner方法,在runInner方法中,首先在註釋1處從ArrayBlockingQueue隊列中獲取一個InflateRequest對象,然後在註釋2處將異步加載好的view對象賦值給了InflateRequest對象,最後,在註釋3處,將請求作爲消息發送給了Handler的handleMessage:

private Callback mHandlerCallback = new Callback() {
    public boolean handleMessage(Message msg) {
        AsyncLayoutInflater.InflateRequest request = (AsyncLayoutInflater.InflateRequest)msg.obj;
        // 1
        if (request.view == null) {
            request.view = AsyncLayoutInflater.this.mInflater.inflate(request.resid, request.parent, false);
        }

        request.callback.onInflateFinished(request.view, request.resid, request.parent);
        AsyncLayoutInflater.this.mInflateThread.releaseRequest(request);
        return true;
    }
};

在handleMessage方法中,當異步加載得到的view爲null時,此時在註釋1處還做了一個fallback處理,直接在主線程進行view的加載,以此兼容某些異常情況,最後,就調用了回調接口的onInflateFinished方法將view的相關信息返回給Activity對象。

小結

由以上分析可知,AsyncLayoutInflater是通過側面緩解的方式去緩解佈局加載過程中的卡頓,但是它依然存在一些問題:

  • 1、不能設置LayoutInflater.Factory,需要通過自定義AsyncLayoutInflater的方式解決,由於它是一個final,所以需要將代碼直接拷處進行修改。
  • 2、因爲是異步加載,所以需要注意在佈局加載過程中不能有依賴於主線程的操作。

由於AsyncLayoutInflater僅僅只能通過側面緩解的方式去緩解佈局加載的卡頓,因此,我們下面將介紹一種從根本上解決問題的方案。對於AsynclayoutInflater的改進措施,可以查看祁同偉同學封裝之後的代碼,具體的改進分析可以查看Android AsyncLayoutInflater 限制及改進,這裏附上改進之後的代碼:

/**
* 實現異步加載佈局的功能,修改點:
* 1. 單一線程;
* 2. super.onCreate之前調用沒有了默認的Factory;
 * 3. 排隊過多的優化;
*/
public class AsyncLayoutInflaterPlus {

    private static final String TAG = "AsyncLayoutInflaterPlus";
    private Handler mHandler;
    private LayoutInflater mInflater;
    private InflateRunnable mInflateRunnable;
    // 真正執行加載任務的線程池
    private static ExecutorService sExecutor = Executors.newFixedThreadPool(Math.max(2,
        Runtime.getRuntime().availableProcessors() - 2));
    // InflateRequest pool
    private static Pools.SynchronizedPool<AsyncLayoutInflaterPlus.InflateRequest> sRequestPool = new Pools.SynchronizedPool<>(10);
    private Future<?> future;

    public AsyncLayoutInflaterPlus(@NonNull Context context) {
        mInflater = new AsyncLayoutInflaterPlus.BasicInflater(context);
        mHandler = new Handler(mHandlerCallback);
    }

    @UiThread
    public void inflate(@LayoutRes int resid, @Nullable ViewGroup parent, @NonNull CountDownLatch countDownLatch,
                    @NonNull AsyncLayoutInflaterPlus.OnInflateFinishedListener callback) {
        if (callback == null) {
            throw new NullPointerException("callback argument may not be null!");
        }
        AsyncLayoutInflaterPlus.InflateRequest request = obtainRequest();
        request.inflater = this;
        request.resid = resid;
        request.parent = parent;
        request.callback = callback;
        request.countDownLatch = countDownLatch;
        mInflateRunnable = new InflateRunnable(request);
        future = sExecutor.submit(mInflateRunnable);
    }

    public void cancel() {
        future.cancel(true);
    }

    /**
    * 判斷這個任務是否已經開始執行
    *
    * @return
    */
    public boolean isRunning() {
        return mInflateRunnable.isRunning();
    }

    private Handler.Callback mHandlerCallback = new Handler.Callback() {
        @Override
        public boolean handleMessage(Message msg) {
            AsyncLayoutInflaterPlus.InflateRequest request = (AsyncLayoutInflaterPlus.InflateRequest) msg.obj;
            if (request.view == null) {
                request.view = mInflater.inflate(
                    request.resid, request.parent, false);
            }
            request.callback.onInflateFinished(
                request.view, request.resid, request.parent);
            request.countDownLatch.countDown();
            releaseRequest(request);
            return true;
        }
    };

    public interface OnInflateFinishedListener {
        void onInflateFinished(View view, int resid, ViewGroup parent);
    }

    private class InflateRunnable implements Runnable {
        private InflateRequest request;
        private boolean isRunning;

        public InflateRunnable(InflateRequest request) {
            this.request = request;
        }

        @Override
        public void run() {
            isRunning = true;
            try {
                request.view = request.inflater.mInflater.inflate(
                    request.resid, request.parent, false);
            } catch (RuntimeException ex) {
                // Probably a Looper failure, retry on the UI thread
                Log.w(TAG, "Failed to inflate resource in the background! Retrying on the UI"
                    + " thread", ex);
            }
            Message.obtain(request.inflater.mHandler, 0, request)
                .sendToTarget();
        }

        public boolean isRunning() {
            return isRunning;
        }
    }

    private static class InflateRequest {
        AsyncLayoutInflaterPlus inflater;
        ViewGroup parent;
        int resid;
        View view;
        AsyncLayoutInflaterPlus.OnInflateFinishedListener callback;
        CountDownLatch countDownLatch;

        InflateRequest() {
        }
    }

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

        BasicInflater(Context context) {
            super(context);
            if (context instanceof AppCompatActivity) {
                // 加上這些可以保證AppCompatActivity的情況下,super.onCreate之前
                // 使用AsyncLayoutInflater加載的佈局也擁有默認的效果
                AppCompatDelegate appCompatDelegate = ((AppCompatActivity) context).getDelegate();
                if (appCompatDelegate instanceof LayoutInflater.Factory2) {
                    LayoutInflaterCompat.setFactory2(this, (LayoutInflater.Factory2) appCompatDelegate);
                }
            }
        }

        @Override
        public LayoutInflater cloneInContext(Context newContext) {
            return new AsyncLayoutInflaterPlus.BasicInflater(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 AsyncLayoutInflaterPlus.InflateRequest obtainRequest() {
        AsyncLayoutInflaterPlus.InflateRequest obj = sRequestPool.acquire();
        if (obj == null) {
            obj = new AsyncLayoutInflaterPlus.InflateRequest();
        }
        return obj;
    }

    public void releaseRequest(AsyncLayoutInflaterPlus.InflateRequest obj) {
        obj.callback = null;
        obj.inflater = null;
        obj.parent = null;
        obj.resid = 0;
        obj.view = null;
        sRequestPool.release(obj);
    }
}

/**
* 調用入口類;同時解決加載和獲取View在不同類的場景
*/
public class AsyncLayoutLoader {

    private int mLayoutId;
    private View mRealView;
    private Context mContext;
    private ViewGroup mRootView;
    private CountDownLatch mCountDownLatch;
    private AsyncLayoutInflaterPlus mInflater;
    private static SparseArrayCompat<AsyncLayoutLoader> sArrayCompat = new SparseArrayCompat<AsyncLayoutLoader>();

    public static AsyncLayoutLoader getInstance(Context context) {
        return new AsyncLayoutLoader(context);
    }

    private AsyncLayoutLoader(Context context) {
        this.mContext = context;
        mCountDownLatch = new CountDownLatch(1);
    }

    @UiThread
    public void inflate(@LayoutRes int resid, @Nullable ViewGroup parent) {
        inflate(resid, parent, null);
    }

    @UiThread
    public void inflate(@LayoutRes int resid, @Nullable ViewGroup parent,
                    AsyncLayoutInflaterPlus.OnInflateFinishedListener listener) {
        mRootView = parent;
        mLayoutId = resid;
        sArrayCompat.append(mLayoutId, this);
        if (listener == null) {
            listener = new AsyncLayoutInflaterPlus.OnInflateFinishedListener() {
                @Override
                public void onInflateFinished(View view, int resid, ViewGroup parent) {
                    mRealView = view;
                }
            };
        }
        mInflater = new AsyncLayoutInflaterPlus(mContext);
        mInflater.inflate(resid, parent, mCountDownLatch, listener);
    }

    /**
    * getLayoutLoader 和 getRealView 方法配對出現
    * 用於加載和獲取View在不同類的場景
    *
    * @param resid
    * @return
    */
    public static AsyncLayoutLoader getLayoutLoader(int resid) {
        return sArrayCompat.get(resid);
    }

    /**
    * getLayoutLoader 和 getRealView 方法配對出現
    * 用於加載和獲取View在不同類的場景
    *
    * @param resid
    * @return
    */
    public View getRealView() {
        if (mRealView == null && !mInflater.isRunning()) {
            mInflater.cancel();
            inflateSync();
        } else if (mRealView == null) {
            try {
                mCountDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            setLayoutParamByParent(mContext, mRootView, mLayoutId, mRealView);
        } else {
            setLayoutParamByParent(mContext, mRootView, mLayoutId, mRealView);
        }
        return mRealView;
    }


    /**
    * 根據Parent設置異步加載View的LayoutParamsView
    *
    * @param context
    * @param parent
    * @param layoutResId
    * @param view
    */
    private static void setLayoutParamByParent(Context context, ViewGroup parent, int layoutResId, View view) {
        if (parent == null) {
            return;
        }
        final XmlResourceParser parser = context.getResources().getLayout(layoutResId);
        try {
            final AttributeSet attrs = Xml.asAttributeSet(parser);
            ViewGroup.LayoutParams params = parent.generateLayoutParams(attrs);
            view.setLayoutParams(params);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            parser.close();
        }
    }

    private void inflateSync() {
        mRealView = LayoutInflater.from(mContext).inflate(mLayoutId, mRootView, false);
    }
}
4、使用X2C進行佈局加載優化

由上分析可知,在佈局加載的過程中有兩個主要的耗時點,即IO操作和反射,而AsyncLayoutInflater僅僅是緩解,那麼有什麼方案能從根本上去解決這個問題呢?

使用Java代碼寫佈局?

如果使用Java代碼寫佈局,無疑從Xml文件進行IO操作的過程和反射獲取View實例的過程都將被抹去。雖然這樣從本質上解決了問題,但是也引入了一些新問題,如不便於開發,可維護性差等等。

那麼,還有沒有別的更好的方式呢?

答案就是X2C。

X2C

X2C項目地址

X2C框架保留了XML的優點,並解決了其IO操作和反射的性能問題。開發人員只需要正常寫XML代碼即可,在編譯期,X2C會利用APT工具將XML代碼翻譯爲Java代碼。

接下來,我們來進行X2C的使用。

首先,在app的build.gradle文件添加如下依賴:

annotationProcessor 'com.zhangyue.we:x2c-apt:1.1.2'
implementation 'com.zhangyue.we:x2c-lib:1.0.6'

然後,在對應的MainActivity類上方添加如下註解,讓MainActivity知道我們使用的佈局是activity_main:

@Xml(layouts = "activity_main")
public class MainActivity extends AppCompatActivity implements OnFeedShowCallBack {

接着,將onCreate方法中setContentView的原始方式改爲X2C的設置方式:

X2C.setContentView(MainActivity.this, R.layout.activity_main);

最後,我們再Rebuild項目,會在build下的generated->source->apt->debug->com.zhangyue.we.x2c下自動生成X2C127_activity_main這個類:

public class X2C127_activity_main implements IViewCreator {
@Override
public View createView(Context context) {
    return new com.zhangyue.we.x2c.layouts.X2C127_Activity_Main().createView(context);
    }
}

在這個類中又繼續調用了layout目錄下的X2C127_Activity_Main實例的createView方法,如下所示:

public class X2C127_Activity_Main implements IViewCreator {
    @Override
    public View createView(Context ctx) {
	    Resources res = ctx.getResources();

        ConstraintLayout constraintLayout0 = new ConstraintLayout(ctx);

        RecyclerView recyclerView1 = new RecyclerView(ctx);
        ConstraintLayout.LayoutParams layoutParam1 = new ConstraintLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT);
        recyclerView1.setId(R.id.recycler_view);
        recyclerView1.setLayoutParams(layoutParam1);
        constraintLayout0.addView(recyclerView1);

        return constraintLayout0;
    }
}

從上可知,裏面採用了new的方式創建了相應的控件,並設置了對應的信息。

接下來,我們回到X2C.setContentView(MainActivity.this, R.layout.activity_main)這個方法,看看它內部究竟做了什麼處理:

/**
 * 設置contentview,檢測如果有對應的java文件,使用java文件,否則使用xml
 *
 * @param activity 上下文
 * @param layoutId layout的資源id
 */
public static void setContentView(Activity activity, int layoutId) {
    if (activity == null) {
        throw new IllegalArgumentException("Activity must not be null");
    }
    // 1
    View view = getView(activity, layoutId);
    if (view != null) {
        activity.setContentView(view);
    } else {
        activity.setContentView(layoutId);
    }
}

在註釋1處,通過getView方法獲取到了對應的view,我們繼續跟蹤進去:

public static View getView(Context context, int layoutId) {
    IViewCreator creator = sSparseArray.get(layoutId);
    if (creator == null) {
        try {
            int group = generateGroupId(layoutId);
            String layoutName = context.getResources().getResourceName(layoutId);
            layoutName = layoutName.substring(layoutName.lastIndexOf("/") + 1);
            String clzName = "com.zhangyue.we.x2c.X2C" + group + "_" + layoutName;
            // 1
            creator = (IViewCreator) context.getClassLoader().loadClass(clzName).newInstance();
        } catch (Exception e) {
            e.printStackTrace();
        }

        //如果creator爲空,放一個默認進去,防止每次都調用反射方法耗時
        if (creator == null) {
            creator = new DefaultCreator();
        }
        sSparseArray.put(layoutId, creator);
    }
    // 2
    return creator.createView(context);
}

可以看到,這裏採用了一個sSparseArray集合去存儲佈局對應的視圖創建對象creator,如果是首次創建creator的話,會在註釋1處使用反射的方式去加載處對應的creator對象,然後將它放入sSparseArray集中,最後在註釋2處調用了creator的createView方法去使用new的方式去創建對應的控件。

但是,X2C框架還存在一些問題:

  • 1、部分Java屬性不支持。
  • 2、失去了系統的兼容(AppCompat)

對於第2個問題,我們需要修改X2C框架的源碼,當發現是TextView等控件時,需要直接使用new的方式去創建一個AppCompatTextView等兼容類型的控件。於此同時,它還有如下兩個小的點不支持,但是這個問題不大:

  • merge標籤 ,在編譯期間無法確定xml的parent,所以無法支持。
  • 系統style,在編譯期間只能查到應用的style列表,無法查詢系統style,所以只支持應用內style。

2、使用ConstraintLayout降低佈局嵌套層級

首先,對於Android視圖繪製的原理,我們必須要有一定的瞭解,關於這塊,大家可以參考下Android View的繪製流程
這篇文章。

對於視圖繪製的性能瓶頸,大概有以下三點:

  • 1、測量、佈局、繪製每個階段的耗時。
  • 2、自頂而下的遍歷,當嵌套層級過多時,遍歷耗時會比較明顯。
  • 3、無效的嵌套佈局或不合理使用RelativeLayout可能會導致觸發多次繪製。

那麼,如何減少佈局的層級及複雜度呢?

基本上只要遵循以下兩點即可:

  • 1、減少View樹層級。
  • 2、寬而淺,避免窄而深。

爲了提升佈局的繪製速度,Google推出了ConstraintLayout,它的特點如下:

  • 1、實現幾乎完全扁平化的佈局。
  • 2、構建複雜佈局性能更高。
  • 3、具有RelativeLayout和LinearLayout的特性。

接下來,我們來簡單使用一下ConstraintLayout來優化一下我們的佈局。

首先,下面是我們的原始佈局:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/ll_out"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="5dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <com.facebook.drawee.view.SimpleDraweeView
            android:id="@+id/iv_news"
            android:layout_width="80dp"
            android:layout_height="80dp"
            android:scaleType="fitXY" />

        <TextView
            android:id="@+id/tv_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:padding="10dp"
            android:textSize="20dp" />
    </LinearLayout>

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="right"
        android:padding="3dp"
        android:text="來自NBA官網"
        android:textSize="14dp" />
</LinearLayout>

可以看到,它具有三層嵌套結構,然後我們來使用ConstraintLayout來優化一下這個佈局:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/ll_out"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="5dp">

    <com.facebook.drawee.view.SimpleDraweeView
        android:id="@+id/iv_news"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:scaleType="fitXY"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:paddingLeft="10dp"
        android:textSize="20dp"
        app:layout_constraintLeft_toRightOf="@id/iv_news"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="@id/iv_news" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="3dp"
        android:text="來自NBA官網"
        android:textSize="14dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/iv_news" />
</android.support.constraint.ConstraintLayout>

經過ConstraintLayout之後,佈局的嵌套層級變爲了2級,如果佈局比較複雜,比如有5,6,7層嵌套層級,使用Contraintlayout之後降低的層級會更加明顯。對於其app下的一系列屬性,其實都非常簡單,這裏就不多做介紹了。

除此之外,還有以下方式可以減少佈局層級和複雜度:

  • 1、不嵌套使用RelativeLayout。
  • 2、不在嵌套LinearLayout中使用weight。
  • 3、使用merge標籤,它能夠減少一個層級,但只能用於根View。

3、過渡繪製優化

在視圖的繪製優化中,還有一個比較重要的優化點,就是避免過渡繪製,這個筆者已經在Android性能優化之繪製優化一文的第四小節詳細分析過了。最後這裏補充一下自定義View中使用clipRect的一個實例。

首先,我們自定義了一個DroidCardsView,他可以存放多個疊加的卡片,onDraw方法的實現如下:

 protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    // Don't draw anything until all the Asynctasks are done and all the DroidCards are ready.
    if (mDroids.length > 0 && mDroidCards.size() == mDroids.length) {
        // Loop over all the droids, except the last one.
        int i;
        for (i = 0; i < mDroidCards.size() - 1; i++) {

            mCardLeft = i * mCardSpacing;

            // Draw the card. Only the parts of the card that lie within the bounds defined by
            // the clipRect() get drawn.
            drawDroidCard(canvas, mDroidCards.get(i), mCardLeft, 0);

        }

        // Draw the final card. This one doesn't get clipped.
        drawDroidCard(canvas, mDroidCards.get(mDroidCards.size() - 1),
                mCardLeft + mCardSpacing, 0);
    }

    // Invalidate the whole view. Doing this calls onDraw() if the view is visible.
    invalidate();
}

從以上代碼可知,這裏是直接進行繪製的,此時顯示的佈局過渡繪製背景如下所示:

image

可以看到,圖片的背景都疊加起來了,這個時候,我們需要在繪製的時候使用clipRect讓系統去識別可繪製的區域,因此我們在自定義的DroidCardsView的onDraw方法去使用clipRect:

protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    // Don't draw anything until all the Asynctasks are done and all the DroidCards are ready.
    if (mDroids.length > 0 && mDroidCards.size() == mDroids.length) {
        // Loop over all the droids, except the last one.
        int i;
        for (i = 0; i < mDroidCards.size() - 1; i++) {

            mCardLeft = i * mCardSpacing;
            
            // 1、clipRect方法和繪製前後成對使用canvas的save方法與restore方法。
            canvas.save();
            // 2、使用clipRect指定繪製區域,這裏的mCardSpacing是指的相鄰卡片最左邊的間距,需要在動態創建DroidCardsView的時候傳入。

            canvas.clipRect(mCardLeft,0,mCardLeft+mCardSpacing,mDroidCards.get(i).getHeight());

            // 3、Draw the card. Only the parts of the card that lie within the bounds defined by
            // the clipRect() get drawn.
            drawDroidCard(canvas, mDroidCards.get(i), mCardLeft, 0);

            canvas.restore();
        }

        // Draw the final card. This one doesn't get clipped.
        drawDroidCard(canvas, mDroidCards.get(mDroidCards.size() - 1),
                mCardLeft + mCardSpacing, 0);
    }

    // Invalidate the whole view. Doing this calls onDraw() if the view is visible.
    invalidate();
}

在註釋1處,首先需要在clipRect方法和繪製前後成對使用canvas的save方法與restore方法用來對畫布進行操作。接着,在註釋2處,使用clipRect指定繪製區域,這裏的mCardSpacing是指的相鄰卡片最左邊的間距,需要在動態創建DroidCardsView的時候傳入。最後,在註釋3處調用實際繪製卡片的方法。

使用clipRect優化過後的佈局過渡繪製背景如下所示:

image

注意:

我們還可以通過canvas.quickReject方法來判斷是否沒和某個矩形相交,以跳過非矩形區域的繪製操作

當然,對視圖的繪製優化還有其它的一些優化操作,比如:

  • 1、使用ViewStub、Merge,ViewStub是一種高效佔位符,用於延遲初始化。

  • 2、onDraw中避免創建大對象,進行耗時操作。

  • 3、TextView的優化,比如利用它的drawableLeft屬性。此外,也可以使用Android 9.0之後的 PrecomputedText,它將文件的measure與layout過程進行了異步化。但是需要注意,如果要顯示的文本比較少,反而會造成不必要的Scheduling delay,建議文本字符大於200時才使用,並記得使用其兼容類PrecomputedTextCompat,它在9.0以上使用PrecomputedText進行優化,在5.0~9.0使用StaticLayout進行優化。具體調用代碼如下所示:

    Future future = PrecomputedTextCompat.getTextFuture(
    “text”, textView.getTextMetricsParamsCompat(), null);
    textView.setTextFuture(future);

到這裏,筆者就將常規的佈局優化講解完了,是不是頓時感覺實力大增呢?

如果你此時內心已經YY到這種程度,那我只能說:

對於Android的佈局優化還有更深入的優化方式嗎?

沒錯,下面,筆者就來和大家一起來探索佈局優化的進階方案。

七、佈局優化的進階方案

1、使用異步佈局框架Litho

Litho是Facebook開源的一款在Android上高效建立UI的聲明式框架,它具有以下特點:

  • 聲明式:它使用了聲明式的API來定義UI組件。
  • 異步佈局:它可以提前佈局UI,而不會阻塞UI線程。
  • 視圖扁平化:它使用了Facebook開源的另一款佈局引擎Yoga進行佈局,以自動減少UI包含的ViewGroup數量
  • 細粒度的回收:可以回收文本或圖形等任何組件,並可以在用戶界面的任何位置重複使用
  • 內部不僅支持使用View來渲染視圖,還可以使用更輕量的Drawable來渲染視圖。Litho實現了大量使用Drawable來渲染的基礎組件,可以進一步使佈局扁平化
簡單使用Litho

接下來,我們在項目裏面來使用Litho。

1、首先,我們需要配置Litho的相關依賴,如下所示:

// 項目下
repositories {
    jcenter()
}

// module下
dependencies {
    // ...
    // Litho
    implementation 'com.facebook.litho:litho-core:0.33.0'
    implementation 'com.facebook.litho:litho-widget:0.33.0'

    annotationProcessor 'com.facebook.litho:litho-processor:0.33.0'

    // SoLoader
    implementation 'com.facebook.soloader:soloader:0.5.1'

    // For integration with Fresco
    implementation 'com.facebook.litho:litho-fresco:0.33.0'

    // For testing
    testImplementation 'com.facebook.litho:litho-testing:0.33.0'
    
    // Sections (options,用來聲明去構建一個list)
    implementation 'com.facebook.litho:litho-sections-core:0.33.0'
    implementation 'com.facebook.litho:litho-sections-widget:0.33.0'
    compileOnly 'com.facebook.litho:litho-sections-annotations:0.33.0'

    annotationProcessor 'com.facebook.litho:litho-sections-processor:0.33.0'
}

2、然後,在Application下的onCreate方法中初始化SoLoader:

@Override
public void onCreate() {
    super.onCreate();

    SoLoader.init(this, false);
}

從之前的介紹可知,我們知道Litho使用了Yoga進行佈局,而Yoga包含有native依賴,在Soloader.init方法中對這些native依賴進行了加載。

3、最後,在Activity的onCreate方法中添加如下代碼即可顯示單個的文本視圖:

 // 1、將Activity的Context對象保存到ComponentContext中,並同時初始化
// 一個資源解析者實例ResourceResolver供其餘組件使用。
ComponentContext componentContext = new ComponentContext(this);

// 2、Text內部使用建造者模式以實現組件屬性的鏈式調用,下面設置的text、
// TextColor等屬性在Litho中被稱爲Prop,此概念引申字React。
Text lithoText = Text.create(componentContext)
        .text("Litho text")
        .textSizeDip(64)
        .textColor(ContextCompat.getColor(this, R.color.light_deep_red))
            .build();

// 3、設置一個LithoView去展示Text組件:LithoView.create內部新建了一個
// LithoView實例,並用給定的Component(lithoText)進行初始化
setContentView(LithoView.create(componentContext, lithoText));

顯示效果如下所示:

image

在上面的示例中,我們僅僅是將Text這個子組件設置給了LithoView,後續爲了實現更復雜的佈局,我們需要使用帶多個子組件的根組件去替換它。

使用自定義Component

由上可知,在Litho中的視圖單元叫做Component,即組件,它的設計理念來源於React組件化的思想。每個組件持有描述一個視圖單元所必須的屬性與狀態,用於視圖佈局的計算工作。視圖最終的繪製工作是由組件指定的繪製單元(View或Drawable)來完成的。接下來,我們使用Litho提供的自定義Component的功能,它能夠讓我們實現更復雜的Component,這裏我們來實現一個類似ListView的列表。

首先,我們先來實現一個ListItem Component,它就如ListView的itemView一樣。在下面的實戰中,我們將會學習到所有的基礎知識,這將會支撐你後續能實現更多更復雜的Component。

然後,在Litho中,我們需要先寫一個Spec類去聲明Component所對應的佈局,在這裏需要使用@LayoutSpec規範註解(除此之外,Litho還提供了另一種類型的組件規範:Mount Spec)。代碼如下所示:

@LayoutSpec
public class ListItemSpec {

    @OnCreateLayout
    static Component onCreateLayout(ComponentContext context) {
        // Column的作用類似於HTML中的<div>標籤
        return Column.create(context)
                .paddingDip(YogaEdge.ALL, 16)
                .backgroundColor(Color.WHITE)
                .child(Text.create(context)
                            .text("Litho Study")
                            .textSizeSp(36)
                         .textColor(Color.BLUE)
                            .build())
                .child(Text.create(context)
                            .text("JsonChao")
                            .textSizeSp(24)
                         .textColor(Color.MAGENTA)
                            .build())
                .build();
    }
}

然後,框架會使用APT技術去幫助生成對應的ListItem Component 類。最後,我們在Activity的onCreate中將上述第一個例子中的第二步改爲如下:

 // 2、構建ListItem組件
ListItem listItem = ListItem.create(componentContext).build();

運行項目,顯示界面如下所示:

image

那上述過程是如何進行構建的呢?

它看起來就像有一個LithoSpec的類名,並且在項目構建之後生成了一個與LithoSpec有着同樣包名的Litho類,如下所示:

image

image

類似於Litho這種類中的所有方法參數都會由Litho進行自動填充。此外,基於這些規格,將會有一些額外的方法由註解處理器自動生成,例如上述示例中Column或Row中的Text的TextSizeSp、backgroundColor等方法。(Row和Column分別對應着Flexox中的行和列,它們都實現了Litho中另一種特殊的組件Layout)

補充:MountSpec規範

MountSpec是用來生成可掛載類型組件的一種規範,它的作用是用來生成渲染具體的View或者Drawable的組件。同LayoutSpec類似,它必須使用@MountSpec註解來標註,並實現一個標註了@onCreateMountContent的方法。但是MountSpec的實現要比Layout更加地複雜,因爲它擁有自己的生命週期,如下所示:

  • @OnPrepare:準備階段,用於進行一些初始化操作。
  • @OnMeasure:負責佈局的計算工作。
  • @OnBoundsDefined:在佈局計算完成之後、掛載視圖之前做一些操作。
  • @OnCreateMountContent:如果沒有可以複用的視圖單元,則調用它去創建需要掛載的視圖。
  • @OnMount:掛載視圖,用於完成佈局相關的設置。
  • @OnBind:綁定視圖,用於完成數據和視圖的綁定。
  • @OnUnBind:解綁視圖,與@OnBind相對,主要用於重置視圖的數據屬性,避免出現數據複用的問題。
  • @OnUnmount:卸載視圖,與@OnMount相對,主要用於重置視圖的佈局相關的屬性,避免出現佈局複用的問題。

MountSpec的生命週期流轉圖如下所示:

image

在使用Litho完成了兩個實例的開發之後,相信我們已經對Litho的佈局方式已經有了一個感性的認知。那麼,Litho到底是如何進行佈局優化的呢?在佈局優化中它所做的核心工作有哪些?

Litho在佈局優化中所做的核心工作包括以下三點:

  • 1、異步佈局化。
  • 2、佈局自動扁平化。
  • 3、更細粒度地優化RecyclerView中組件的緩存與回收的方法。
1、異步佈局化

在前文中,我們知道Android的佈局加載過程通常會先後涉及到measure、layout、draw過程,並且它們都是在主線程執行的,如果方法執行過程中耗時太多,則主界面必然會產生卡頓現象。

還記得我們在前面介紹的PrecomputedText,它內部將measure與layout的過程放在了異步線程進行初始化,而Litho與PrecomputedText類似,也是將measre與layout的過程進行了異步化,核心原理就是利用CPU的閒置時間提前在異步線程中完成measure和layout的過程,僅在UI線程中完成繪製工作

那麼Android爲什麼不自己實現異步佈局呢?

主要有以下兩原因:

  • 1、因爲View的屬性是可變的,只要屬性發生變化就可能導致佈局變化,所以需要重新計算佈局,那麼提前異步去計算佈局的意義就不大了。而Litho組件的屬性是不可變的,因此它的佈局計算結果也是不變的。
  • 2、提前異步佈局需要去提前創建好接下來用到的若干條目的視圖,但是Android原生的View作爲視圖單元,不僅包含一個視圖的所有屬性,而且還負責視圖的繪製工作。如果要在繪製前提前去計算佈局,就需要預先去持有大量未展示的View實例,這將會大大增加App進程的內存佔用。對於Litho的組件來說,它只是視圖屬性的一個集合,僅僅負責計算佈局,繪製工作由指定的繪製單元來完成。因此在Litho中,提前創建好下面要用到的多個條目的組件,是不會有性能問題的。兩者的繪製原理簡圖如下所示:

image

2、佈局自動扁平化

經過之前的學習,我們瞭解到Litho採用了一套自有的佈局引擎Yoga,它會在佈局的過程中去檢測出不必要的佈局嵌套層級,並自動去減少多餘的層級以實現佈局的扁平化,這可以顯著減少渲染時的遞歸調用,加快渲染速度。例如,在實現一個圖片帶多個文字的佈局中,我們通常會至少有兩個佈局層級,當然,你也可以使用TextView的drawableStart方法 + 代碼動態佈局使用Spannable/Html.fromHtml(用來實現多種不同規格的文字) + lineSpaceExtra/lineSpacingMultiplier(用來調整多行文本的顯示間距)來將佈局層級降爲一層,但是這種實現方式比較繁瑣,而通過使用Litho,我們可以把降低佈局嵌套層級的任務全部丟給佈局引擎Yoga去處理。由前面可知,Litho是使用Flexbox來創建佈局的,並最終生成帶有層級結構的組件樹。通過使用Yoga來進行佈局計算,可以使用Flexbox的相對佈局變成了只有一層嵌套的絕對佈局。相比於ConstraintLayout,對於實現複雜佈局的時候可讀性會更好一些,因爲ConstraintLayout此時會有過多的約束條件,這會導致可讀性變差。此外,Litho自身還提供了許多掛載Drawable的基本視圖組件,相比Viwe組件使用它們可以顯著減少內存佔用(通常會減少80%的內存佔用)。Litho實現佈局自動扁平化的原理圖如下所示:

image

3、更細粒度地優化RecyclerView中組件的緩存與回收的方法

使用了RecyclerView與ListView這麼久,我們明白它是以viewType爲粒度來對一個組件集合統一進行緩存與回收的,並且,當viewType的類型越多,其對組件集合的緩存與回收的效果就會越差。相對於RecyclerView與ListView緩存與回收的粗粒度而言,Litho實現了更細粒度的回收機制,它是以Text、image、video等單個Component爲粒度來作爲其基準的,具體實現原理在item回收前,會把LithoView中掛載的各個繪製單元進行解綁拆分出來,由Litho自己的緩存池去分類回收,然後在展示前由LithoView按照組件樹的樣式掛載組裝各個繪製單元,這樣就達到了細粒度複用的目的。毫無疑問,這不僅提高了其緩存的命中率與內存的使用率,也降低了提高了其滾動刷新的頻率。更細粒度複用優化內存的原圖如下所示:

image

由上圖可以看出,滑出屏幕的itemType1會被拆分成一個個的視圖單元。其中LithoView容器由Recycler緩存池回收,而其他視圖單元則由Litho的緩存池分類回收,例如分類爲Img緩存池、Text緩存池等等。

現在,我們對Litho已經比較瞭解了,它似乎很完美,但是任何事物都有其弊端,在學習一個新的事物時,我們不僅僅只去使用與瞭解它的優勢,更應該對它的缺陷與弊端瞭如指掌。Litho在佈局的過程中,使用了類似React的單向數據流設計,並且由於Litho是使用代碼進行動態佈局,這大大增加了佈局的複雜度,而且,代碼佈局是無法實時預覽的,這也增加了開發調試時的難度。

綜上,對於某些性能性能要求高的場景,可以先使用Litho佈局的方式去替換,特別是需要利用好Litho中的RecyclerViewCollectionComponent與sections去充分提升RecylerView的性能。

現在,我們來使用RecyclerViewCollectionComponent與sections去創建一個可滾動的列表單元。

接下來,我們需要使用SectionsAPI,它可以將列表分爲多個Section,然後編寫GroupSectionSpec註解類來聲明每個Section需要呈現的內容及其使用的數據。下面,我們創建一個ListSectoinSpec:

// 1、可以理解爲一個組合Sectoin規格
@GroupSectionSpec
public class ListSectionSpec {

    @OnCreateChildren
    static Children onCreateChildren(final SectionContext context) {
        Children.Builder builder = Children.create();

        for (int i = 0; i < 20; i++) {
            builder.child(
                   // 單組件區域用來包含一個特定的組件 
                   SingleComponentSection.create(context)
                    .key(String.valueOf(i))
                    .component(ListItem.create(context).build())
        };

        return builder.build();
    }
}

然後,我們將MainActivity onCreate方法中的步驟2替換爲如下代碼:

 // 2、使用RecyclerCollectionComponent去繪製list
    RecyclerCollectionComponent recyclerCollectionComponent = RecyclerCollectionComponent.create(componentContext)
            // 使下拉刷新實現
            .disablePTR(true)
            .section(ListSection.create(new SectionContext(componentContext)).build())
            .build();

最終的顯示效果如下所示:

image

如果我們需要顯示不同UI的ListItem該怎麼辦呢?

這個時候我們需要去自定義Component的屬性,即props,它是一種不可變屬性(此外還有一種可變屬性稱爲State,但是其變化是由組件內部進行控制的,例如輸入框、Checkbox等都是由組件內部去感知用戶的行爲,並由此更新組件的State屬性),你設置的這些屬性將會改變Component的行爲或表現。Props是Component Spec中方法的參數,並且使用@Prop註解。

下面,我們使用props將ListItemSpec的onCreateLayout修改爲可自定義組件屬性的方法,如下所示:

@LayoutSpec
public class ListItemSpec {

    @OnCreateLayout
    static Component onCreateLayout(ComponentContext context,
                                @Prop int bacColor,
                                @Prop String title,
                                @Prop String subTitle,
                                @Prop int textSize,
                                @Prop int subTextSize) {
        // Column的作用類似於HTML中的<div>標籤
        return Column.create(context)
                .paddingDip(YogaEdge.ALL, 16)
                .backgroundColor(bacColor)
                .child(Text.create(context)
                            .text(title)
                         .textSizeSp(textSize)
                         .textColor(Color.BLUE)
                            .build())
                .child(Text.create(context)
                            .text(subTitle)
                         .textSizeSp(subTextSize)
                         .textColor(Color.MAGENTA)
                            .build())
                .build();
    }
}

奇妙之處就發生在我們所定義的@Prop註解與註解處理器之間,註解處理器以一種智能的方對組件構建過程中所關聯的屬性生成了對應的方法

接下來,我們再修改ListSectionSpec類,如下所示:

@GroupSectionSpec
public class ListSectionSpec {

    @OnCreateChildren
    static Children onCreateChildren(final SectionContext context) {
        Children.Builder builder = Children.create();

        for (int i = 0; i < 20; i++) {
            builder.child(
                    SingleComponentSection.create(context)
                    .key(String.valueOf(i))
                    .component(ListItem.create(context)
                            .bacColor(i % 2 == 0 ? Color.BLUE:Color.MAGENTA)
                            .title("第" + i + "次練習")
                         .subTitle("JsonChao")
                            .textSize(36)
                            .subTextSize(24)
                            .build())
            );
        }

        return builder.build();
    }
}

最終的顯示效果如下所示:

image

除此之外,我們還可以有更多的方式去定義@Prop,如下所示:

@Prop(optional = true, resType = ResType.DIMEN_OFFSET) int shadowRadius,

上面定義了一個可選的Prop,傳入的shadowRadius是支持dimen規格的,如px、dp、sp等等。

小結

使用Litho,在佈局性能上有很大的提升,但是開發成本太高,因爲需要自己去實現很多的組件,並且其組件需要在編譯時才能生成,不能夠進行實時預覽,但是可以把Litho封裝成Flexbox佈局的底層渲染引擎,以此實現上層的動態化,具體實現原理可參見Litho在美團動態化方案MTFlexbox中的實踐

2、使用Flutter實現高性能的UI佈局

Flutter可以說是2019最火爆的框架之一了,它是 Google 開源的 UI 工具包,幫助開發者通過一套代碼庫高效構建多平臺精美應用,支持移動、Web、桌面和嵌入式平臺。對於Android來說,FLutter能夠創作媲美原生的高性能應用,應用使用 Dart語言進行 開發。Flutter的架構類似於Android的層級架構,每一層都建立在前一層之上,其架構圖如下所示:

image

在Framework層中,Flutter通過在 widgets 層組合基礎 widgets 來構建 Material 層,而 widgets 層本身則是通過對來自 Rendering 層的低層次對象組合而來。而在Engine層,Flutter集成了Skia引擎用於進行柵格化,並且使用了Dart虛擬機。

那麼Flutter的圖形性能爲何能夠媲美原生應用呢?

接下來,我們以Flutter、原生Android、其它跨平臺框架如RN來做比較,它們的圖形繪製調用層級圖如下所示:

image

可以看到,Flutter框架的代碼完全取代了Java層的框架代碼,所以只要當Flutter框架中Dart代碼的效率可以媲美原生框架的Java代碼的時候,那麼總體的Flutter App的性能就能夠媲美原生的APP。而反觀其它流行的跨平臺框架如RN,它首先需要調用自身的Js代碼,然後再去調用Java層的代碼,這裏比原生和Flutter的App顯然多出來一個步驟,所以它的性能肯定是不及原生的APP的。此外,Flutter App不同於原生、RN,它內部是直接包含了Skia渲染引擎的,只要Flutter SDK進行升級,Skia就能夠升級,這樣Skia的性能改進就能夠同步到Flutter框架之中。而對於Android原生和RN來說,只能等到Android系統升級才能同步Skia的性能改進。

而Flutter又是如何實現高性能UI佈局的呢?

接下來,我們來大致瞭解一下Flutter的UI繪製原理,它主要是通過VSYNC信號來使UI線程和GPU線程有條不紊的週期性的去渲染界面,其繪製原理圖如下所示:

image

繪製步驟大致如下:

  • 1、首先 UI Runner 會執行 root isolate(可簡單理解爲Dart VM的線程),它會告訴引擎層有幀要渲染,當需要渲染則會調用到Engine的ScheduleFrame()來註冊VSYNC信號回調,一旦觸發回調doFrame(),並當它執行完成後,便會移除回調方法,也就是說一次註冊一次回調。
  • 2、當需要再次繪製則需要重新調用到ScheduleFrame()方法,該方法的唯一重要參數regenerate_layer_tree決定在幀繪製過程是否需要重新生成layer tree,還是直接複用上一次的layer tree。
  • 3、接着,執行的是UI線程繪製過程中最核心的WidgetsBinding的drawFrame()方法,然後會創建layer tree視圖樹。
  • 4、然後 Layer Tree 會交給 GPU Task Runner 進行合成和柵格化。
  • 5、最後,GPU Task Runner會利用Skia庫結合GL或Vu’lkan將layer tree提供的信息轉化爲平臺可執行的GPU指令。

此外,Flutter 也採用了類似 Litho 的props屬性不可變、Reat單向數據流的方案,用於將視圖與數據分離。對於Flutter這一大前端領域的核心技術,筆者也是充滿興趣,後續會有計劃對此進行深入研究,敬請期待。

3、使用RenderThread 與 RenderScript

在Android 5.0之後,Android引進了RenderThread,它能夠實現動畫的異步渲染。但是目前支持RenderThread完全渲染的動畫,只有兩種,即ViewPropertyAnimator和CircularReveal(揭露動畫)。對於CircularReveal使用比較簡單且功能較爲單一,就不多做過多的描述了。下面我簡單說下ViewPropertyAnimator中如何去利用RenderThread。

1、在ViewPropertyAnimator類系中,有一個ViewPropertyAnimatorRT ,它的主要作用就把動畫交給RenderThread去處理。因此,我們需要先去創建對應view的ViewPropertyAnimatorRT,代碼如下所示:
 /**
 * 使用反射的方式去創建對應View的ViewPropertyAnimatorRT(非hide類)
 */
private static Object createViewPropertyAnimatorRT(View view) {
    try {           
        Class<?> animRtClazz = Class.forName("android.view.ViewPropertyAnimatorRT");
        Constructor<?> animRtConstructor = animRtClazz.getDeclaredConstructor(View.class);
        animRtConstructor.setAccessible(true);
        Object animRt = animRtConstructor.newInstance(view);            
        return animRt;
    } catch (Exception e) {            
        Log.d(TAG, "創建ViewPropertyAnimatorRT出錯,錯誤信息:" + e.toString());           
        return null;
    }
}
2、接下來,我們需要將ViewPropertyAnimatorRT設置給ViewPropertyAnimator的mRTBackend字段,這樣ViewPropertyAnimator才能利用它去將動畫交給RenderThread處理,如下所示:
private static void setViewPropertyAnimatorRT(ViewPropertyAnimator animator, Object rt) {       
 try {
        Class<?> animClazz = Class.forName("android.view.ViewPropertyAnimator");
        Field animRtField = animClazz.getDeclaredField("mRTBackend");
        animRtField.setAccessible(true);
        animRtField.set(animator, rt);
    } catch (Exception e) {
        Log.d(TAG, "設置ViewPropertyAnimatorRT出錯,錯誤信息:" + e.toString());
    }
}

/**
 * 在animator.start()即執行動畫開始之前配置的方法
 */
public static void onStartBeforeConfig(ViewPropertyAnimator animator, View view) {
    Object rt = createViewPropertyAnimatorRT(view);
    setViewPropertyAnimatorRT(animator, rt);
}
3、最後,在開啓動畫之前將ViewPropertyAnimatorRT實例設置進去即可,如下所示:
ViewPropertyAnimator animator = v.animate().scaleY(2).setDuration(2000);
AnimHelper.onStartBeforeConfig(animator, v);
animator.start();

當前,如果是做音視頻或圖像處理的工作,經常需要對圖片進行高斯模糊、放大、銳化等操作,但是這裏涉及大量的圖片變換操作,例如縮放、裁剪、二值化以及降噪等。而圖片的變換又涉及大量的計算任務,這個時候我們可以通過RenderScript去充分利用手機的GPU計算能力,以實現高效的圖片處理

而RenderScript的工作流程需要經歷如下三個步驟:

  • 1、RenderScript運行時API:提供進行運算的API。
  • 2、反射層:相當於NDK中的JNI膠水代碼,它是一些由Android編譯工具自動生成的類,對我們寫的RenderScript代碼進行包裝,以使得安卓層能夠和RenderScript進行交互。
  • 3、安卓框架:通過調用反射層來訪問RenderScript運行時。

由於RenderScript主要是用於音視頻、圖像處理等細分領域,這裏筆者就不繼續深入擴展了,對於NDK、音視頻領域的知識,筆者在今年會有一系列學習計劃,目前大綱已經定製好了,如果有興趣的朋友,可以瞭解下:Awesome-Android-NDK

八、佈局優化的常見問題

1、你在做佈局優化的過程中用到了哪些工具?

我在做佈局優化的過程中,用到了很多的工具,但是每一個工具都有它不同的使用場景,不同的場景應該使用不同的工具。下面我從線上和線下兩個角度來進行分析。

比如說,我要統計線上的FPS,我使用的就是Choreographer這個類,它具有以下特性:

  • 1、能夠獲取整體的幀率。
  • 2、能夠帶到線上使用。
  • 3、它獲取的幀率幾乎是實時的,能夠滿足我們的需求。

同時,在線下,如果要去優化佈局加載帶來的時間消耗,那就需要檢測每一個佈局的耗時,對此我使用的是AOP的方式,它沒有侵入性,同時也不需要別的開發同學進行接入,就可以方便地獲取每一個佈局加載的耗時。如果還要更細粒度地去檢測每一個控件的加載耗時,那麼就需要使用LayoutInflaterCompat.setFactory2這個方法去進行Hook。

此外,我還使用了LayoutInspector和Systrace這兩個工具,Systrace可以很方便地看到每幀的具體耗時以及這一幀在佈局當中它真正做了什麼。而LayoutInspector可以很方便地看到每一個界面的佈局層級,幫助我們對層級進行優化。

2、佈局爲什麼會導致卡頓,你又是如何優化的?

分析完佈局的加載流程之後,我們發現有如下四點可能會導致佈局卡頓:

  • 1、首先,系統會將我們的Xml文件通過IO的方式映射的方式加載到我們的內存當中,而IO的過程可能會導致卡頓。
  • 2、其次,佈局加載的過程是一個反射的過程,而反射的過程也會可能會導致卡頓。
  • 3、同時,這個佈局的層級如果比較深,那麼進行佈局遍歷的過程就會比較耗時。
  • 4、最後,不合理的嵌套RelativeLayout佈局也會導致重繪的次數過多。

對此,我們的優化方式有如下幾種:

  • 1、針對佈局加載Xml文件的優化,我們使用了異步Inflate的方式,即AsyncLayoutInflater。它的核心原理是在子線程中對我們的Layout進行加載,而加載完成之後會將View通過Handler發送到主線程來使用。所以不會阻塞我們的主線程,加載的時間全部是在異步線程中進行消耗的。而這僅僅是一個從側面緩解的思路。
  • 2、後面,我們發現了一個從根源解決上述痛點的方式,即使用X2C框架。它的一個核心原理就是在開發過程我們還是使用的XML進行編寫佈局,但是在編譯的時候它會使用APT的方式將XML佈局轉換爲Java的方式進行佈局,通過這樣的方式去寫佈局,它有以下優點:1、它省去了使用IO的方式去加載XML佈局的耗時過程。2、它是採用Java代碼直接new的方式去創建控件對象,所以它也沒有反射帶來的性能損耗。這樣就從根本上解決了佈局加載過程中帶來的問題。
  • 3、然後,我們可以使用ConstraintLayout去減少我們界面佈局的嵌套層級,如果原始佈局層級越深,它能減少的層級就越多。而使用它也能避免嵌套RelativeLayout佈局導致的重繪次數過多。
  • 4、最後,我們可以使用AspectJ框架(即AOP)和LayoutInflaterCompat.setFactory2的方式分別去建立線下全局的佈局加載速度和控件加載速度的監控體系。

3、做完佈局優化有哪些成果產出?

  • 1、首先,我們建立了一個體系化的監控手段,這裏的體系還指的是線上加線下的一個綜合方案,針對線下,我們使用AOP或者ARTHook,可以很方便地獲取到每一個佈局的加載耗時以及每一個控件的加載耗時。針對線上,我們通過Choreographer.getInstance().postFrameCallback的方式收集到了FPS,這樣我們可以知道用戶在哪些界面出現了丟幀的情況。
  • 2、然後,對於佈局監控方面,我們設立了FPS、佈局加載時間、佈局層級等一系列指標。
  • 3、最後,在每一個版本上線之前,我們都會對我們的核心路徑進行一次Review,確保我們的FPS、佈局加載時間、佈局層級等達到一個合理的狀態。

九、總結

對於Android的佈局優化,筆者以一種自頂向下,層層遞進的方式和大家一起深入地去探索了Android中如何將佈局優化做到極致,其中主要涉及以下八大主題:

  • 1、繪製原理:CPU\GPU、Android圖形系統的整體架構、繪製線程、刷新機制。
  • 2、屏幕適配:OLED 屏幕和 LCD 屏幕的區別、屏幕適配方案。
  • 3、優化工具:使用Systrace來進行佈局優化、利用Layout Inspector來查看視圖層級結構、採用Choreographer來獲取FPS以及自動化測量 UI 渲染性能的方式(gfxinfo、SurfaceFlinger等dumpsys命令)。
  • 4、佈局加載原理:佈局加載源碼分析、LayoutInflater.Factory分析。
  • 5、獲取界面佈局耗時:使用AOP的方式去獲取界面加載的耗時、利用LayoutInflaterCompat.setFactory2去監控每一個控件加載的耗時。
  • 6、佈局優化常規方案:使用AOP的方式去獲取界面加載的耗時、利用LayoutInflaterCompat.setFactory2去監控每一個控件加載的耗時。
  • 7、佈局優化的進階方案:使用異步佈局框架Litho、使用Flutter實現高性能的UI佈局、使用RenderThread實現動畫的異步渲染與 利用RenderScript實現高效的圖片處理。
  • 8、佈局優化的常見問題。

可以看到,佈局優化看似是Android性能優化中最簡單的專項優化項,但是筆者卻花費了整整三、四萬字的篇幅才能比較完整地將其核心知識傳授給大家。因此,不要小看每一個專項優化點,深入進去,必定滿載而歸

參考鏈接:

1、國內Top團隊大牛帶你玩轉Android性能分析與優化 第五章 佈局優化

2、極客時間之Android開發高手課 UI優化

3、手機屏幕的前世今生 可能比你想的還精彩

4、OLED 和 LCD 什麼區別?

5、Android 目前穩定高效的UI適配方案

6、騷年你的屏幕適配方式該升級了!-smallestWidth 限定符適配方案

7、dimens_sw github

8、一種極低成本的Android屏幕適配方式

9、騷年你的屏幕適配方式該升級了!-今日頭條適配方案

10、今日頭條屏幕適配方案終極版正式發佈!

11、使用Systrace分析UI性能

12、GAPID-Graphics API Debugger

13、Android性能優化之渲染篇

14、Android 屏幕繪製機制及硬件加速

15、Android 圖形處理官方教程

16、Vulkan - 高性能渲染

17、Android Vulkan Tutorial

18、Test UI performance-gfxinfo

19、使用dumpsys gfxinfo 測UI性能(適用於Android6.0以後)

20、TextureView API

21、PrecomputedText API

22、Litho Tutorial

23、基本功 | Litho的使用及原理剖析

24、Flutter官方文檔中文版

25、[Google Flutter 團隊出品] 深入瞭解 Flutter 的高性能圖形渲染

26、Flutter渲染機制—UI線程

27、RenderThread:異步渲染動畫

28、RenderScript官方文檔

29、RenderScript :簡單而快速的圖像處理

30、RenderScript渲染利器

讚賞

如果這個庫對您有很大幫助,您願意支持這個項目的進一步開發和這個項目的持續維護。你可以掃描下面的二維碼,讓我喝一杯咖啡或啤酒。非常感謝您的捐贈。謝謝!


Contanct Me

● 微信:

歡迎關注我的微信:bcce5360

● 微信羣:

微信羣如果不能掃碼加入,麻煩大家想進微信羣的朋友們,加我微信拉你進羣。

● QQ羣:

2千人QQ羣,Awesome-Android學習交流羣,QQ羣號:959936182, 歡迎大家加入~

About me

很感謝您閱讀這篇文章,希望您能將它分享給您的朋友或技術羣,這對我意義重大。

希望我們能成爲朋友,在 Github掘金上一起分享知識。

發佈了16 篇原創文章 · 獲贊 0 · 訪問量 3912
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章