在上篇文章中,筆者帶領你們學習了佈局優化涉及到的繪製原理、優化工具、監測手段等等知識。若是對這塊內容還不瞭解的建議先看看《深刻探索Android佈局優化(上)》。本篇,爲深刻探索Android佈局優化的下篇。這篇文章包含的主要內容以下所示:html
下面,筆者將與你們一塊兒進入進行佈局優化的實操環節。前端
佈局優化的方法有不少,大部分主流的方案筆者已經在Android性能優化之繪製優化裏講解過了。下面,我將介紹一些其它的優化方案。java
使用Java代碼動態添加控件的簡單示例以下:android
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);
複製代碼
在使用子線程建立視圖控件的時候,咱們能夠把子線程Looper的MessageQueue替換成主線程的MessageQueue,在建立完須要的視圖控件後記得將子線程Looper中的MessageQueue恢復爲原來的。在Awesome-WanAndroid項目下的UiUtils的Ui優化工具類中,提供了相應的實現,代碼以下所示:git
/**
* 實現將子線程Looper中的MessageQueue替換爲主線程中Looper的
* MessageQueue,這樣就可以在子線程中異步建立UI。
*
* 注意:須要在子線程中調用。
*
* @param reset 是否將子線程中的MessageQueue重置爲原來的,false則表示須要進行替換
* @return 替換是否成功
*/
public static boolean replaceLooperWithMainThreadQueue(boolean reset) {
if (CommonUtils.isMainThread()) {
return true;
} else {
// 一、獲取子線程的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();
// 二、經過調用MainLooper的getQueue方法區獲取主線程Looper中的MessageQueue實例
Object queue = ReflectUtils.reflect(Looper.getMainLooper()).method("getQueue").get();
if (!(queue instanceof MessageQueue)) {
return false;
}
// 三、將子線程中的MessageQueue字段的值設置爲主線的MessageQueue實例
ReflectUtils.reflect(looper).field("mQueue", queue);
}
// 四、reset爲false,表示須要將子線程Looper中的MessageQueue重置爲原來的。
ReflectUtils.reflect(threadLocal).method("set", looper);
return true;
}
}
}
複製代碼
在第三小節中,咱們對Android的佈局加載原理進行了深刻地分析,從中咱們得出了佈局加載過程當中的兩個耗時點:github
很明顯,咱們沒法從根本上去解決這兩個問題,可是Google提供了一個從側面解決的方案:使用AsyncLayoutInflater去異步加載對應的佈局,它的特色以下:web
接下來,我將詳細地介紹AsynclayoutInflater的使用。json
首先,在項目的build.gradle中進行配置:canvas
implementation 'com.android.support:asynclayoutinflater:28.0.0'
複製代碼
而後,在Activity中的onCreate方法中將setContentView註釋:api
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是經過側面緩解的方式去緩解佈局加載過程當中的卡頓,可是它依然存在一些問題:
因爲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);
}
}
複製代碼
由上分析可知,在佈局加載的過程當中有兩個主要的耗時點,即IO操做和反射,而AsyncLayoutInflater僅僅是緩解,那麼有什麼方案能從根本上去解決這個問題呢?
若是使用Java代碼寫佈局,無疑從Xml文件進行IO操做的過程和反射獲取View實例的過程都將被抹去。雖然這樣從本質上解決了問題,可是也引入了一些新問題,如不便於開發,可維護性差等等。
那麼,還有沒有別的更好的方式呢?
答案就是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框架還存在一些問題:
對於第2個問題,咱們須要修改X2C框架的源碼,當發現是TextView等控件時,須要直接使用new的方式去建立一個AppCompatTextView等兼容類型的控件。於此同時,它還有以下兩個小的點不支持,可是這個問題不大:
首先,對於Android視圖繪製的原理,咱們必需要有必定的瞭解,關於這塊,你們能夠參考下Android View的繪製流程 這篇文章。
對於視圖繪製的性能瓶頸,大概有如下三點:
那麼,如何減小布局的層級及複雜度呢?
基本上只要遵循如下兩點便可:
爲了提高佈局的繪製速度,Google推出了ConstraintLayout,它的特色以下:
接下來,咱們來簡單使用一下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下的一系列屬性,其實都很是簡單,這裏就很少作介紹了。
除此以外,還有如下方式能夠減小布局層級和複雜度:
在視圖的繪製優化中,還有一個比較重要的優化點,就是避免過渡繪製,這個筆者已經在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();
}
複製代碼
從以上代碼可知,這裏是直接進行繪製的,此時顯示的佈局過渡繪製背景以下所示:
能夠看到,圖片的背景都疊加起來了,這個時候,咱們須要在繪製的時候使用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;
// 一、clipRect方法和繪製先後成對使用canvas的save方法與restore方法。
canvas.save();
// 二、使用clipRect指定繪製區域,這裏的mCardSpacing是指的相鄰卡片最左邊的間距,須要在動態建立DroidCardsView的時候傳入。
canvas.clipRect(mCardLeft,0,mCardLeft+mCardSpacing,mDroidCards.get(i).getHeight());
// 三、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優化事後的佈局過渡繪製背景以下所示:
注意:
咱們還能夠經過canvas.quickReject方法來判斷是否沒和某個矩形相交,以跳過非矩形區域的繪製操做。
固然,對視圖的繪製優化還有其它的一些優化操做,好比:
一、使用ViewStub、Merge,ViewStub是一種高效佔位符,用於延遲初始化。
二、onDraw中避免建立大對象,進行耗時操做。
三、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的佈局優化還有更深刻的優化方式嗎?
沒錯,下面,筆者就來和你們一塊兒來探索佈局優化的進階方案。
Litho是Facebook開源的一款在Android上高效創建UI的聲明式框架,它具備如下特色:
接下來,咱們在項目裏面來使用Litho。
一、首先,咱們須要配置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'
}
複製代碼
二、而後,在Application下的onCreate方法中初始化SoLoader:
@Override
public void onCreate() {
super.onCreate();
SoLoader.init(this, false);
}
複製代碼
從以前的介紹可知,咱們知道Litho使用了Yoga進行佈局,而Yoga包含有native依賴,在Soloader.init方法中對這些native依賴進行了加載。
三、最後,在Activity的onCreate方法中添加以下代碼便可顯示單個的文本視圖:
// 一、將Activity的Context對象保存到ComponentContext中,並同時初始化
// 一個資源解析者實例ResourceResolver供其他組件使用。
ComponentContext componentContext = new ComponentContext(this);
// 二、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();
// 三、設置一個LithoView去展現Text組件:LithoView.create內部新建了一個
// LithoView實例,並用給定的Component(lithoText)進行初始化
setContentView(LithoView.create(componentContext, lithoText));
複製代碼
顯示效果以下所示:
在上面的示例中,咱們僅僅是將Text這個子組件設置給了LithoView,後續爲了實現更復雜的佈局,咱們須要使用帶多個子組件的根組件去替換它。
由上可知,在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中將上述第一個例子中的第二步改成以下:
// 二、構建ListItem組件
ListItem listItem = ListItem.create(componentContext).build();
複製代碼
運行項目,顯示界面以下所示:
那上述過程是如何進行構建的呢?
它看起來就像有一個LithoSpec的類名,而且在項目構建以後生成了一個與LithoSpec有着一樣包名的Litho類,以下所示:
相似於Litho這種類中的全部方法參數都會由Litho進行自動填充。此外,基於這些規格,將會有一些額外的方法由註解處理器自動生成,例如上述示例中Column或Row中的Text的TextSizeSp、backgroundColor等方法。(Row和Column分別對應着Flexox中的行和列,它們都實現了Litho中另外一種特殊的組件Layout)
MountSpec是用來生成可掛載類型組件的一種規範,它的做用是用來生成渲染具體的View或者Drawable的組件。同LayoutSpec相似,它必須使用@MountSpec註解來標註,並實現一個標註了@onCreateMountContent的方法。可是MountSpec的實現要比Layout更加地複雜,由於它擁有本身的生命週期,以下所示:
MountSpec的生命週期流轉圖以下所示:
在使用Litho完成了兩個實例的開發以後,相信咱們已經對Litho的佈局方式已經有了一個感性的認知。那麼,Litho究竟是如何進行佈局優化的呢?在佈局優化中它所作的核心工做有哪些?
Litho在佈局優化中所作的核心工做包括如下三點:
在前文中,咱們知道Android的佈局加載過程一般會前後涉及到measure、layout、draw過程,而且它們都是在主線程執行的,若是方法執行過程當中耗時太多,則主界面必然會產生卡頓現象。
還記得咱們在前面介紹的PrecomputedText,它內部將measure與layout的過程放在了異步線程進行初始化,而Litho與PrecomputedText相似,也是將measre與layout的過程進行了異步化,核心原理就是利用CPU的閒置時間提早在異步線程中完成measure和layout的過程,僅在UI線程中完成繪製工做。
那麼Android爲何不本身實現異步佈局呢?
主要有如下兩緣由:
通過以前的學習,咱們瞭解到Litho採用了一套自有的佈局引擎Yoga,它會在佈局的過程當中去檢測出沒必要要的佈局嵌套層級,並自動去減小多餘的層級以實現佈局的扁平化,這能夠顯著減小渲染時的遞歸調用,加快渲染速度。例如,在實現一個圖片帶多個文字的佈局中,咱們一般會至少有兩個佈局層級,固然,你也可使用TextView的drawableStart方法 + 代碼動態佈局使用Spannable/Html.fromHtml(用來實現多種不一樣規格的文字) + lineSpaceExtra/lineSpacingMultiplier(用來調整多行文本的顯示間距)來將佈局層級降爲一層,可是這種實現方式比較繁瑣,而經過使用Litho,咱們能夠把下降佈局嵌套層級的任務所有丟給佈局引擎Yoga去處理。由前面可知,Litho是使用Flexbox來建立佈局的,並最終生成帶有層級結構的組件樹。經過使用Yoga來進行佈局計算,可使用Flexbox的相對佈局變成了只有一層嵌套的絕對佈局。相比於ConstraintLayout,對於實現複雜佈局的時候可讀性會更好一些,由於ConstraintLayout此時會有過多的約束條件,這會致使可讀性變差。此外,Litho自身還提供了許多掛載Drawable的基本視圖組件,相比Viwe組件使用它們能夠顯著減小內存佔用(一般會減小80%的內存佔用)。Litho實現佈局自動扁平化的原理圖以下所示:
使用了RecyclerView與ListView這麼久,咱們明白它是以viewType爲粒度來對一個組件集合統一進行緩存與回收的,而且,當viewType的類型越多,其對組件集合的緩存與回收的效果就會越差。相對於RecyclerView與ListView緩存與回收的粗粒度而言,Litho實現了更細粒度的回收機制,它是以Text、image、video等單個Component爲粒度來做爲其基準的,具體實現原理爲在item回收前,會把LithoView中掛載的各個繪製單元進行解綁拆分出來,由Litho本身的緩存池去分類回收,而後在展現前由LithoView按照組件樹的樣式掛載組裝各個繪製單元,這樣就達到了細粒度複用的目的。毫無疑問,這不只提升了其緩存的命中率與內存的使用率,也下降了提升了其滾動刷新的頻率。更細粒度複用優化內存的原圖以下所示:
由上圖能夠看出,滑出屏幕的itemType1會被拆分紅一個個的視圖單元。其中LithoView容器由Recycler緩存池回收,而其餘視圖單元則由Litho的緩存池分類回收,例如分類爲Img緩存池、Text緩存池等等。
如今,咱們對Litho已經比較瞭解了,它彷佛很完美,可是任何事物都有其弊端,在學習一個新的事物時,咱們不只僅只去使用與瞭解它的優點,更應該對它的缺陷與弊端了如指掌。Litho在佈局的過程當中,使用了相似React的單向數據流設計,而且因爲Litho是使用代碼進行動態佈局,這大大增長了佈局的複雜度,並且,代碼佈局是沒法實時預覽的,這也增長了開發調試時的難度。
綜上,對於某些性能性能要求高的場景,能夠先使用Litho佈局的方式去替換,特別是須要利用好Litho中的RecyclerViewCollectionComponent與sections去充分提高RecylerView的性能。
如今,咱們來使用RecyclerViewCollectionComponent與sections去建立一個可滾動的列表單元。
接下來,咱們須要使用SectionsAPI,它能夠將列表分爲多個Section,而後編寫GroupSectionSpec註解類來聲明每一個Section須要呈現的內容及其使用的數據。下面,咱們建立一個ListSectoinSpec:
// 一、能夠理解爲一個組合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替換爲以下代碼:
// 二、使用RecyclerCollectionComponent去繪製list
RecyclerCollectionComponent recyclerCollectionComponent = RecyclerCollectionComponent.create(componentContext)
// 使下拉刷新實現
.disablePTR(true)
.section(ListSection.create(new SectionContext(componentContext)).build())
.build();
複製代碼
最終的顯示效果以下所示:
若是咱們須要顯示不一樣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();
}
}
複製代碼
最終的顯示效果以下所示:
除此以外,咱們還能夠有更多的方式去定義@Prop,以下所示:
@Prop(optional = true, resType = ResType.DIMEN_OFFSET) int shadowRadius,
複製代碼
上面定義了一個可選的Prop,傳入的shadowRadius是支持dimen規格的,如px、dp、sp等等。
使用Litho,在佈局性能上有很大的提高,可是開發成本過高,由於須要本身去實現不少的組件,而且其組件須要在編譯時才能生成,不可以進行實時預覽,可是能夠把Litho封裝成Flexbox佈局的底層渲染引擎,以此實現上層的動態化,具體實現原理可參見Litho在美團動態化方案MTFlexbox中的實踐。
Flutter能夠說是2019最火爆的框架之一了,它是 Google 開源的 UI 工具包,幫助開發者經過一套代碼庫高效構建多平臺精美應用,支持移動、Web、桌面和嵌入式平臺。對於Android來講,FLutter可以創做媲美原生的高性能應用,應用使用 Dart語言進行 開發。Flutter的架構相似於Android的層級架構,每一層都創建在前一層之上,其架構圖以下所示:
在Framework層中,Flutter經過在 widgets 層組合基礎 widgets 來構建 Material 層,而 widgets 層自己則是經過對來自 Rendering 層的低層次對象組合而來。而在Engine層,Flutter集成了Skia引擎用於進行柵格化,而且使用了Dart虛擬機。
接下來,咱們以Flutter、原生Android、其它跨平臺框架如RN來作比較,它們的圖形繪製調用層級圖以下所示:
能夠看到,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繪製原理,它主要是經過VSYNC信號來使UI線程和GPU線程有條不紊的週期性的去渲染界面,其繪製原理圖以下所示:
繪製步驟大體以下:
此外,Flutter 也採用了相似 Litho 的props屬性不可變、Reat單向數據流的方案,用於將視圖與數據分離。對於Flutter這一大前端領域的核心技術,筆者也是充滿興趣,後續會有計劃對此進行深刻研究,敬請期待。
在Android 5.0以後,Android引進了RenderThread,它可以實現動畫的異步渲染。可是目前支持RenderThread徹底渲染的動畫,只有兩種,即ViewPropertyAnimator和CircularReveal(揭露動畫)。對於CircularReveal使用比較簡單且功能較爲單一,就很少作過多的描述了。下面我簡單說下ViewPropertyAnimator中如何去利用RenderThread。
/**
* 使用反射的方式去建立對應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;
}
}
複製代碼
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);
}
複製代碼
ViewPropertyAnimator animator = v.animate().scaleY(2).setDuration(2000);
AnimHelper.onStartBeforeConfig(animator, v);
animator.start();
複製代碼
當前,若是是作音視頻或圖像處理的工做,常常須要對圖片進行高斯模糊、放大、銳化等操做,可是這裏涉及大量的圖片變換操做,例如縮放、裁剪、二值化以及降噪等。而圖片的變換又涉及大量的計算任務,這個時候咱們能夠經過RenderScript去充分利用手機的GPU計算能力,以實現高效的圖片處理。
而RenderScript的工做流程須要經歷以下三個步驟:
因爲RenderScript主要是用於音視頻、圖像處理等細分領域,這裏筆者就不繼續深刻擴展了,對於NDK、音視頻領域的知識,筆者在今年會有一系列學習計劃,目前大綱已經定製好了,若是有興趣的朋友,能夠了解下:Awesome-Android-NDK。
我在作佈局優化的過程當中,用到了不少的工具,可是每個工具都有它不一樣的使用場景,不一樣的場景應該使用不一樣的工具。下面我從線上和線下兩個角度來進行分析。
好比說,我要統計線上的FPS,我使用的就是Choreographer這個類,它具備如下特性:
同時,在線下,若是要去優化佈局加載帶來的時間消耗,那就須要檢測每個佈局的耗時,對此我使用的是AOP的方式,它沒有侵入性,同時也不須要別的開發同窗進行接入,就能夠方便地獲取每個佈局加載的耗時。若是還要更細粒度地去檢測每個控件的加載耗時,那麼就須要使用LayoutInflaterCompat.setFactory2這個方法去進行Hook。
此外,我還使用了LayoutInspector和Systrace這兩個工具,Systrace能夠很方便地看到每幀的具體耗時以及這一幀在佈局當中它真正作了什麼。而LayoutInspector能夠很方便地看到每個界面的佈局層級,幫助咱們對層級進行優化。
分析完佈局的加載流程以後,咱們發現有以下四點可能會致使佈局卡頓:
對此,咱們的優化方式有以下幾種:
對於Android的佈局優化,筆者以一種自頂向下,層層遞進的方式和你們一塊兒深刻地去探索了Android中如何將佈局優化作到極致,其中主要涉及如下八大主題:
能夠看到,佈局優化看似是Android性能優化中最簡單的專項優化項,可是筆者卻花費了整整3、四萬字的篇幅才能比較完整地將其核心知識傳授給你們。所以,不要小看每個專項優化點,深刻進去,一定滿載而歸。
一、國內Top團隊大牛帶你玩轉Android性能分析與優化 第五章 佈局優化
六、騷年你的屏幕適配方式該升級了!-smallestWidth 限定符適配方案
十二、GAPID-Graphics API Debugger
1八、Test UI performance-gfxinfo
1九、使用dumpsys gfxinfo 測UI性能(適用於Android6.0之後)
2五、[Google Flutter 團隊出品] 深刻了解 Flutter 的高性能圖形渲染
若是這個庫對您有很大幫助,您願意支持這個項目的進一步開發和這個項目的持續維護。你能夠掃描下面的二維碼,讓我喝一杯咖啡或啤酒。很是感謝您的捐贈。謝謝!
歡迎關注個人微信:
bcce5360
微信羣若是不能掃碼加入,麻煩你們想進微信羣的朋友們,加我微信拉你進羣。
2千人QQ羣,Awesome-Android學習交流羣,QQ羣號:959936182, 歡迎你們加入~