Android 事件分發介紹

来源:https://www.cnblogs.com/zhiqinlin/Undeclared/17957738
-Advertisement-
Play Games

Android 中 View 的佈局是一個樹形結構,各個 ViewGroup 和 View 是按樹形結構嵌套佈局的,從而會出現用戶觸摸的位置坐標可能會落在多個 View 的範圍內,這樣就不知道哪個 View 來響應這個事件,為瞭解決這一問題,就出現了事件分發機制。 ...


目錄

一、目的

        最開始接觸Android時,僅僅是知道Android系統存在的點擊事件、觸摸事件,但是並不清楚這些事件的由來。
        之後,在面試Oppo和美圖時,皆有問到Android的事件分發機制,但是都被問得很懵逼,歸根到底都是對於其實現邏輯的不理解。
        隨後,想去彌補該模塊的不足,瀏覽很多關於Android事件分發的博文,但仍存在一些疑惑,就想著去閱讀下源碼,整理下筆記,希望對同學們有幫助。

二、環境

  1. 版本:Android 11
  2. 平臺:展銳 SPRD8541E

三、相關概念

3.1 事件分發

        Android 中 View 的佈局是一個樹形結構,各個 ViewGroup 和 View 是按樹形結構嵌套佈局的,從而會出現用戶觸摸的位置坐標可能會落在多個 View 的範圍內,這樣就不知道哪個 View 來響應這個事件,為瞭解決這一問題,就出現了事件分發機制。

四、詳細設計

4.1應用佈局

4.1.1 應用佈局結構

        如下為一個Activity打開後,其對應視圖的層級結構。

4.1.2 LayoutInspector

        Layout Inspector是google提供給我們進行佈局分析的一個工具,也是目前google在棄用Hierarchy View後推薦使用的一款佈局分析工具。

4.2 關鍵View&方法

4.2.1 相關View

組件 描述
Activity Android事件分發的起始端,其為一個window視窗,內部持有Decorder視圖,該視圖為當前窗體的根節點,同時,它也是一個ViewGroup容器。
ViewGroup Android中ViewGroup是一個佈局容器,可以嵌套多個 ViewGroup 和 View,事件傳遞和攔截都由 ViewGroup 完成。
View 事件傳遞的最末端,要麼消費事件,要麼不消費把事件傳遞給父容器

4.2.2 相關方法

方法 描述
dispatchTouchEvent 分發事件
onInterceptTouchEvent 攔截事件
onTouchEvent 觸摸事件

4.2.3 View與方法關係

組件 dispatchTouchEvent onInterceptTouchEvent onTouchEvent
Activity
ViewGroup
View

4.3 事件分發概念圖

4.3.1 事件分發類圖

4.3.2 事件分發模型圖

        Android的ACTION_DOWN事件分發如圖,從1-9步驟,描述一個down事件的分發過程,如果大家能懂,就不用看下麵文字描述了(寫完這個篇幅,感覺文字好多,不好理解!)

  1. ACTION_DOWN事件觸發。 當我們手指觸摸屏幕,tp驅動會響應中斷,通過ims輸入系統,將down事件的相關信息發送到當前的視窗,即當前的Activity。
  2. Activity事件分發。 會引用dispatchTouchEvent()方法,對down事件分發。Activity本身會持有一個window對象,window對象的實現類PhoneWindow會持有一個DecorView對象,DecorView是一個ViewGroup對象,即我們可以理解為,Activity最終會將事件分發給下一個節點——ViewGroup。
  3. ViewGroup事件攔截。 ViewGroup接收到事件後,會先引用onInterceptTouchEvent(),查看當前的視圖容器是否做事件攔截。
  4. ViewGroup消費事件。 如當前的ViewGroup對事件進行攔截,即會調用onTouchEvent(),對事件消費。
  5. ViewGroup事件不攔截。 則ViewGroup會繼續遍歷自身的子節點,並且當事件的坐標位於子節點上,則繼續下發到下一個節點。ViewGroup的子節點有可能是View,也可能是ViewGroup(當然,ViewGroup最後也是繼承於View的,突然感覺有點廢話)。
  6. ViewGroup事件分發。 目標視圖如果是ViewGroup,會引用其super類的dispatchTouchEvent()方法,即事件下發,不管目標視圖是View或者ViewGroup最終引用的是View類的分發方法。
  7. View事件消費。 在View的dispatchTouchEvent()方法中會根據當前View是否可以點擊、onTouch()是否消費、onTouchEvent()是否消費等條件,來判斷當前是否為目標View。
  8. View事件未消費。 View事件未消費,則其父節點,即ViewGroup會調用onTouchEvent()方法,並根據返回值來決定是否消費事件。
  9. ViewGroup事件未消費。 ViewGroup事件未消費,擇其父節點,即Actviity會調用onTouchEvent()方法

