天天看點

很好懂的Swift MVVM in Rx

并不是說Rx就是MVVM,隻是用了Rx,才能更有MVVM的趕腳,畢竟iOS原生的MVC架構沒那麼好改變~

是以你以為接下來要寫什麼是MVVM了嘛?自己去百度吧

是以你以為接下來我要寫什麼是Rx了嘛?自己去Github吧

首先有些概念

1. MVVM有哪些檔案

簡單來說就是我們有

Controller

ViewModel

兩個檔案,去做一坨東西,

Controller

操作UI,

ViewModel

操作業務,(嗯,這個是整篇裡說的最官方的了)

2.

Controller

ViewModel

怎麼互動?

當然如果我說

Controller

裡寫

viewModel.doSth()

這樣,你肯定就不會看下去了;

是以我們引入一個【節點】的概念,節點有兩三種,

一種是凸凸的節點,代表着他會向外發射一個能量,至于這能量是個啥,你先别管~對于凸凸,通常情況下你隻需要給他來一發就完了,也可能你需要包裝一下裡面的能量,比如把貓糧裝到盒子包起來然後再發出去

一種是凹凹的節點,代表着他需要外面給他來一發能量,這個要做的事情比較多,你收到能量後需要拿着他去做接下來的事,這是你對應的凸凸需要你完成的,或者說你需要把快遞拆了,然後把貓糧拆了,然後去喂貓,然後觀察貓是不是正常,是不是喜歡吃,然後如果不正常還需要退貨去罵人~

一種是凹凸的節點,這就很牛皮了他既能來一發又能收一發,是以上面提到的他都要幹

而至于能量/快遞盒子是神馬?方法傳遞時候不是需要帶參數麼,你把參數當成能量也闊以~

有點抽象?看下面

class ViewController: UIViewController {
    let viewModel = ViewModel()
    func buttonTapped()
    {
        viewModel.dosth(一發能量)
    }
}
複制代碼
           

這是普通版本的,而在節點之下,Controller中會有個凸凸的節點,而ViewModel就會有個凹凹的節點,然後用厲害的手段把他們結合起來。

class ViewController: UIViewController
{
    let signalOutput = 凸凸<能量>()
    
    override func viewDidLoad() {
        
        // 把凸凸和凹凹結合到一起
        // 别想那些羞射的事情,不是那樣的!
        putTogether(signalOutput , signalInput)
    }
}

class ViewModel
{
    let signalInput = 凹凹<能量>()
}
複制代碼
           

是以說白了就是: 我們把所有方法調用變成了屬性,是以我們不再存在方法調用了,方法會濃縮到屬性裡去,當然你如果把【凹凹】了解成封裝了個

closure

,那我覺得你已經領悟了很多了~,是以下面你應該有這麼個概念,

Button

點選之類的事情,我就需要有個【凸凸】,同時我需要在ViewModel中找個【凹凹】,把他們捏一起讓他們肚子裡的能量傳遞起來,

不過

Button

點下去,你啥都沒,是以隻是一個空的能量在那傳遞,傳遞還是會傳,隻是沒什麼内容,就像你收到一個空的快遞盒子一樣~

還是不了解的話試一下最後一個場景,把凸凸想成送快遞的,把凹凹想成收快遞的,而裡面的能量就是快遞盒子,再想一下,如果還想不通,那就回過去再看看吧~

> 是以我們從三個簡單的實際場景看下,在我目前的思維模式下,是怎麼把簡單問題變得灰常複雜的,這其實就是MVVM幹的事情~

場景一:支付場景下的簡單判斷

現在有個按鈕,點選後觸發一個請求告訴我們使用者有木有設定過支付密碼/

TouchId

/甚至

FaceId

,怎麼高端你就怎麼想;

如果有設定過,直接讓使用者輸入密碼/摸手機/秀臉,操作完成後帶着密碼送出請求,然後完成支付。流程結束

如果木有設定過,那就跳轉到設定頁面結束。流程結束

這應該描述起來很簡單,但是如果按照普通的寫法,不是

Controller

會變的很肥,就是流程間的互動

Closure

會很多

是以該怎麼把這個看着簡單的東西解析成看着灰常複雜的場景呢~

1. 首先我們要知道

Controller

要做些啥

  • 按鈕點選
  • 彈個密碼框讓使用者輸
  • 跳轉去設定密碼
