2017/6/13

Xamarin.Forms 教學系列文(十八.壹) MVVM - 事件自動化 (這章很重要啊各位大哥)




學習目標
  • MVVM 架構
  • ViewModel 建立
  • INPC 縮寫 (極度重要,在下一小節)

太久沒發文居然被朋友說富監... 
讀這章前請先拜讀 16 章 Data Binding,至少要懂這兩個概念
  1.  Source → Target
  2.  Binding 的寫法

MVVM 是一種程式架構,從我們較耳熟能詳的 MVC 架構演化而來,讓這個架構更適合 Xamarin 或 手機開發 使用,最大的好處是降低維護成本。

其目的為:
程式不用自行處理事件控制以及前端物件的資料變化

為了達到這個目的:
需要建立 Model 和 View 的溝通橋樑,亦即 ViewModel擁有主動通知前端的功能

白話文就是:
當後端資料更改時 (編輯或新增),前端顯示資料也會自動跟著改


雖然不寫 MVVM,只用事件驅動 App還是能動,不過一旦學會能夠省下許多維護的成本,有多個 VisualElement 資料來源是同一個時,好用到炸裂


MVVM 將程式分成三層
  • Model - 資料層,有時資料來源會是 file 或 web accesses
  • ViewModel - Model 與 View 的溝通層,處理 Model 的資料將之顯示於 View
  • View - 介面層,目前 XAML 為實作方法

三個介面的溝通都要通過 Method by call,彼此的層級互不影響。


而在 Xamarin,View 和 ViewModel 的溝通可以改用更方便的 Data Binding 。


由於 ViewModel 需要有 主動通知前端 的功能,
其方法為實作 INotifyPropertyChanged 介面,其中包含一個事件:
public interface INotifyPropertyChanged 
{ 
    event PropertyChangedEventHandler PropertyChanged; 
}

 INotifyPropertyChanged 在本章節很常討論,往後文章內都用縮寫 INPC 



看到這邊可能還是會充滿黑人問號,所以說那最重要的

ViewModel 到底是什麼?

簡單來說,就是一個繼承自 INPC 的類別 (Class),內容包含著一些商業邏輯 (Business Logic),並擁有大量可被綁定且會主動通知前端的屬性 (Property)

當前端要顯示資料的 Viewsual Element 綁定 ViewModel 的屬性後,就能不用寫事件控制 (Event Handler) 這類的瑣事。


MVVM Clock

來看範例,其功能為一個簡易的時鐘,每 15 毫秒檢查時間是否有更改,若有,則觸發 INPC 通知前端顯示更改。

可以把 DateTime.Now 視為資料來源 (Model)。

要實作繼承自 INPC 的 ViewModel:
using System;
using System.ComponentModel;
using Xamarin.Forms;

namespace Xamarin.FormsBook.Toolkit
{
    public class DateTimeViewModel : INotifyPropertyChanged
    {
        DateTime dateTime = DateTime.Now; // 初始值

        public event PropertyChangedEventHandler PropertyChanged;

        public DateTimeViewModel()
        {
            // 建構子執行每 15 毫秒更新時間
            Device.StartTimer(TimeSpan.FromMilliseconds(15), OnTimerTick);
        }

        bool OnTimerTick()
        {
            // 指派,執行 set 事件
            DateTime = DateTime.Now;
            return true;
        }

        // Property
        public DateTime DateTime
        {
            private set
            {
                // 當值更改時
                if (dateTime != value)
                {
                    // 將新值存進 dateTime 變數,等待下次比對
                    dateTime = value;

                    //每次都指派新的 PropertyChanged 是為了防止多執行緒時產生的問題。
                    PropertyChangedEventHandler handler = PropertyChanged;

                    //判斷是否 null 也是防止多執行緒會產生的問題
                    if (handler != null)
                    {
                        //通知前端
                        handler(this, new PropertyChangedEventArgs("DateTime"));
                    }
                }
            }

            get
            {
                return dateTime;
            }
        }
    }
}


XAML:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
             xmlns:sys="clr-namespace:System;assembly=mscorlib" 
             xmlns:toolkit= "clr-namespace:Xamarin.FormsBook.Toolkit;assembly=Xamarin.FormsBook.Toolkit" 
             x:Class="MvvmClock.MvvmClockPage">
    <ContentPage.Resources>

        <ResourceDictionary>
            <!-- View Model -->
            <toolkit:DateTimeViewModel x:Key="dateTimeViewModel" />
            
            <Style TargetType="Label">
                <Setter Property="FontSize" Value="Large" />
                <Setter Property="HorizontalTextAlignment" Value="Center" />
            </Style>
        </ResourceDictionary>

    </ContentPage.Resources>
    <StackLayout VerticalOptions="Center">
        
        <!-- 不會隨時間更改的 Label -->
        <Label Text="{Binding Source={x:Static sys:DateTime.Now}, 
            StringFormat='This program started at {0:F}'}" />

        <!-- Data Binding-->
        <Label Text="But now..." />
        <Label Text="{Binding Source={StaticResource dateTimeViewModel}, 
            Path=DateTime.Hour, 
            StringFormat='The hour is {0}'}" />

        <Label Text="{Binding Source={StaticResource dateTimeViewModel}, 
            Path=DateTime.Minute, 
            StringFormat='The minute is {0}'}" />

        <Label Text="{Binding Source={StaticResource dateTimeViewModel},
            Path=DateTime.Second, 
            StringFormat='The seconds are {0}'}" />

        <Label Text="{Binding Source={StaticResource dateTimeViewModel}, 
            Path=DateTime.Millisecond, 
            StringFormat='The milliseconds are {0}'}" />
    </StackLayout>