PS:
(1) ACTION_MOVEACTION_UP事件,流程與ACTION_DOWN的分發過程基本一致,MOVE和UP事件也是通過Activity開始,藉助DOWN事件產生的目標View,逐級分發。
(2) ACTION_CANCEL事件,是在down與up、move事件切換過程中,事件被攔截,兩次的touchTarget目標view不一致,而產生的事件。用於對之前的目標View做恢復處理,避免down與up/move事件不對稱。

4.4 Activity組件

4.4.1 Activity->dispatchTouchEvent()

        底層上報的事件信息,最終會引用到該方法。Activity會持有一個根視圖DecordView,事件最終會往該ViewGroup分發,如所有的View都未消費該事件,則最終由Activity的onTouchEvent()
來兜底處理。

@frameworks\base\core\java\android\app\Activity.java
public boolean dispatchTouchEvent(MotionEvent ev) {
    ...
    if (getWindow().superDispatchTouchEvent(ev)) {//Step 1. 查看Window對應的View是否分發該事件
        return true;
    }
    return onTouchEvent(ev);//Step 2. 如果沒有組件消費事件,則由Activity兜底處理
}

4.4.2 Activity->getWindow()

        我們每次啟動一個Activity的組件,會先打開一個window視窗,而PhoneWindow是Window唯一的實現類。

@frameworks\base\core\java\android\app\Activity.java
public Window getWindow() {
    return mWindow;
}

final void attach(Context context, ActivityThread aThread...) {
    ...
    mWindow = new PhoneWindow(this, window, activityConfigCallback);//PhoneWindow是Window視窗唯一的實現類
    ...
}

        PhoneWindow對象內部持有DecorView對象,而該View正是該視窗對應的視圖容器,也是根節點。(此部分不具體分析)

@frameworks\base\core\java\com\android\internal\policy\PhoneWindow.java
public class PhoneWindow extends Window implements MenuBuilder.     Callback {
    ...
    private DecorView mDecor;//
    ...
    @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return mDecor.superDispatchTouchEvent(event);//往View的根節點分發事件
    }
}

4.4.3 Activity->onTouchEvent()

        Activity的onTouchEvent方法,是在沒有任何組件消費事件的情況下,觸發的方法。

@frameworks\base\core\java\android\app\Activity.java
public boolean onTouchEvent(MotionEvent event) {
    if (mWindow.shouldCloseOnTouch(this, event)) {
        finish();
        return true;
    }
    return false;
}

4.5 ViewGroup組件

        ViewGroup組件在整個事件分發的模型中,既有分發事件的責任,又要具備處理事件的能力,真的典型的當爹又當媽。
        當Activity調用superDispatchTouchEvent,即最終會使用到DecorView的superDispatchTouchEvent方法,而DecorView是繼承於ViewGroup,即最終會引用ViewGroup的dispatchTouchEvent方法。

4.5.1 ViewGroup->dispatchTouchEvent()