上面是在MVVM中,Controller需要做的事,隻有這些,沒有别的了

是以這三個事情一定會對應三個節點了,那哪些是凸凸哪些是凹凹呢? 對于

Controller

來說很簡單的概念就是,所有從

View

觸發/或者說從界面觸發的東西都是凸凸,比如按鈕點選,比如清單

cell

點選等。

是以上面這三個

  • 按鈕點選 【我是凸凸】
  • 輸入完成密碼【我是凸凸】
  • 彈個密碼框讓使用者輸 【我是凹凹】
  • 跳轉去設定密碼 【我是凹凹】

可能我們會問,為什麼下面2個是凹凹,因為他不也是觸發UI嘛?但是如果這樣問,誰讓你的按鈕點選了,誰讓你彈個密碼框了,誰讓你跳轉了,你就知道了,除了第一第二個是使用者點的,其他兩個其實都是别的代碼讓你觸發的,是以在這兩個檔案中,隻有可能是

ViewModel

Controller

去做了這兩件事

### 2. `ViewModel`要做些啥 - 一個查詢是不是有支付密碼的請求 - 處理是不是有支付密碼然後分發 - 告訴`Controller`彈出密碼框 - 告訴`Controller`去設定密碼 - 接受個密碼 - 一個送出支付密碼的請求 - 告訴`Controller`支付成功了

ViewModel

要做的事就多了,但是我們簡單解析後就這些了,還是和上面一樣,我們把凸凸凹凹分析一下,

ViewModel

如果了解了,那就真的明白這個概念了,其實增的很簡單。

  • 一個查詢是不是有支付密碼的請求 【凹凹】
  • 處理是不是有支付密碼然後分發 【凹凹】
  • 告訴

    Controller

    彈出密碼框 【凸凸】
  • 告訴

    Controller

    去設定密碼 【凸凸】
  • 接受個密碼 【凹凹】
  • 一個送出支付密碼的請求 【凹凹】
  • 告訴

    Controller

    支付成功了 【凸凸】

其實凸凸很好了解,都告訴

Controller

做事了,還不是凸凸也真是服。而請求發起者在這個場景下都是來自于

Controller

,而第二個處理是否有密碼的節點其實隻是個過度節點,而同樣的過度還會在請求中出現,當然這之後再說;

是以查詢支付密碼後,請求會有個能量傳遞給【處理是否有支付密碼的分發】,這就屬于内部凸凸凹凹了,用的順的話這就會很習慣。

在有了這些概念後來看張整合的圖:

這裡有幾個是内部向下箭頭,其實凸凸也是可以接受内部來的能量的,換句話說他對外可能是凸凸,對内,那就未必咯~

### 3. `Controller`的凹凹

首先你要知道凸凸和凹凹是個啥~,我們就非常生硬的引入了

RxSwift

的概念,反正隻是個類名而已,不會影響大家任何思路: 我們把單個節點定義成:

let signalOutput = PublishRelay<Void>()
複制代碼
           

其中

PublishRelay

就是凸凸和凹凹的類型,其實他們是同一個類的,隻是從功能上區分了凸和凹的概念,在代碼上沒什麼差别。 另外

PublishRelay

PublishSubject

的簡單封裝,作用一毛一樣,差別就是前者在

RxCocoa

中,而後者在

RxSwift

中,是以單純用這個,我們就可以來看下

Controller

會變成什麼樣:

class ViewController: UIViewController {
    let button = UIButton()
    // 按鈕點選
    let buttonTapOutput = PublishRelay<Void>()
    
    // 跳轉去設定支付密碼
    let gotoSetpswInput = PublishRelay<Void>()
    
    // 彈密碼輸入框
    let showSetpswInput = PublishRelay<Void>()
    
    // 輸入密碼完成
    let finishedSettingpswOutput = PublishRelay<String>()
    
    // 流程完成
    let allFinishedInput = PublishRelay<String>()
    
    override func viewDidLoad() {
        
        button.rx.tap
            .bind(to: buttonTapOutput)
            .disposed(by: rx.disposeBag)
        
        gotoSetpswInput.subscribe(onNext: { (_) in
            // 此處添加navigate跳轉邏輯
            print("跳轉啦啦啦")
            
        }).disposed(by: rx.disposeBag)
        
        showSetpswInput.subscribe(onNext: { (_) in
            // 此處添加彈出密碼設定
            print("設定密碼啦啦啦")
            
        }).disposed(by: rx.disposeBag)
        
        allFinishedInput.subscribe(onNext: { (_) in
            print("all done")
            
        }).disposed(by: rx.disposeBag)
    }
}
複制代碼
           

