從資料結構與演算法以及設計模式角度去學習View的繪製流程

ztq發表於2020-03-15

0.前言

很多小夥伴可能在學習view的繪製流程原始碼的時候有點抓不住重點,所以在分析程式碼的時候繞來繞去腦袋暈乎乎的。今天我就來給大家化繁為簡,只關注它最核心的東西。從資料結構與演算法還有設計模式的角度帶領大家真正去掌握。我這篇文章旨在讓大家能更深刻理解View繪製流程的設計,不涉及具體的細節。最好的效果是大家先看這篇文章,然後根據文中介紹的知識點去自行檢視原始碼。或者感到吃力的話可以結合別的大牛寫的文章去看原始碼。^_^

1.先修知識點

首先,View體系的資料結構就是樹形結構。ViewGroup繼承View,而且ViewGroup持有View的引用,所以這不就是一個樹的節點嘛。資料結構跟他的演算法是相關的,所以至少你要掌握樹的遍歷,尤其是樹的先序遍歷,也就是深度遍歷。

在view體系設計中也涉及到了幾個設計模式,分別是組合模式,責任鏈模式,模板方法模式。(當然還有其他如觀察者模式,介面卡模式等等,不在這次的討論範圍。)

  1. 組合模式

    如果想實現一個樹狀的關係,那麼就可以使用組合模式。如View和ViewGroup的關係,ViewGroup繼承於View,同時也含有子View的引用集合。組合模式一般用於樹形結構,所以在這裡不需要展開。你只需要知道,View體系本身就是組合模式的體現。

  2. 責任鏈模式

    如果想實現一個呼叫可以讓多個類都有機會去處理,那麼可以使用責任鏈模式。類Node含有一個自己的引用,相當於一個連結串列指標,指向下一個節點。

    class Node {
        public String name;
        public Node next;
    
        public Node(String name) {
            this.name = name;
        }
    
        public void operate(int num) {
            //1.自己先來處理
            System.out.println(String.format("我是節點%s,我在處理:%d", name, num));
            //2.分發給下一節點處理
            if (next != null) {
                next.operate(num);
            }
        }
    }
    複製程式碼
    public class Main {
        public static void main(String[] args) {
            Node[] nodes = new Node[5];
            Node head = nodes[0] = new Node("0");
            for (int i = 1; i < 5; i++) {//構造的連結串列為:0->1->2->3->4
                nodes[i] = new Node(i + "");
                head.next = nodes[i];
                head = nodes[i];
            }
            head = nodes[0];
            head.operate(100);
        }
    }
    結果為:
    我是節點0,我在處理:100
    我是節點1,我在處理:100
    我是節點2,我在處理:100
    我是節點3,我在處理:100
    我是節點4,我在處理:100
    複製程式碼

    通過把每個處理者看成是連結串列上面的一個節點,實現一個呼叫可以分發給多個處理者去處理。

  3. 模板方法模式

    如果某一個功能邏輯的流程是比較固定的,但是有一定的步驟,那麼可以通過模板方法模式把具體步驟交給子類去實現。

    這個怎麼理解呢?以上面責任鏈模式為例,每個節點的operate的流程是固定的:1.自己處理訊息,2.把訊息分發給下一個節點。但是可以發現上面的例子有點雞肋,因為每個Node節點的處理是完全一樣的,這看起來沒什麼意義。好吧,那結合模板方法模式來進行一個改造。

    abstract class Node {
        public String name;
        public Node next;
    
        public Node(String name) {
            this.name = name;
        }
    
        public void operate(int num) {
            //1.自己先來處理
            int result = onOperate(num);
            System.out.println(String.format("處理的結果:%d", result));
            //2.分發給下一節點處理
            if (next != null) {
                next.operate(result);
            }
        }
    
        //抽象方法,具體的處理交給子類根據自己的需求去實現
        protected abstract int onOperate(int num);
    }
    複製程式碼

    可以看到把原先自己直接處理的邏輯抽成了一個抽象函式,這樣子類就必須去實現onOperate方法去做自己的處理邏輯。假設有這樣的需求:實現三個節點,一個是進行+1的操作;一個是進行-1的操作;一個是乘2的操作。

    class AddNode extends Node {
        public AddNode() {
            super("加法器");
        }
    
        @Override
        protected int onOperate(int num) {
            System.out.println(String.format("我是%s,我將對%d進行加1操作", name, num));
            return num + 1;
        }
    }
    
    class MinusNode extends Node {
        public MinusNode() {
            super("減法器");
        }
    
        @Override
        protected int onOperate(int num) {
            System.out.println(String.format("我是%s,我將對%d進行減1操作", name, num));
            return num - 1;
        }
    }
    
    class MultiNode extends Node {
        public MultiNode() {
            super("乘法器");
        }
    
        @Override
        protected int onOperate(int num) {
            System.out.println(String.format("我是%s,我將對%d進行乘2操作", name, num));
            return num * 2;
        }
    }
    複製程式碼
    public class Main {
        public static void main(String[] args) {
            int num = 100;
            //做運算:(num+1)*2-1 = 201
            Node add = new AddNode();
            Node minus = new MinusNode();
            Node multi = new MultiNode();
    
            add.next = multi;
            multi.next = minus;
    
            add.operate(num);
        }
    }
    結果為:
    我是加法器,我將對100進行加1操作
    處理的結果:101
    我是乘法器,我將對101進行乘2操作
    處理的結果:202
    我是減法器,我將對202進行減1操作
    處理的結果:201
    複製程式碼

    模板方法模式體現在:因為每個節點具體的訊息處理邏輯是不一樣的,通過把operate流程固定,把訊息處理邏輯寫成抽象函式onOperate交給節點子類去實現。這樣不同的節點就可以做不同的處理了。

    發現一個彩蛋了沒? onOperate函式怎麼那麼熟悉?

    先來想想經常接觸到的onXXX方法:onCreate,onMeasure,onInterceptTouchEvent……沒錯,事實上掌握了這幾個設計模式,很多時候原始碼的閱讀都會很流暢了。如觸控事件分發,View繪製的三大過程,Activity生命週期回撥,AsyncTask...等等的機制和原理。推薦大家一定要找時間深入研究,成體系地學習一下設計模式。這是高階工程師架構設計必備技能。

  4. 樹的遍歷

    為了真正關注核心點而不被其他的東西干擾帶偏,所以我假定View樹是一個二叉樹,或者說我選取一個二叉樹View樹來進行分析。

    首先來回顧一下樹的遍歷(遞迴版):