此方法為事件分發最核心的代碼。其主要處理如下四件事情:
Setp 1. 重置事件。 一次完整觸摸的事件:DOWN -> MOVE -> UP,即我們可以理解為DOWN是所有觸摸事件的起始事件。當輸入事件是ACTION_DOWN時,重置觸摸事件狀態信息,避免產生干擾。
Step 2. 攔截事件。 攔截事件是ViewGroup特有的方法,用於攔截事件,並將該事件分發給自己消費,防止事件繼續下發。
Step 3.查找目標View。 查找目標View主要針對於Down事件。當ViewGroup未攔截事件,且輸入事件是ACTION_DOWN時,會遍歷該ViewGroup的所有子節點,並根據觸摸位置的坐標,來決定當前子節點是否是下一級目標View。當找到目標View節點後,會分發Down事件,並記錄該節點信息。
Step 4.下發事件。 如果目標View未找到的話,則會將事件交由自己的onTouchEvent()處理;如果目標View已經找到,則Down事件就此結束(此處暫不考慮多指場景);Move和Up事件將繼續下發(預設情況下Move、Up和Down事件是成對出現的,如果目標View已經存在,則Down事件已經下發,即意味著Move和Up事件也需要下發給對應的目標View)。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    ...
    if (actionMasked == MotionEvent.ACTION_DOWN) {//Step 1.重置事件信息,避免影響下一次事件
        cancelAndClearTouchTargets(ev);
        resetTouchState();
    }

    if (actionMasked == MotionEvent.ACTION_DOWN
            || mFirstTouchTarget != null) {
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
        if (!disallowIntercept) {
            intercepted = onInterceptTouchEvent(ev);//Step 2.攔截事件
            ev.setAction(action); // restore action in case it was changed
        }
    } 
    ...
    if (!canceled && !intercepted) {//Step 3.查找目標View
        if (actionMasked == MotionEvent.ACTION_DOWN
                || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
            ...
            if (newTouchTarget == null && childrenCount != 0) {
                ...
                for (int i = childrenCount - 1; i >= 0; i--) {//遍歷所有的子節點
                    ...
                    if (!child.canReceivePointerEvents()
                            || !isTransformedTouchPointInView(x, y, child, null)) {// 子節點不可以接收事件,或者觸摸位置不在子節點的範圍上
                        continue;
                    }
                    ...
                    if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {//找到目標View
                        ...
                        break;
                    }
                }
                ...
            }
            ...
        }
    }
    //Step 4.根據找到的目標View情況,繼續下發事件
    if (mFirstTouchTarget == null) {
        // No touch targets so treat this as an ordinary view.
        handled = dispatchTransformedTouchEvent(ev, canceled, null,
                TouchTarget.ALL_POINTER_IDS);//沒有找到目標View或者事件被攔截,事件下發給自己
    } else {
        ...
        while (target != null) {//多組數據,一般是指多指場景
            final TouchTarget next = target.next;
            if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {//此場景一般是down事件
                handled = true
            } else {
                if (dispatchTransformedTouchEvent(ev, cancelChild,
                        target.child, target.pointerIdBits)) {//此場景一般是move、up事件
                    handled = true;
                }
                ...
            }
            predecessor = target;
            target = next;
        }
        ...
    }
    ...
    return handled;
}

4.5.2 ViewGroup->dispatchTransformedTouchEvent()

事件分發關鍵方法,主要用於向目標View分發事件,具體邏輯如下:
Step 1.Cancel事件分發。 之前我們提過Down和Up事件是成對存在的,如果Down事件已經下發的情況下,Up事件卻因為事件攔截等原因,未能下發給目標View,目標View未收到Up事件,此時就可能產生一些按壓狀態的異常問題,故,在當前場景下,將會分發一個ACTION_CANCEL事件給目標View。
Step 2.事件處理。 如果事件未找到目標View,則child會為null,此時的事件將由自身處理。
Step 3.事件分發。 如果事件還存在目標View,則此時的事件會再分發。

    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
        ...
        if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {//Step 1.下發取消事件
            event.setAction(MotionEvent.ACTION_CANCEL);
            if (child == null) {
                handled = super.dispatchTouchEvent(event);
            } else {
                handled = child.dispatchTouchEvent(event);
            }
            event.setAction(oldAction);
            return handled;
        }
        ...
        if (child == null) {//Step 2.如果事件未找到目標View,則觸摸事件會發給自己
            handled = super.dispatchTouchEvent(transformedEvent);
        } else {
            final float offsetX = mScrollX - child.mLeft;
            final float offsetY = mScrollY - child.mTop;
            transformedEvent.offsetLocation(offsetX, offsetY);
            if (! child.hasIdentityMatrix()) {
                transformedEvent.transform(child.getInverseMatrix());
            }
            handled = child.dispatchTouchEvent(transformedEvent);//Step 3.找到目標View,事件下發給子節點
        }
        ...
        return handled;
    }