如果無法了解,你可以了解成

bind

就是把凸凸和凹凹結合的方法,當然也可以連接配接兩個凸凸,這樣相當于兩個快遞員之間的傳遞,而

subscribeNext

的用法在這就是給凹凹添加一系列處理事件,他在擷取到能量後需要做一堆事,比如我們可以在設定密碼處彈框,在跳轉處寫

navigationController.push()...

再詳細點描述,其實

bind

subscribe

的用法是一樣的,隻是

bind

給另一個節點,相當于多了個快遞傳送,但是最後的

closure

肯定需要的,隻是你什麼時候寫而已,而

subscribe

就是你懶的找快遞了直接開箱驗貨了而已

上面的代碼中其實隻處理了

Controller的input

,在那張圖上就是箭頭指向

Controller

的部分,因為這就是

Controller

要做的事,是以在

MVVM

崇尚

VC/VM

分離的情況下,我們可以毫無幹涉的先把

Controller

要做的部分完善掉,無需考慮任何别的業務邏輯,這也是隔離的一個好處

### 4. `ViewModel`的凹凹 從上面這點我們可以看出,當我們劃厘清楚了凹凹凸凸,我們的開發順序會變的順暢,處理完Controller的凹凹之後,我們接下來需要處理`ViewModel`的凹凹,因為凹凹都是目前類肚子中的邏輯,是以會很好做

class ViewModel {
    let disposeBag = DisposeBag()
    
    // 查詢是否有支付密碼,帶請求
    let checkPswInput = PublishRelay<Void>()
    
    // 處理查詢結果
    let checkeNeedPswDispatch = PublishRelay<Bool>()
    
    // 去設定密碼
    let gotoSetpswOutput = PublishRelay<Void>()
    
    // 彈出框輸入密碼
    let showSetpswOutput = PublishRelay<Void>()
    
    // 密碼輸入完成
    let setPswFinishInput = PublishRelay<String>()
    
    // all done
    let finishedAllOuput = PublishRelay<Void>()
    
    init() {
        
        // flatMap後的内容其實應該用一個請求代替,這裡簡略一下直接把傳回值标出來了
        // 一般Rx的請求傳回的結果就是Observable<T>的
        checkPswInput
            .flatMap { Observable.just(true) }
            .bind(to: checkeNeedPswDispatch)
            .disposed(by: disposeBag)
        
        checkeNeedPswDispatch.subscribe(onNext: { (hasPsw) in
            if hasPsw {
                self.showSetpswOutput.accept(())
            }
            else {
                self.gotoSetpswOutput.accept(())
            }
        }).disposed(by: disposeBag)
        
        setPswFinishInput
            .flatMap { _ in Observable.just(true) }
            .map { _ in () }.bind(to: finishedAllOuput)
            .disposed(by: disposeBag)
    }
}
複制代碼
           

因為重點不在請求,是以我們簡單忽略請求部分,把最終結果貼了上來,

flatMap

Map

簡單來說就是快遞拿到你的包裹給你加了個盒子或者拆了個盒子換了個信封之類的,雖然這很不道德,但是在Rx中這再正常不過,雖然這有點響應式的概念,如果想了解的話可以點一下看看,不過如果看不懂:

所謂響應式,就是你在網上買了袋貓糧,賣家把貨給一個快遞,途中他可能給你裝盒子,拆盒子,拿出來嘗一口,給你換一袋便宜的,交給另一個快遞小哥,搭個飛機乘個坦克,然後給你重新買袋正品,最後送到你的手上的還是一袋東西,那至于他是不是你想要的貓糧,就要看中間的過程了,但是這件事就是有始有終的一整個工作流,途中凹凹凸凸凸凸凹凹啥的一大堆,最終就是一袋東西在那傳,送到你手上。

好了不扯遠,在上面的代碼之後,其實我們已經可以為

Controller

寫好凸凸來綁定

ViewModel

的凹凹,反之亦然

5.

Controller

ViewModel

的凸凸凹凹綁定