從資料結構與演算法以及設計模式角度去學習View的繪製流程

class Node{
    int id;
    Node left;
    Node right;

    public Node(int id) {
        this.id = id;
    }
}
複製程式碼
public class Main {
    public static void main(String[] args) {
        Node[] nodes = new Node[8];
        for (int i = 0; i < 8; i++) {
            nodes[i] = new Node(i);
        }
        nodes[0].left = nodes[1];
        nodes[0].right = nodes[2];
        nodes[1].left = nodes[3];
        nodes[2].left = nodes[4];
        nodes[2].right = nodes[5];
        nodes[3].left = nodes[6];
        nodes[3].right = nodes[7];
        dfs(nodes[0]);
    }

    private static void dfs(Node root) {
        if (root == null) {
            return;
        }
        System.out.println(root.id);
        if (root.left != null) {
            dfs(root.left);
        }
        if (root.right != null) {
            dfs(root.right);
        }
    }
}
結果為:
0
1
3
6
7
2
4
5
複製程式碼

相信很多人都能寫出上面的深度遍歷程式碼,but,這顯然不夠“java”,嚴格來說這是c語言形式的寫法,只是把節點看成是資料實體,不那麼物件導向。那好,我們來實現更加物件導向的深度遍歷寫法。

物件導向也就是類裡面有資料也有行為,那我們就把遍歷的行為交給類去做。說白了就是把dfs函式寫成成員函式。

class Node {
    int id;
    Node left;
    Node right;

    public Node(int id) {
        this.id = id;
    }