4.6 View組件

        View組件在事件處理模型中,主要是處理事件。我們知道ViewGroup,也是繼承於View,所以ViewGroup也是同樣具備View的處理事件能力。

4.6.1 View->dispatchTouchEvent()

Step 1.觸發onTouch()方法。 如果當前的View是可點擊的,且配置了onTouch事件監聽,則觸發該View的onTouch()方法。
Step 2.觸發onTouchEvent()方法。 如果該事件在上一步的onTouch()函數中未被消費,則觸發onTouchEvent()方法。

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;
    ...
    if (onFilterTouchEventForSecurity(event)) {
        ...
        ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {//Step 1.觸發onTouch事件
            result = true;
        }

        if (!result && onTouchEvent(event)) {//Step 2.如onTouch未消費,觸發onTouchEvent事件
            result = true;
        }
    }
    ...
    return result;
}

4.6.2 OnTouchListener->onTouch()

        View可以設置事件監聽,用於監聽onTouch事件的回調,當然,像我們常見的onClick()、onLongClick()等事件也可監聽,其相關源碼如下:

@frameworks\base\core\java\android\view\View.java
public void setOnTouchListener(OnTouchListener l) {//設置onTouch監聽
    getListenerInfo().mOnTouchListener = l;
}

ListenerInfo getListenerInfo() {
    if (mListenerInfo != null) {
        return mListenerInfo;
    }
    mListenerInfo = new ListenerInfo();
    return mListenerInfo;
}

public interface OnTouchListener {//Touch介面,用於回調onTouch事件
    boolean onTouch(View v, MotionEvent event);
}

4.6.3 View->onTouchEvent()

        事件如未被onTouch消費掉,則會引用到onTouchEvent()方法,該方法會涉及ACTION_UP、ACTION_DOWN、ACTION_CANCEL、ACTION_MOVE事件的處理,View的onClick()、onLongClick()也是由該方法觸發。此外,如果當前的View是可點擊的話,則直接消費該事件。

public boolean onTouchEvent(MotionEvent event) {
    ...
    final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
        || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
        || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;//當前View是否可點擊
    ...
    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP://抬起
                ...
                if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
                    if (!focusTaken) {
                        removeLongPressCallback();//若有長按事件未處理,則移除長按事件
                        if (mPerformClick == null) {
                            mPerformClick = new PerformClick();
                        }
                        if (!post(mPerformClick)) {//通過Hanlder將點擊事件發送到主線程執行
                            performClickInternal();//如果不成功,則直接引用點擊事件
                        }
                    }
                }
                if (mUnsetPressedState == null) {
                    mUnsetPressedState = new UnsetPressedState();//更新按鈕的按壓事件
                }
                ...
                break;
            case MotionEvent.ACTION_DOWN://按下
                ...
                if (isInScrollingContainer) {//在可滾動的容器內,為了容錯,延遲點擊
                    ...
                    postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                } else {
                    setPressed(true, x, y);//設置按下的狀態
                    checkForLongClick(
                            ViewConfiguration.getLongPressTimeout(),
                            x,
                            y,
                            TOUCH_GESTURE_CLASSIFIED__CLASSIFICATION__LONG_PRESS);//開啟一個長按延時事件
                }
                break;

            case MotionEvent.ACTION_CANCEL://取消
                ...
                break;
            case MotionEvent.ACTION_MOVE://移動
                ...
                break;
        }
        return true;//如果是可點擊的View,即消費事件
    }
    ...
    return false;
}