class ViewController: UIViewController
{
    let button = UIButton()
    
    let viewModel = ViewModel()
    // 按鈕點選
    let buttonTapOutput = PublishRelay<Void>()
    
    // 跳轉去設定支付密碼
    let gotoSetpswInput = PublishRelay<Void>()
    
    // 彈密碼輸入框
    let showSetpswInput = PublishRelay<Void>()
    
    // 輸入密碼完成
    let finishedSettingpswOutput = PublishRelay<String>()
    
    // 流程完成
    let allFinishedInput = PublishRelay<Void>()
    
    override func viewDidLoad() {
        
        button.rx.tap
            .bind(to: buttonTapOutput)
            .disposed(by: rx.disposeBag)
        
        gotoSetpswInput.subscribe(onNext: { (_) in
            // 此處添加navigate跳轉邏輯
            print("跳轉啦啦啦")
            
        }).disposed(by: rx.disposeBag)
        
        showSetpswInput.subscribe(onNext: { (_) in
            // 此處添加彈出密碼設定
            print("設定密碼啦啦啦")
            
        }).disposed(by: rx.disposeBag)
        
        allFinishedInput.subscribe(onNext: { (_) in
            print("all done")
            
        }).disposed(by: rx.disposeBag)
        
        
        // Controller <=> ViewModel 綁定
        buttonTapOutput
            .bind(to: viewModel.checkPswInput)
            .disposed(by: rx.disposeBag)
        
        finishedSettingpswOutput
            .bind(to: viewModel.setPswFinishInput)
            .disposed(by: rx.disposeBag)
        
        viewModel.gotoSetpswOutput.bind(to: gotoSetpswInput).disposed(by: rx.disposeBag)
        viewModel.showSetpswOutput.bind(to: showSetpswInput).disposed(by: rx.disposeBag)
        viewModel.finishedAllOuput.bind(to: allFinishedInput).disposed(by: rx.disposeBag)
    }
}
複制代碼
           

我們在

ViewDidLoad

最後添加了點綁定方法,這樣,這個場景中所有線的凸凸凹凹就全部被結合到一起了

就這樣,一個看似複雜的業務流程被解析成了更複雜的MVVM的Rx形态,不過是不是在開發思路上順暢了不少呢?大概這就是為什麼我們會用這套東西的原因吧

## 場景二:添加删除清單某一項 為什麼拿這麼簡單的場景舉例,因為這能很好的展現這套思維的另一個特征,如果說場景一中是帶着你繞圈子,作用是強化一個個節點的概念;那這個場景就是強調【資料驅動】的概念了 > 簡單來說就是你一定希望,我的Array中少一項,那以他為資料源的tableView就會自動少一行。其實這個場景就是幫助我們達到這個目的的

是以我們先請我們的資料源來亮相一下:

var sectionedData: BehaviorRelay<[MGItem>]>!
複制代碼
           

有人會說你作弊,

BehaviorRelay

又是啥,其實他就是個有貨的凸凸凹凹,在前面的

PublishRelay

中那是個快遞的角色的話,記住

PublishRelay

隻會傳遞能量,他不會留存你的能量,即快遞拿到你的貨物後,給到下一個之後快遞是不會保留的你貓糧的,畢竟他不養貓留着也沒啥用。

但是在這個場景中,我們需要一個長期持有能量的節點來充當資料源,畢竟你的資料源如果隻是個快遞小哥,那他可能啥都沒~,

是以我們用一個很誇張的例子來了解

BehaviorRelay

,把他了解成你善良的母親大人就行了,有一天你把貓糧給你的母親大人,讓她幫你傳達給你的夥伴,于是這袋貓糧就永遠留在了你母親大人的心裡,雖然最後她會交給你的夥伴,但是如果這時候你說,我還要一袋,我還要兩袋,你母親大人總能幫你變出來,因為她已經記住了貓糧長什麼樣了,是以你就可以了解,你無限的索取,你母親大人會幫你變出一集裝箱的貓糧,而且他們是同一個樣子的。

專業點就是,你母親大人不停給你貓糧指針,指向同一個位址,位址就在她腦子裡,是以哪天你說我要狗糧的時候,你母親大人的腦子裡就會立馬變成狗糧,巴不得她把所有貓糧全變成狗糧,雖然現實中她做不到。

有點啰嗦,是以