    //把dfs寫成成員函式
    public void dfs() {
        System.out.println(this.id);
        if (left != null) {
            left.dfs();
        }
        if (right != null) {
            right.dfs();
        }

    }
}
複製程式碼
public class Main {
    public static void main(String[] args) {
        Node[] nodes = new Node[8];
        for (int i = 0; i < 8; i++) {
            nodes[i] = new Node(i);
        }
        nodes[0].left = nodes[1];
        nodes[0].right = nodes[2];
        nodes[1].left = nodes[3];
        nodes[2].left = nodes[4];
        nodes[2].right = nodes[5];
        nodes[3].left = nodes[6];
        nodes[3].right = nodes[7];
        //dfs(nodes[0]);
        nodes[0].dfs();
    }
}
結果為:
0
1
3
6
7
2
4
5
複製程式碼

好了,樹的遍歷主要是想說明Java版的物件導向的寫法。因為我在百度隨意搜尋了一下,發現基本都是用c語言版本的寫法來寫的。

2.View的Measure流程的核心

前面洋洋灑灑寫了那麼多,現在終於可以應用啦。理解上面的知識點能讓你更加容易理解複雜的View的Measure流程。

為了真正關注核心點而不被其他的東西干擾帶偏,所以我假定View樹是一個二叉樹,或者說我選取一個二叉樹View樹來進行分析。

測量就是計算每個View的大小,先來定義View類。

abstract class View {
    int id;
    int width;
    int height;
    View left;
    View right;

    public View(int id) {
        this.id = id;
    }

    final public void measure(int width, int height) {
        //1.具體如何測量交給子類決定
        onMeasure(width, height);
    }

    //設定測量值
    public void setMeasuredDimension(int w, int h) {
        width = w;
        height = h;
        System.out.println(String.format("%d的測量結果是w=%d,h=%d", id, width, height));
    }

    protected abstract void onMeasure(int width, int height);
}
複製程式碼

很簡陋的一個類,但是包含了最基本的要素了。measure方法裡就用了模板方法模式,把具體如何測量交給子類實現。而且用final關鍵字,所以子類不能覆寫measure,也就是說measure方法的流程不讓改動。

注意:下文子節點是指View樹的子節點,父節點是指View樹的父節點,注意跟父類子類區分開。這是兩回事來的。

好了,再來實現兩個子類,不妨就叫TextView,ImageView。TextView具體的測量就是把父節點傳遞過來的值減去10,而ImageView是減去20。

class TextView extends View {

    public TextView(int id) {
        super(id);
    }

    @Override
    protected void onMeasure(int width, int height) {
        int myW = width - 10;
        int myH = height - 10;
        setMeasuredDimension(myW, myH);
        //去測量子節點
        if (left != null) {
            left.measure(myW, myH);
        }
        if (right != null) {
            right.measure(myW, myH);
        }
    }
}
複製程式碼
class ImageView extends View {

    public ImageView(int id) {
        super(id);
    }

    @Override
    protected void onMeasure(int width, int height) {
        int myW = width - 20;
        int myH = height - 20;
        setMeasuredDimension(myW, myH);
        //去測量子節點
        if (left != null) {
            left.measure(myW, myH);
        }
        if (right != null) {
            right.measure(myW, myH);
        }
    }
}
複製程式碼

大家可以看到,子節點的測量也是交給子類去負責分發測量了。跟之前討論模板方法模式時有點不同,但是本質上是一樣的。只是模板方法模式的例子是父類負責分發,這裡是子類分發。

從資料結構與演算法以及設計模式角度去學習View的繪製流程
構造上圖的View樹進行測試。

public class Main {
    public static void main(String[] args) {
        View decorView = new ImageView(0);
        View imageView1 = new ImageView(1);
        View imageView2 = new ImageView(2);
        View textView3 = new TextView(3);
        View textView4 = new TextView(4);

        decorView.left = imageView1;
        decorView.right = imageView2;
        imageView1.left = textView3;
        imageView1.right = textView4;

        //獲取window視窗大小(一般是手機螢幕大小),假設是1080x1920
        int windowW = 1080;
        int windowH = 1920;
        decorView.measure(windowW, windowH);
    }
}
結果為:
0的測量結果是w=1060,h=1900
1的測量結果是w=1040,h=1880
3的測量結果是w=1030,h=1870
4的測量結果是w=1030,h=1870
2的測量結果是w=1040,h=1880
複製程式碼

根據上圖和執行結果可知,View的測量是深度遍歷的。測量到一個節點時,這個節點負責去發起子節點的測量,這是責任鏈模式;而為了把具體測量實現交給子類,使用了模板方法模式。