4.7 例子-點擊事件時序圖

        如下是Android的點擊事件時序圖,如果能夠理解單擊事件的由來,對於整個事件分發的知識要點已大體掌握。

五、小結&問題點

  1. 事件分發流程?包括ACTION_DWON、ACTION_UP、ACTION_MOVE事件的處理過程;
  2. ACTION_CANCEL事件的使用場景?父控制項對move事件攔截場景?
  3. 單擊、長按、觸摸事件的產生過程?
  4. 點擊一個View未抬起,同時move該事件直至離開當前View的範圍,處理過程如何?
  5. 如果所有View都未消費事件,流程如何?
  6. ViewPage+ListView,左右滑動和上下滑動衝突的解決問題?即事件攔截過程?
  7. 普通的View是根據什麼來決定是否消費事件,例如Button?
    =>答:如無重寫onTouchEvent事件,根據當前的View是否可點擊,來決定是否消費事件。

        我最開始沒有看源碼,直接去看博客上的內容,彎彎繞繞,似懂非懂。在面試的過程中,面試官舉個場景分析流程,我都懵逼,分析不出來,現場很尷尬。之後看源碼,整體流程代碼量很少,感嘆於Android事件分發流程的設計,很少的代碼量,卻承載了很重要的功能,而沒有見過該模塊發生過異常。
        多讀書,多看報,少吃零食,多睡覺!

六、代碼倉庫地址

Demo地址:  https://gitee.com/linzhiqin/custom-demo

七、參考資料