BehaviorRelay

就是可以永久持有一個能量,并且你做任何改動,能量都會被永久變化的東西,用他來做資料源再好不過。

1. 還是先從資料源定義開始

func sectionableData() -> BehaviorRelay<[MGSection<MGItem>]> {
    let item1 = MGItem(str: "1")
    let item2 = MGItem(str: "2")
    let item3 = MGItem(str: "4")
    let item4 = MGItem(str: "5")

    let section1 = MGSection(header: "header1", items: [item1, item2])
    let section2 = MGSection(header: "header2", items: [item3, item4])

    return BehaviorRelay(value: [section1, section2])

}
複制代碼
           

我們用這個方法來建立一個資料源,

MGSection

可能有點陌生,其實就是我們現在需要的是帶

Section

的清單,而SectionHeader就是

header1

,

header2

由于

Rx

本身并沒有MVVM的資料驅動的概念,是以我們需要引入

RxDataSouces

這個神秘的庫來幫我們完善資料源,

SectionModelType

就是

RxDataSouces

中定義的協定了
/// MGSection model
public class MGSection<ItemElement>: SectionModelType {

    public typealias Item = ItemElement

    public var header: String = ""

    public var items: [Item] = []

    init() {

    }

    public required init(original: MGSection, items: [Item]) {
        self.header = original.header
        self.items = items
    }

    /// 初始化調用我就行了
    ///
    /// - Parameters:
    ///   - header: header string
    ///   - items: items
    public convenience init(header: String, items: [Item]) {
        let section = MGSection<Item>()
        section.header = header
        section.items = items

        self.init(original: section, items: items)
        self.header = header

    }
}
複制代碼
           

其實看不懂也無所謂,簡單了解就是我們建立一個普通的數組,然後用

RxDataSouces

所要求的格式進行封裝後得到了

BehaviorRelay<[MGSection<MGItem>]>

這麼一個資料源,僅此而已。

2.

Controller

的綁定

override func viewDidLoad() {
    super.viewDidLoad()

    viewModel.initial()
    
    viewModel
        .sectionedData.asObservable()
        .bind(to: tableView, by: { (_, _, _, item) -> UITableViewCell in
            let cell = self.tableView.dequeueReusableCell(withIdentifier: "cell")
            cell?.textLabel?.text = item.name
            return cell!
        }).disposed(by: disposeBag)

}
複制代碼
           

這樣就看得懂了,雖然這個

bind

方法有點讓人無法了解,畢竟這是個封裝,内容在這裡:

func bind<RowItem>(to tableView: UITableView, by configCell : @escaping
    (TableViewSectionedDataSource<MGSection<RowItem>>,
    UITableView,
    IndexPath,
    RowItem) -> UITableViewCell )
    -> Disposable
    where E == DataSourceWithRequest<RowItem> {
        
        let realDataSource = RxTableViewSectionedReloadDataSource<MGSection<RowItem>>(configureCell: configCell)
        
        realDataSource.titleForHeaderInSection = { ds, index in
            return ds.sectionModels[index].header
        }
        
        return self.bind(to: tableView.rx.items(dataSource: realDataSource))
}
複制代碼
           
emmm... 怎麼說呢,這已經是我能提供的最能看得懂的版本了,還是那句話,這裡不多講

Rx

,是以最淺顯的了解就是我們把

RxDataSources

封裝好的資料源

bind

給了

tableView

,畢竟

bind

這個動作之前已經見過了
大概樣子就是這樣,這也太簡單了,是以接下來要給

Add Item

按鈕搞點事情了

3. 改下資料源會怎麼樣

addBar.rx.tap.map{}.subscribe(onNext: { (_) in
       print("")
       self.viewModel.sectionedData.accept([])
 })
複制代碼
           

我們粗暴的用上面這個姿勢來修改,

.accept

其實就是修改了你母親大人腦中的貓糧,當然這裡是把貓糧吃完了~,而僅僅這一步操作後,其實

tableView

就已經重新整理了,即一個

cell

都木有了。

是以這就是我們要達到的目的,與之前MVC中把資料源清空後再

tableView.reloadData()

的操作相比,這樣更純粹,而之前其實我們即操作了資料源,又重新整理了

tableview

,為了做删除資料這個操作,我們改了2個部分的代碼,俗話說得好,改的多錯的越多~

