前言
今天一個朋友去面試,被問到
- 爲什麼Loop 死循環而不阻塞UI線程?
- 爲什麼子線程不能更新UI?是不是子線程一定不可以更新UI?
- SurfaceView是爲什麼可以直接子線程繪製呢?
- 用SurfaceView 做一個小遊戲,別踩百塊,so easy!
今天我們來一起討論一下這些問題,在看下面討論時,你需要掌握Android Handler,View 線程等基礎知識。
單線程 異步消息的原理
我們剛開始學習移動端開發的時候,不管是Android,還是IOS,經常會聽到一句話,網絡請求是耗時操作,需要開一個單獨的線程請求網絡。
而如果最近接觸過Flutter的同學,可能知道網絡請求只是一個異步操作,不需要開單獨的線程或者進程進行耗時請求,那這種機制是什麼樣的原理呢?
這裏先解釋一下,網絡請求是一個耗時操作的確是沒問題的,但是他不是一個耗CPU的操作,他僅僅是一個異步操作。那異步操作是不是可以用單線程就實現了呢?(因爲他不耗CPU)
我們看一下異步消息的模型(生產者消費者模型),如下:
那麼單線程的話,怎麼搞呢?其實只要一個消息不斷的去讀隊列,如果沒有消息,那就只等待狀態,只要有消息進來,比如點擊事件,滑動事件等,就可以直接取出消息執行。
下面我們來看一下Android裏面的異步消息實現機制 Handler,主線程在APP啓動(ActivityThread)的時候,就會啓動消息循環,如下:
//ActivityThread 省略部分代碼
public static void main(String[] args) {
AndroidOs.install();
Process.setArgV0("<pre-initialized>");
Looper.prepareMainLooper(); //Handler啓動機制: Looper.prepare()
ActivityThread thread = new ActivityThread();
thread.attach(false, startSeq);
if (sMainThreadHandler == null) {
sMainThreadHandler = thread.getHandler();
}
Looper.loop();////Handler啓動原理: Looper.loop()
}
爲什麼Loop 死循環而不阻塞UI線程?
//Looper
public static void loop() {
final Looper me = myLooper();
for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}
...
}
}
....
這個從上面的單線程異步消息模型,我們就可以知道,他不是阻塞線程了,而是只要有消息插入MessageQueue隊列,就可以直接執行。
UI更新被設計成單線程(主線程或者說是UI線程)的原因
我們知道UI刷新,需要在規定時間內完成,以此帶來流暢的體驗。如果刷新頻率是60HZ的話,需要在16ms內完成一幀的繪製,除了一些人爲原因,怎麼做才能達到UI刷新高效呢?
事實就是UI線程被設計成單線程訪問?這樣有什麼好處呢?
- 單線程訪問,是不需要加鎖的。
- 如果多個線程訪問那就需要加鎖,耗時會比較多,如果多線程訪問不加鎖,多個線程共同訪問更新操作同一個UI控件時容易發生不可控的錯誤。
所以UI線程被設計成單線才能程訪問,也是這樣設計的一個僞鎖。
是不是子線程一定不可以更新UI
答案是否定的,有些人可能認爲SurfaceView的畫布就可以在子線程中訪問,這個本來就是另外的一個範疇,我們下一節討論。
從上面一節,我們知道,UI線程被設計成單線程訪問的,但是看代碼,他設計只是在訪問UI的時候檢測線程是否是主線程。如下:
//ViewRootImpl
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
那我們可不可以繞過這個checkThread方法呢?來達到子線程訪問UI,我們先看一段代碼:
public class MainActivity extends AppCompatActivity {
private TextView tvTest;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
tvTest = findViewById(R.id.tvTest);
new Thread(new Runnable() {
@Override
public void run() {
tvTest.setText("測試子線程加載");
}
}).start();
}
}
這段代碼是可以直接運行成功的,並且沒有任何問題,那這是是爲什麼呢?可能你已經猜想到這是爲什麼了—— 繞過了checkThread方法。
下面來分析一下原因:
訪問及刷新UI,最後都會調用到ViewRootImpl,如果對ViewRootImpl還很陌生,可以參考我的另一篇博客 Android 繪製原理淺析【乾貨】。
那麼直接在onCreate 啓動時,ViewRootImpl肯定還沒啓動起來啊,不然,那刷新肯定失敗,我們可以驗證一下。把上面Thread 裏面加一個延遲,變成這樣
public class MainActivity extends AppCompatActivity {
private TextView tvTest;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
tvTest = findViewById(R.id.tvTest);
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
tvTest.setText("測試子線程加載");
}
}).start();
}
}
運行起來直接崩潰
android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:7753)
at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1225)
at android.view.View.requestLayout(View.java:23093)
at android.view.View.requestLayout(View.java:23093)
at android.view.View.requestLayout(View.java:23093)
at android.view.View.requestLayout(View.java:23093)
at android.view.View.requestLayout(View.java:23093)
at android.view.View.requestLayout(View.java:23093)
at androidx.constraintlayout.widget.ConstraintLayout.requestLayout(ConstraintLayout.java:3172)
at android.view.View.requestLayout(View.java:23093)
at android.widget.TextView.checkForRelayout(TextView.java:8908)
at android.widget.TextView.setText(TextView.java:5730)
at android.widget.TextView.setText(TextView.java:5571)
at android.widget.TextView.setText(TextView.java:5528)
at com.ding.carshdemo.MainActivity$1.run(MainActivity.java:27)
和猜想一致,那麼ViewRootImpl是什麼時候被啓動起來的呢?
在Android 繪製原理淺析【乾貨】 中提到,當Activity準備好後,最終會調用到Activity中的makeVisible,並通過WindowManager添加View,代碼如下
//Activity
void makeVisible() {
if (!mWindowAdded) {
ViewManager wm = getWindowManager();
wm.addView(mDecor, getWindow().getAttributes());
mWindowAdded = true;
}
mDecor.setVisibility(View.VISIBLE);
}
看一下wm addView方法
//WindowManagerImpl
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
applyDefaultToken(params);
mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
}
在看一下mGlobal.addView方法
//WindowManagerGlobal
public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow) {
ViewRootImpl root;
.....
View panelParentView = null;
synchronized (mLock) {
root = new ViewRootImpl(view.getContext(), display);
view.setLayoutParams(wparams);
mViews.add(view);
mRoots.add(root);
}
...
}
終於找到了ViewRootImpl的創建。那麼回到上面makeVisible是什麼時候被調用到的呢?
看Activity啓動流程時,我們知道,Ativity的啓動和AMS交互的代碼在ActivityThread中,搜索makeVisible方法,可以看到調用地方爲
//ActivityThrea
public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward,
String reason) {
...
if (r.activity.mVisibleFromClient) {
r.activity.makeVisible();
}
...
}
private void updateVisibility(ActivityClientRecord r, boolean show) {
....
if (show) {
if (!r.activity.mVisibleFromServer) {
if (r.activity.mVisibleFromClient) {
r.activity.makeVisible();
}
...
}
//調用updateVisibility地方爲
handleStopActivity() handleWindowVisibility() handleSendResult()
這裏我們只關注ViewRootImpl創建的第一個地方,從Acitivity聲明週期handleResumeActivity會被優先調用到,也就是說在handleResumeActivity啓動後(OnResume),ViewRootImpl就被創建了,這個時候,就無法在在子線程中訪問UI了,上面子線程延遲了一會,handleResumeActivity已經被調用了,所以發生了崩潰。
SurfaceView是爲什麼可以直接子線程繪製呢?
在Android 繪製原理淺析【乾貨】 提到了,我們一般的View有一個Surface,並且對應SurfaceFlinger的一塊內存區域。這個本地Surface和View是綁定的,他的繪製操作,最終都會調用到ViewRootImpl,那麼這個就會被檢查是否主線程了,所以只要在ViewRootImpl啓動後,訪問UI的所有操作都不可以在子線程中進行。
那SurfaceView爲什麼可以子線程訪問他的畫布呢?如下
public class MainActivity extends AppCompatActivity implements SurfaceHolder.Callback {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
SurfaceView surfaceView = findViewById(R.id.sv);
surfaceView.getHolder().addCallback(this);
}
@Override
public void surfaceCreated(final SurfaceHolder holder) {
new Thread(new Runnable() {
@Override
public void run() {
while (true){
Canvas canvas = holder.lockCanvas();
canvas.drawColor(Color.RED);
holder.unlockCanvasAndPost(canvas);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
}
}
其實查看SurfaceView的代碼,可以發現他自帶一個Surface
public class SurfaceView extends View implements ViewRootImpl.WindowStoppedCallback {
...
final Surface mSurface = new Surface();
...
}
在SurfaceView的updateSurface()中
protected void updateSurface() {
....
if (creating) {
//View自帶Surface的創建
mSurfaceSession = new SurfaceSession(viewRoot.mSurface);
mDeferredDestroySurfaceControl = mSurfaceControl;
updateOpaqueFlag();
final String name = "SurfaceView - " + viewRoot.getTitle().toString();
mSurfaceControl = new SurfaceControlWithBackground(
name,
(mSurfaceFlags & SurfaceControl.OPAQUE) != 0,
new SurfaceControl.Builder(mSurfaceSession)
.setSize(mSurfaceWidth, mSurfaceHeight)
.setFormat(mFormat)
.setFlags(mSurfaceFlags));
}
//SurfaceView 中自帶的Surface
if (creating) {
mSurface.copyFrom(mSurfaceControl);
}
....
}
SurfaceView中的mSurface也有在SurfaceFlinger對應的內存區域,這樣就很容易實現子線程訪問畫布了。
這樣設計有什麼不好的地方嗎?
因爲這個 mSurface 不在 View 體系中,它的顯示也不受 View 的屬性控制,所以不能進行平移,縮放等變換,也不能放在其它 ViewGroup 中,一些 View 中的特性也無法使用。
別踩百塊
我們知道SurfaceView可以在子線程中刷新畫布(所稱的離屏刷新),那做一些刷新頻率高的遊戲,就很適合.下面我們開始擼一個前些年比較火的小遊戲。
看遊戲分爲幾個步驟,這裏主要講一下原理和關鍵代碼(下面有完整代碼地址)
- 繪製一幀
- 動起來
- 手勢交互
- 判斷遊戲是否結束
- 優化內存
繪製一幀
我們把一行都成一個圖像,那麼他有一個黑色塊,和多個白色塊組成. 那就可以簡單抽象爲:
public class Block {
private int height;
private int top;
private int random = 0; //第幾個是黑色塊
}
繪製邏輯
public void draw(Canvas canvas,int random){
this.random=random;
canvas.save();
for(int i=0;i<WhiteAndBlack.DEAFAUL_LINE_NUME;i++){
if(random == i){
blackRect=new Rect(left+i*width,top,width+width*i,top+height);
canvas.drawRect(left+i*width,top,width+width*i,top+height,mPaint);
}else if(error == i){
canvas.drawRect(left+i*width,top,width+width*i,top+height, errorPaint);
}else{
canvas.drawRect(left+i*width,top,width+width*i,top+height,mDefaultPaint);
}
}
canvas.restore();
}
那麼一行的數據有了,我只需要一個List就可以繪製一屏幕的數據
//List<Block> list;
private void drawBg() {
synchronized (list) {
mCanvas.drawColor(Color.WHITE);
if (list.size() == 0) {
for (int i = 0; i <= DEAULT_HEIGHT_NUM; i++) {
addBlock(i);
}
} else {
......
}
}
}
private void addBlock(int i) {
Block blok = new Block(mContext);
blok.setTop(mHeight - (mHeight / DEAULT_HEIGHT_NUM) * i);
int random = (int) (Math.random() * DEAFAUL_LINE_NUME);
blok.draw(mCanvas, random);
list.add(blok);
}
要讓其動起來
SurfaceView在不斷的刷新,那麼只要讓List裏面的數據每一行的top不斷增加,下面沒有數據了,直接添加到上面
//SurfaceView 新開的子線程Thread
@Override
public void run() {
isRunning=true;
while (isRunning){
draw();
}
}
private void draw() {
try {
mCanvas = mHolder.lockCanvas();
if(mCanvas !=null) {
drawBg();
// removeNotBg();
// checkGameover(-1,-1);
}
}catch (Exception e){
}finally {
mHolder.unlockCanvasAndPost(mCanvas);
}
}
private void drawBg() {
synchronized (list) {
mCanvas.drawColor(Color.WHITE);
if (list.size() == 0) {
....
} else {
for (Block block : list) {
//top 不斷添加
block.setTop(block.getTop() + mSpeend);
block.draw(mCanvas, block.getRandom());
}
if (list.get(list.size() - 1).getTop() >= 0) {
Block block = new Block(mContext);
block.setTop(list.get(list.size() - 1).getTop() - (mHeight / DEAULT_HEIGHT_NUM));
int random = (int) (Math.random() * DEAFAUL_LINE_NUME);
block.draw(mCanvas, random);
//如果上面的top出去了,那下面在加一個block
list.add(block);
}
}
mCanvas.drawText(String.valueOf(count),350,mHeight/8,textPaint);
}
}
手勢交互
如果用戶黑塊點擊了,就開始遊戲,如果已經開始,那麼點擊了正確的黑塊,就繪製成灰色並加速,並檢查遊戲是否結束了
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if(isRunning) {
checkGameover((int) event.getX(), (int) event.getY());
}else{
count=0;
list.clear();
mSpeend=0;
thread = new Thread(this);
thread.start();
}
break;
}
return super.onTouchEvent(event);
}
繪製灰色代碼見下面
判斷遊戲是否結束了
- 下面到屏幕底端了,還未點擊
- 點擊錯誤
private boolean checkGameover(int x,int y){
synchronized (list) {
for (Block block : list) {
if(x !=-1 && y !=-1) {
if (block.getBlackRect().contains(x, y)) {
count++;
if(mSpeend == 0){
mSpeend=DensityUtils.dp2px(getContext(),10);
}else if(mSpeend <=10){
mSpeend+=DensityUtils.dp2px(getContext(),2);
}else if(count == 60){
mSpeend+=DensityUtils.dp2px(getContext(),2);
} else if(count == 100){
mSpeend+=DensityUtils.dp2px(getContext(),2);
}else if(count == 200){
mSpeend+=DensityUtils.dp2px(getContext(),1);
} else if(count == 300){
mSpeend+=DensityUtils.dp2px(getContext(),1);
} else if(count == 400){
mSpeend+=DensityUtils.dp2px(getContext(),1);
}
block.setBlcakPaint();
} else if (y > block.getTop() && y < block.getTop() + block.getHeight()) {
isRunning = false;
block.setError(x / block.getWidth());
}
}else{
if(block.getTop()+block.getHeight()-50 >=mHeight && !block.isChick()){
isRunning=false;
block.setError(block.getRandom());
}
}
}
}
return false;
}
最後優化一下內存
因爲我們在不斷的添加block,玩一會內存就爆了,可以學習ListView,劃出屏幕後上方就移除.
private void removeNotBg() {
synchronized (list) {
for (Block block : list) {
if (block.getTop() >= mHeight) {
needRemoveList.add(block);
}
}
if(needRemoveList.size() !=0){
list.removeAll(needRemoveList);
needRemoveList.clear();
}
}
}
由於代碼量比較小,直接上傳到了百度雲網盤,地址:
https://pan.baidu.com/s/1-pSwF34OWuMSTPioFYfWmA 提取碼: 2j3a
總結
在Android/IOS/Flutter/Window中,都有消息循環這套機制,保證了UI高效,安全。我們作爲Android開發程序員,有必要掌握。如果文章對你有幫助,幫忙點一下贊,非常謝謝。