https://zhuanlan.zhihu.com/p/623664769?utm_id=0
事件分發視頻(總結很好,但是得先理解基本概念,才方便學習)
https://www.bilibili.com/video/BV1sy4y1W7az?p=1&vd_source=f222e3bf3083cad8d9f660629bc47c16


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 使用STM32CubeMX軟體配置STM32F407開發板上串口USART1進行DMA傳輸數據,然後實現與實驗STM32CubeMX教程9 USART/UART 非同步通信相同的目標 ...
  • 文件系統結構 unix的文件系統相關知識 unix將可用的磁碟空間劃分為兩種主要類型的區域:inode區域和數據區域。 unix為每個文件分配一個inode,其中保存文件的關鍵元數據,如文件的stat屬性和指向文件數據塊的指針。 數據區域中的空間會被分成大小相同的數據塊(就像記憶體管理中的分頁)。數據 ...
  • 1月9日,計世資訊(CCW Research)發佈《2022-2023年中國信創資料庫行業市場研究報告》(以下簡稱“報告”),從產品技術能力和市場及戰略能力兩個維度對我國主要資料庫產品服務商進行競爭力分析。其中,中國電信天翼雲憑藉其產品豐富的管理功能、靈活的部署架構,位列雲資料庫產品領域領導者象限。 ...
  • 作者:俊達 引言 MySQL是MySQL安裝包預設的客戶端,該客戶端程式通常位於二進位安裝包的bin目錄中,或者通過rpm安裝包安裝mysql-community-client,是資料庫管理系統的重要組成部分。MySQL客戶端不僅僅是一個簡單的軟體工具,更是連接用戶與資料庫之間的橋梁,對於有效地使用 ...
  • 作者:櫰木 環境準備 本次使用到的二進位軟體包目錄為:系統初始化前提是操作系統已完成安裝、各個主機之間網路互通,系統常用命令已安裝,本預設這些前提條件已具備,不在闡述。 1 主機環境初始化 安裝centos系統完成後需要對主機進行初始化配置和驗證工作,在所有主機上(hd1.dtstack.com-h ...
  • 摘要 隨著任務數量、任務類型需求不斷增長,對我們的數據開發平臺提出了更高的要求。本文主要分享我們將調度引擎升級到 Apache DolphinScheduler 的實踐經驗,以及對數據開發平臺的一些思考。 1. 背景 首先介紹下我們的大數據平臺架構: 數據計算層承接了全公司的數據開發需求,負責運行各 ...
  • 一、背景 為瞭解決應卡頓,分析耗時。 二、原理 Looper中的loop方法: public static void loop() { ... for (;;) { ... // This must be in a local variable, in case a UI event sets th ...
  • ☞ Github ☜ ☞ Gitee ☜ 說明 Binder作為Android系統跨進程通信的核心機制。網上也有很多深度講解該機制的文章,如: Android跨進程通信詳解Binder機制原理 Android系統核心機制Binder【系列】 這些文章和系統源碼可以很好幫助我們理解Binder的實現原 ...
一周排行
    -Advertisement-
    Play Games
  • 在C#中使用SQL Server實現事務的ACID(原子性、一致性、隔離性、持久性)屬性和使用資料庫鎖(悲觀鎖和樂觀鎖)時,你可以通過ADO.NET的SqlConnection和SqlTransaction類來實現。下麵是一些示例和概念說明。 實現ACID事務 ACID屬性是事務處理的四個基本特征, ...
  • 我們在《SqlSugar開發框架》中,Winform界面開發部分往往也用到了自定義的用戶控制項,對應一些特殊的界面或者常用到的一些局部界面內容,我們可以使用自定義的用戶控制項來提高界面的統一性,同時也增強了使用的便利性。如我們Winform界面中用到的分頁控制項、附件顯示內容、以及一些公司、部門、菜單的下... ...
  • 在本篇教程中,我們學習瞭如何在 Taurus.MVC WebMVC 中進行數據綁定操作。我們還學習瞭如何使用 ${屬性名稱} CMS 語法來綁定頁面上的元素與 Model 中的屬性。通過這些步驟,我們成功實現了一個簡單的數據綁定示例。 ...
  • 是在MVVM中用來傳遞消息的一種方式。它是在MVVMLight框架中提供的一個實現了IMessenger介面的類,可以用來在ViewModel之間、ViewModel和View之間傳遞消息。 Send 接受一個泛型參數,表示要發送的消息內容。 Register 方法用於註冊某個對象接收消息。 pub ...
  • 概述:在WPF中,通過EventHandler可實現基礎和高級的UI更新方式。基礎用法涉及在類中定義事件,併在UI中訂閱以執行更新操作。高級用法藉助Dispatcher類,確保在非UI線程上執行操作後,通過UI線程更新界面。這兩種方法提供了靈活而可靠的UI更新機制。 在WPF(Windows Pre ...
  • 概述:本文介紹了在C#程式開發中如何利用自定義擴展方法測量代碼執行時間。通過使用簡單的Action委托,開發者可以輕鬆獲取代碼塊的執行時間,幫助優化性能、驗證演算法效率以及監控系統性能。這種通用方法提供了一種便捷而有效的方式,有助於提高開發效率和代碼質量。 在軟體開發中,瞭解代碼執行時間是優化程式性能 ...
  • 概述:Cron表達式是一種強大的定時任務調度工具,通過配置不同欄位實現靈活的時間規定。在.NET中,Quartz庫提供了簡便的方式配置Cron表達式,實現精準的定時任務調度。這種靈活性和可擴展性使得開發者能夠根據需求輕鬆地制定和管理定時任務,例如每天備份系統日誌或其他重要操作。 Cron表達式詳解 ...
  • 概述:.NET提供多種定時器,如System.Windows.Forms.Timer適用於UI,System.Web.UI.Timer用於Web,System.Diagnostics.Timer用於性能監控,System.Threading.Timer和System.Timers.Timer用於一般 ...
  • 問題背景 有同事聯繫我說,在生產環境上,訪問不了我負責的common服務,然後我去檢查common服務的health endpoint, 沒問題,然後我問了下異常,timeout導致的System.OperationCanceledException。那大概率是客戶端的問題,會不會是埠耗盡,用ne ...
  • 前言: 在本篇 Taurus.MVC WebMVC 入門開發教程的第四篇文章中, 我們將學習如何實現數據列表的綁定,通過使用 List<Model> 來展示多個數據項。 我們將繼續使用 Taurus.Mvc 命名空間,同時探討如何在視圖中綁定並顯示一個 Model 列表。 步驟1:創建 Model ...