3.更進一步

有的小夥伴可能說了,你這個跟Android實際的View程式碼出入有點大啊,你看都沒有體現出View跟ViewGroup呢!好吧,那我們來實現更加貼近Android的程式碼實現吧。

從資料結構與演算法以及設計模式角度去學習View的繪製流程

public class Main {
    public static void main(String[] args) {
        LinearLayout linearLayout0 = new LinearLayout(0);
        LinearLayout linearLayout1 = new LinearLayout(1);
        TextView textView2 = new TextView(2);
        LinearLayout linearLayout3 = new LinearLayout(3);
        LinearLayout linearLayout4 = new LinearLayout(4);

        linearLayout0.left = linearLayout1;
        linearLayout0.right = textView2;
        linearLayout1.left = linearLayout3;
        linearLayout1.right = linearLayout4;

         //獲取window視窗大小,假設是1080x1920
        int windowW = 1080;
        int windowH = 1920;
        linearLayout0.measure(windowW,windowH);

    }
}

class View {
    int id;
    int width;
    int height;

    public View(int id) {
        this.id = id;
    }

    final public void measure(int width, int height) {
        //1.具體如何測量交給子類決定
        onMeasure(width, height);
    }

    //設定測量值
    public void setMeasuredDimension(int w, int h) {
        width = w;
        height = h;
        System.out.println(String.format("%d的測量結果是w=%d,h=%d", id, width, height));
    }

    protected void onMeasure(int width, int height) {
        //預設實現為直接設定父類傳遞過來的引數
        setMeasuredDimension(width, height);
    }

}

class ViewGroup extends View {
    public ViewGroup(int id) {
        super(id);
    }

    //ViewGroup才有子View
    View left;
    View right;

    @Override
    protected void onMeasure(int width, int height) {
        //預設實現為把width,height減去50作為自己的引數
        int myW = width - 50;
        int myH = height - 50;
        setMeasuredDimension(myW, myH);
        //發起子節點的測量
        if (left != null) {
            left.measure(myW, myH);
        }
        if (right != null) {
            right.measure(myW, myH);
        }
    }
}

//View的子類沒有子節點,只需要關心自己的測量
class TextView extends View {
    public TextView(int id) {
        super(id);
    }

    //實現自己的測量邏輯,把width,height減去10
    @Override
    protected void onMeasure(int width, int height) {
        setMeasuredDimension(width - 10, height - 10);
    }
}

//ViewGroup的子類有子節點,需要發起子節點的測量
class LinearLayout extends ViewGroup {
    public LinearLayout(int id) {
        super(id);
    }

    //把width,height減去30作為自己的引數
    @Override
    protected void onMeasure(int width, int height) {
        int myW = width - 30;
        int myH = height - 30;
        setMeasuredDimension(myW, myH);
        //負責發起子節點的測量,這裡實現為先測量右節點再測量左節點
        if (right != null) {
            right.measure(myW, myH);
        }
        if (left != null) {
            left.measure(myW, myH);
        }
    }
}
結果為:
0的測量結果是w=1050,h=1890
2的測量結果是w=1040,h=1880
1的測量結果是w=1020,h=1860
4的測量結果是w=990,h=1830
3的測量結果是w=990,h=1830
複製程式碼

重要的點都在程式碼上註釋了。

可以看到,之前的遍歷順序是01342,現在是02143了,因為LinearLayout是先進行右節點的測量。

4.總結

View的體系設計用到了許多設計模式,這裡主要是責任鏈模式和模板方法模式,理解設計模式能更加容易讀懂原始碼。

View的遍歷是深度遍歷,需要掌握Java版的實現。

layout以及draw流程的核心也差不多也是這樣,大家跟著我說的去分析原始碼效果更好。注意子節點的draw流程直接由父類發起了,子類只需要在onDraw中繪製自己的內容即可。

文中關注的重點在於如何實現一顆View樹的測量過程。還有很多細節沒有涉及,例如MeasureSpec。實際上View最終測量結果是結合我們在xml自己定義的引數和父View自己的引數去決定的。

小提示:大家在看遞迴程式碼時可以結合畫一下呼叫棧去分析。

如有寫得不準確的地方,歡迎交流指正。^_^

相關文章