當然有人會說其實這個修改資料源後

tableview

直接變的底層還是這麼回事,那我隻能說,畢竟那不是我們自己改的,錯的不可能是我們~

當然有個不怎麼優雅的點大家應該可以發現,說是說清空資料,其實我們做的并不是

removeAll

的操作,而是建立了個空數組,換句話說這時候的指令并不是告訴你母親大人請把貓糧吃完,我現在不想要了;而是我們直接給她一個空袋子說,你手上的貓糧吃完了,這種看似騙自己的手法确實有點尴尬,而由于

BehaviorRelay

中存儲的能量是不可編輯的,是以我們隻有通過覆寫原本的數組,來達到所謂的删除目的。

是以如果是一個添加動作,原本是

[1,2]

,我們隻要給個新數組

[1,2,3]

tableView

做的其實就是

insertSection

動作,至于為什麼,

RxDataSources

做了你想知道的一切,這裡就不多擴充了。

4. MVVM一點呢?

之前提到過MVVM會讓簡單事情複雜化,是以上面這個例子明顯并不MVVM,畢竟你怎麼可以直接在按鈕點選事件中去操作

ViewModel

中的業務資料呢?說好的節點和快遞呢?

這種來自靈魂深處的拷問讓我們無法作答,是以還是往下看吧。
addBar.rx.tap.map{}.bind(to: viewModel.addObjInput)
複制代碼
           

是以我們把

Controller

改成了這樣,

同時為了看着美觀,我們給

BehaviorRelay

加一個

add

方法來欺騙欺騙我們自己:

extension BehaviorRelay
{
    func add<T>(element: T) where Element == [T]
    {
        var newValue = self.value
        newValue.append(element)
        self.accept(newValue)
    }
}
複制代碼
           

上面提到過其實我們是用替換來充當

add

,是以這裡就露骨點這樣寫了,雖然外面調用的時候我們可以心安理得的用

add

了。

是以在

ViewModel

中,最終節點訂閱會變成:

addObjInput.subscribe(onNext: { (_) in
        let item5 = MGItem(str: "5")
        let item6 = MGItem(str: "6")
        
        let section1 = MGSection(header: "header3", items: [item5, item6])
        self.sectionedData.add(element: section1)
    })
複制代碼
           

g結果就是這樣,你會問既然是

add

那為什麼沒動畫,請相信我,這真的是add,隻是我調了個沒動畫的

RxDatasource

的封裝,僅此而已。

至此,你會發現我們改了資料源,tableView就出現了變化,這就是MVVM的另一個核心。

## 場景三:簡單的不能簡單的計數label ![](https://ws1.sinaimg.cn/large/006tNbRwgy1fu9go8v9pkg309d0gdaac.gif)

圖中的需求其實很簡單,你會說

button

點選時候改變全局變量就行了,再給

label

重新整理一下就完了,但是你發現其實你又操作了資料,又操作了

label

,仿佛又有些不太對, 因為這個場景隻是個鞏固,是以我們就直接貼全部代碼了:

Controller

:

class ViewController: UIViewController {

    //MARK : - UIs
    @IBOutlet weak var countLabel: UILabel!
    
    @IBOutlet weak var countButton: UIButton!


    let disposeBag = DisposeBag()

	 let vm = ViewModel()

    //MARK : - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.counter.map { "\($0)" }.bind(to: countLabel.rx.text)
        countButton.rx.tap.bind(to: viewModel.input)
    }
}
複制代碼
           

ViewModel

:

class ViewModel {
    let input: PublishRelay<Void> = PublishRelay()

    let counter: BehaviorRelay<Int> = BehaviorRelay(value: 1)
    
    init() {
        input.map { _ in self.counter.value + 1 }.bind(to: counter)
    }
}
複制代碼
           

同樣我們把所有事情移到了

ViewModel

,同樣我們省去了重新整理Label這麼一個操作,綁定配置之後,我們就可以安心去對我們的

ViewModel

做所有業務操作了,其實嚴格意義上來說

{ "\($0)" }

這個操作也應該在

ViewModel

中,奈何實在是不想多寫了,我就寫了個反面教材以示警戒吧~

至此,簡單的入門應該真的入了,如果還沒有,那就隻能留言作者或者強行怼作者了~

轉載于:https://juejin.im/post/5b72cbaae51d45663c0694b9