</ContentPage>

執行結果:


可以把 Source 寫在 StackLayout 的 BindingContext,Binding 的 Label 就不用寫那麼多東西…
<StackLayout VerticalOptions="Center" 
             BindingContext="{StaticResource dateTimeViewModel}">

    <Label Text="{Binding Source={x:Static sys:DateTime.Now}, 
        StringFormat='This program started at {0:F}'}" />

    <Label Text="But now..." /> -

    <Label Text="{Binding Path=DateTime.Hour, StringFormat='The hour is {0}'}" />
    <Label Text="{Binding Path=DateTime.Minute, StringFormat='The minute is {0}'}" />
    <Label Text="{Binding Path=DateTime.Second, StringFormat='The seconds are {0}'}" />
    <Label Text="{Binding Path=DateTime.Millisecond, StringFormat='The milliseconds are {0}'}" />
</StackLayout>


或是直接把 ViewModel 內的 DateTime 設成 Source,Binding 時更精簡…
<StackLayout VerticalOptions="Center">
    <StackLayout.BindingContext>
        <Binding Path="DateTime">
            <Binding.Source>
                <toolkit:DateTimeViewModel />
            </Binding.Source>
        </Binding>
    </StackLayout.BindingContext>

    <Label Text="{Binding Source={x:Static sys:DateTime.Now}, 
        StringFormat='This program started at {0:F}'}" />
    <Label Text="But now..." />
    <Label Text="{Binding Hour, StringFormat='The hour is {0}'}" />
    <Label Text="{Binding Minute, StringFormat='The minute is {0}'}" />
    <Label Text="{Binding Second, StringFormat='The seconds are {0}'}" />
    <Label Text="{Binding Millisecond, StringFormat='The milliseconds are {0}'}" />
</StackLayout>



趁勢來看更複雜的範例~


有互動性的 MVVM

畫面上有兩個 Slider,分別綁定 ViewModel 的兩個屬性,被乘數 (multiplicand) 與乘數 (multiplier)

當拖曳 Slider 時,ViewModel 會將值相乘並於 Label 顯示結果:
using System;
using System.ComponentModel;

namespace SimpleMultiplier
{
    class SimpleMultiplierViewModel : INotifyPropertyChanged
    {
        double multiplicand, multiplier, product;
        public event PropertyChangedEventHandler PropertyChanged;

        //被乘數
        public double Multiplicand
        {
            set
            {
                if (multiplicand != value)
                {
                    multiplicand = value;
                    OnPropertyChanged("Multiplicand");
                    UpdateProduct();
                }
            }
            get
            {
                return multiplicand;
            }
        }

        //乘數
        public double Multiplier
        {
            set
            {
                if (multiplier != value)
                {
                    multiplier = value;
                    OnPropertyChanged("Multiplier");
                    UpdateProduct();
                }
            }
            get
            {
                return multiplier;
            }
        }

        public double Product
        {
            protected set
            {
                if (product != value)
                {
                    product = value;
                    OnPropertyChanged("Product");
                }
            }
            get
            {
                return product;
            }
        }

        void UpdateProduct()
        {
            Product = Multiplicand * Multiplier;
        }

        //OnPropertyChanged 寫成共用副程式
        protected void OnPropertyChanged(string propertyName)
        {
            PropertyChangedEventHandler handler = PropertyChanged;

            if (handler != null)
            {
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
            }
        }
    }
}


XAML:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" 
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:SimpleMultiplier"
             x:Class="SimpleMultiplier.SimpleMultiplierPage" Padding="10, 0">

    <ContentPage.Resources>
        <ResourceDictionary>
            <!-- ViewModel -->
            <local:SimpleMultiplierViewModel x:Key="viewModel" />
            <Style TargetType="Label">
                <Setter Property="FontSize" Value="Large" />
            </Style>
        </ResourceDictionary>
    </ContentPage.Resources>

    <StackLayout BindingContext="{StaticResource viewModel}">
        <StackLayout VerticalOptions="CenterAndExpand">
            <!-- 被乘數 -->
            <Slider Value="{Binding Multiplicand}" />
            
            <!-- 乘數 -->
            <Slider Value="{Binding Multiplier}" />
        </StackLayout>

        <StackLayout Orientation="Horizontal"
                     Spacing="0" 
                     VerticalOptions="CenterAndExpand" 
                     HorizontalOptions="Center">

            <Label Text="{Binding Multiplicand, StringFormat='{0:F3}'}" />
            <Label Text="{Binding Multiplier, StringFormat=' x {0:F3}'}" />
            
            <!-- 結果 -->
            <Label Text="{Binding Product, StringFormat=' = {0:F3}'}" />
        </StackLayout>
    </StackLayout>
</ContentPage>

執行結果:
是不是好用到炸裂...

另外,也可以在 XAML 指派初始值~
<local:SimpleMultiplierViewModel x:Key="viewModel"
                                 Multiplicand="0.5" 
                                 Multiplier="0.5" />


INPC 縮寫

上方兩支範例可以發現兩個問題,

其一為 INPC 使用 弱型別 當作參數 (程式常常死在這種地方,打錯一個字),
另一個問題是每個 set 有太多重複的程式碼,不易維護。

原文書上將這兩個問題的解法縮寫成一個類別,在寫 MVVM 時能夠直接繼承


這部分我將其獨立成下一小小節,也讓資料在網誌上較好查找~









沒有留言:

張貼留言