Android 子線程更新UI了解嗎荞怒?

前言

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

  • 為什么Loop 死循環(huán)而不阻塞UI線程玖像?
  • 為什么子線程不能更新UI紫谷?是不是子線程一定不可以更新UI?
  • SurfaceView是為什么可以直接子線程繪制呢捐寥?
  • 用SurfaceView 做一個小游戲笤昨,別踩百塊,so easy!

今天我們來一起討論一下這些問題握恳,在看下面討論時瞒窒,你需要掌握Android Handler,View 線程等基礎知識乡洼。

單線程 異步消息的原理

我們剛開始學習移動端開發(fā)的時候崇裁,不管是Android,還是IOS束昵,經(jīng)常會聽到一句話拔稳,網(wǎng)絡請求是耗時操作,需要開一個單獨的線程請求網(wǎng)絡锹雏。

而如果最近接觸過Flutter的同學巴比,可能知道網(wǎng)絡請求只是一個異步操作,不需要開單獨的線程或者進程進行耗時請求礁遵,那這種機制是什么樣的原理呢轻绞?

這里先解釋一下,網(wǎng)絡請求是一個耗時操作的確是沒問題的佣耐,但是他不是一個耗CPU的操作政勃,他僅僅是一個異步操作。那異步操作是不是可以用單線程就實現(xiàn)了呢兼砖?(因為他不耗CPU)

我們看一下異步消息的模型(生產(chǎn)者消費者模型)奸远,如下:

image

那么單線程的話,怎么搞呢讽挟?其實只要一個消息不斷的去讀隊列然走,如果沒有消息,那就只等待狀態(tài)戏挡,只要有消息進來芍瑞,比如點擊事件,滑動事件等褐墅,就可以直接取出消息執(zhí)行拆檬。

下面我們來看一下Android里面的異步消息實現(xiàn)機制 Handler洪己,主線程在APP啟動(ActivityThread)的時候,就會啟動消息循環(huán)竟贯,如下:

//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 死循環(huán)而不阻塞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隊列,就可以直接執(zhí)行持际。

UI更新被設計成單線程(主線程或者說是UI線程)的原因

我們知道UI刷新沃琅,需要在規(guī)定時間內(nèi)完成,以此帶來流暢的體驗蜘欲。如果刷新頻率是60HZ的話益眉,需要在16ms內(nèi)完成一幀的繪制,除了一些人為原因姥份,怎么做才能達到UI刷新高效呢郭脂?

事實就是UI線程被設計成單線程訪問?這樣有什么好處呢澈歉?

  • 單線程訪問展鸡,是不需要加鎖的。
  • 如果多個線程訪問那就需要加鎖埃难,耗時會比較多娱颊,如果多線程訪問不加鎖,多個線程共同訪問更新操作同一個UI控件時容易發(fā)生不可控的錯誤凯砍。

所以UI線程被設計成單線才能程訪問,也是這樣設計的一個偽鎖拴竹。

是不是子線程一定不可以更新UI

答案是否定的悟衩,有些人可能認為SurfaceView的畫布就可以在子線程中訪問,這個本來就是另外的一個范疇栓拜,我們下一節(jié)討論座泳。

從上面一節(jié),我們知道幕与,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();
    }
}

這段代碼是可以直接運行成功的诫给,并且沒有任何問題配紫,那這是是為什么呢克胳?可能你已經(jīng)猜想到這是為什么了—— 繞過了checkThread方法内狗。

下面來分析一下原因:
訪問及刷新UI,最后都會調(diào)用到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準備好后揉忘,最終會調(diào)用到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的創(chuàng)建。那么回到上面makeVisible是什么時候被調(diào)用到的呢泣矛?
看Activity啟動流程時疲眷,我們知道,Ativity的啟動和AMS交互的代碼在ActivityThread中您朽,搜索makeVisible方法狂丝,可以看到調(diào)用地方為

//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();
                    }
        ...
}

//調(diào)用updateVisibility地方為
handleStopActivity()  handleWindowVisibility() handleSendResult()

這里我們只關注ViewRootImpl創(chuàng)建的第一個地方,從Acitivity聲明周期handleResumeActivity會被優(yōu)先調(diào)用到哗总,也就是說在handleResumeActivity啟動后(OnResume)几颜,ViewRootImpl就被創(chuàng)建了,這個時候讯屈,就無法在在子線程中訪問UI了蛋哭,上面子線程延遲了一會,handleResumeActivity已經(jīng)被調(diào)用了涮母,所以發(fā)生了崩潰谆趾。

SurfaceView是為什么可以直接子線程繪制呢?

