Android 子線程更新UI瞭解嗎?

前言

今天一個朋友去面試,被問到java

  • 爲何Loop 死循環而不阻塞UI線程?
  • 爲何子線程不能更新UI?是否是子線程必定不能夠更新UI?
  • SurfaceView是爲何能夠直接子線程繪製呢?
  • 用SurfaceView 作一個小遊戲,別踩百塊,so easy!

今天咱們來一塊兒討論一下這些問題,在看下面討論時,你須要掌握Android Handler,View 線程等基礎知識。android

單線程 異步消息的原理

咱們剛開始學習移動端開發的時候,不論是Android,仍是IOS,常常會聽到一句話,網絡請求是耗時操做,須要開一個單獨的線程請求網絡。程序員

而若是最近接觸過Flutter的同窗,可能知道網絡請求只是一個異步操做,不須要開單獨的線程或者進程進行耗時請求,那這種機制是什麼樣的原理呢?面試

這裏先解釋一下,網絡請求是一個耗時操做的確是沒問題的,可是他不是一個耗CPU的操做,他僅僅是一個異步操做。那異步操做是否是能夠用單線程就實現了呢?(由於他不耗CPU)canvas

咱們看一下異步消息的模型(生產者消費者模型),以下:安全

那麼單線程的話,怎麼搞呢?其實只要一個消息不斷的去讀隊列,若是沒有消息,那就只等待狀態,只要有消息進來,好比點擊事件,滑動事件等,就能夠直接取出消息執行。bash

下面咱們來看一下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刷新高效呢?app

事實就是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();
            }
        }
    }
複製代碼

因爲代碼量比較小,直接上傳到了百度雲網盤,地址: pan.baidu.com/s/1-pSwF34O… 提取碼: 2j3a

總結

在Android/IOS/Flutter/Window中,都有消息循環這套機制,保證了UI高效,安全。咱們做爲Android開發程序員,有必要掌握。若是文章對你有幫助,幫忙點一下贊,很是謝謝。

相關文章
相關標籤/搜索