Android 繪制原理淺析【干貨】 提到了,我們一般的View有一個Surface叛本,并且對應SurfaceFlinger的一塊內(nèi)存區(qū)域沪蓬。這個本地Surface和View是綁定的,他的繪制操作来候,最終都會調(diào)用到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的代碼,可以發(fā)現(xiàn)他自帶一個Surface

public class SurfaceView extends View implements ViewRootImpl.WindowStoppedCallback {
    ...
   final Surface mSurface = new Surface();   
   ...
}

在SurfaceView的updateSurface()中

  protected void updateSurface() {
  ....
    if (creating) {
      //View自帶Surface的創(chuàng)建
         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對應的內(nèi)存區(qū)域植锉,這樣就很容易實現(xiàn)子線程訪問畫布了辫樱。

這樣設計有什么不好的地方嗎?

因為這個 mSurface 不在 View 體系中俊庇,它的顯示也不受 View 的屬性控制狮暑,所以不能進行平移,縮放等變換辉饱,也不能放在其它 ViewGroup 中搬男,一些 View 中的特性也無法使用。

別踩百塊

我們知道SurfaceView可以在子線程中刷新畫布(所稱的離屏刷新)彭沼,那做一些刷新頻率高的游戲缔逛,就很適合.下面我們開始擼一個前些年比較火的小游戲。

image

看游戲分為幾個步驟姓惑,這里主要講一下原理和關鍵代碼(下面有完整代碼地址)

  • 繪制一幀
  • 動起來
  • 手勢交互
  • 判斷游戲是否結(jié)束
  • 優(yōu)化內(nèi)存

繪制一幀

image

我們把一行都成一個圖像褐奴,那么他有一個黑色塊,和多個白色塊組成. 那就可以簡單抽象為:

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();
    }

那么一行的數(shù)據(jù)有了于毙,我只需要一個List就可以繪制一屏幕的數(shù)據(jù)

//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里面的數(shù)據(jù)每一行的top不斷增加,下面沒有數(shù)據(jù)了唯沮,直接添加到上面

  //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);
        }
    }

手勢交互

如果用戶黑塊點擊了,就開始游戲,如果已經(jīng)開始介蛉,那么點擊了正確的黑塊萌庆,就繪制成灰色并加速,并檢查游戲是否結(jié)束了

 @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);
    }

繪制灰色代碼見下面

判斷游戲是否結(jié)束了

  • 下面到屏幕底端了币旧,還未點擊
  • 點擊錯誤
  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;
    }

最后優(yōu)化一下內(nèi)存

因為我們在不斷的添加block践险,玩一會內(nèi)存就爆了,可以學習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();
            }
        }
    }

由于代碼量比較小,直接上傳到了百度云網(wǎng)盤于游,地址:
https://pan.baidu.com/s/1-pSwF34OWuMSTPioFYfWmA 提取碼: 2j3a

總結(jié)

在Android/IOS/Flutter/Window中毁葱,都有消息循環(huán)這套機制,保證了UI高效贰剥,安全倾剿。我們作為Android開發(fā)程序員,有必要掌握。如果文章對你有幫助前痘,幫忙點一下贊凛捏,非常謝謝。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末芹缔,一起剝皮案震驚了整個濱河市坯癣,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌最欠,老刑警劉巖示罗,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異芝硬,居然都是意外死亡蚜点,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門拌阴,熙熙樓的掌柜王于貴愁眉苦臉地迎上來绍绘,“玉大人,你說我怎么就攤上這事迟赃∨憔校” “怎么了?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵捺氢,是天一觀的道長藻丢。 經(jīng)常有香客問我,道長摄乒,這世上最難降的妖魔是什么悠反? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮馍佑,結(jié)果婚禮上斋否,老公的妹妹穿的比我還像新娘。我一直安慰自己拭荤,他們只是感情好茵臭,可當我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著舅世,像睡著了一般旦委。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上雏亚,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天缨硝,我揣著相機與錄音,去河邊找鬼罢低。 笑死查辩,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播宜岛,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼长踊,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了萍倡?” 一聲冷哼從身側(cè)響起身弊,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎遣铝,沒想到半個月后佑刷,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡酿炸,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年瘫絮,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片填硕。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡麦萤,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出扁眯,到底是詐尸還是另有隱情壮莹,我是刑警寧澤,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布姻檀,位于F島的核電站命满,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏绣版。R本人自食惡果不足惜胶台,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望杂抽。 院中可真熱鬧诈唬,春花似錦、人聲如沸缩麸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽杭朱。三九已至阅仔,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間弧械,已是汗流浹背八酒。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留梦谜,地道東北人丘跌。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓,卻偏偏與公主長得像唁桩,于是被迫代替她去往敵國和親闭树。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,979評論 2 355

推薦閱讀更多精彩內(nèi)容