原文:REACTIVE APPS WITH MODEL-VIEW-INTENT - PART3 - STATE REDUCER
作者:Hannes Dorfmann
譯者:卻把清梅嗅
在上一章節(jié)中,我們針對 如何使用單向流和 Model-View-Intent 模式構(gòu)建一個(gè)簡單的頁面 進(jìn)行了探討辩块;本章節(jié)澡屡,我們將在reducer
的幫助下實(shí)現(xiàn)MVI
模式中更加復(fù)雜的頁面。
如果你還未閱讀前兩個(gè)章節(jié),閱讀本文之前您應(yīng)該先去閱讀它們,從而對如下兩個(gè)問題的答案有初步的了解:
- 1.我們?nèi)绾瓮ㄟ^
Presenter
將View
層和業(yè)務(wù)邏輯相關(guān)聯(lián)? - 2.數(shù)據(jù)流是如何保證單向性的区拳?
如下圖所示,現(xiàn)在我們構(gòu)建這樣一個(gè)復(fù)雜的頁面:
如你所見意乓,屏幕中顯示的是按照類別進(jìn)行歸類的商品列表樱调;App
每次只會為每個(gè)分類展示3個(gè)條目,當(dāng)用戶點(diǎn)擊了 加載更多 按鈕時(shí)届良,將會通過網(wǎng)絡(luò)請求去加載該分類下所有的條目啊奄。
此外罪裹,用戶還可以執(zhí)行 下拉刷新 的操作,并且一旦用戶向下滾動(dòng)到列表末尾,分頁功能就會繼續(xù)加載下一頁的數(shù)據(jù)——當(dāng)然械蹋,所有這些行為可以同時(shí)執(zhí)行扯再,并且每個(gè)行為都可能會收到失敵旨省(即沒有互聯(lián)網(wǎng)連接)开镣。
讓我們一步步來,首先荚藻,我們先對View
層的接口進(jìn)行實(shí)現(xiàn):
public interface HomeView {
/**
* 加載第一頁數(shù)據(jù)的intent
*
* @return 發(fā)射的數(shù)據(jù)是沒有意義的屋灌,true或者false沒有區(qū)別
*/
public Observable<Boolean> loadFirstPageIntent();
/**
* 分頁加載下一頁的intent
*
* @return 發(fā)射的數(shù)據(jù)是沒有意義的,true或者false沒有區(qū)別
*/
public Observable<Boolean> loadNextPageIntent();
/**
* 對下拉刷新的響應(yīng)intent
*
* @return 發(fā)射的數(shù)據(jù)是沒有意義的应狱,true或者false沒有區(qū)別
*/
public Observable<Boolean> pullToRefreshIntent();
/**
* 根據(jù)當(dāng)前分類加載所有條目的intent
*
* @return 指定分類共郭,String代表分類的名字
*/
public Observable<String> loadAllProductsFromCategoryIntent();
/**
* 對ViewState進(jìn)行渲染
*/
public void render(HomeViewState viewState);
}
View
層具體的實(shí)現(xiàn)簡單明了,本文將不進(jìn)行展示(但你可以在Github上找到它)侦香。
接下來讓我們把目光轉(zhuǎn)向Model
落塑,正如前文所提到的,Model
應(yīng)該反應(yīng)了狀態(tài)罐韩,現(xiàn)在我來介紹一下Model
的具體實(shí)現(xiàn):HomeViewState憾赁。
public final class HomeViewState {
private final boolean loadingFirstPage; // RecyclerView加載狀態(tài)的指示器
private final Throwable firstPageError; // 如果非空,展示一個(gè)error
private final List<FeedItem> data; // 列表的數(shù)據(jù)
private final boolean loadingNextPage; // RecyclerView分頁加載狀態(tài)的指示器
private final Throwable nextPageError; // 如果非空散吵,展示分頁error的toast
private final boolean loadingPullToRefresh; // 展示下拉刷新狀態(tài)的指示器
private final Throwable pullToRefreshError; // 非空意味著下拉刷新的error
// ... 構(gòu)造器 ...
// ... getter方法 ...
}
請注意龙考,FeedItem 僅僅是一個(gè)接口蟆肆,每個(gè)條目都需要實(shí)現(xiàn)該接口,然后交給RecyclerView
去展示晦款。比如 Product 實(shí)現(xiàn)了 FeedItem炎功;此外,列表中的類別標(biāo)題 SectionHeader 也實(shí)現(xiàn)了 FeedItem缓溅;還有蛇损,作為UI中的元素之一,表示 “可以加載該類別更多” 的指示器同樣也是 FeedItem坛怪,其內(nèi)部還持有了一個(gè)小狀態(tài)——該狀態(tài)代表了當(dāng)前是否 正在加載更多條目 淤齐。
public class AdditionalItemsLoadable implements FeedItem {
private final int moreItemsAvailableCount;
private final String categoryName;
private final boolean loading; // true 代表item正處于加載狀態(tài)
private final Throwable loadingError; // 標(biāo)志loading時(shí)捕獲到了error
// ... 構(gòu)造器 ...
// ... getter方法 ...
這之后便是壓軸的業(yè)務(wù)邏輯組件 HomeFeedLoader ,它負(fù)責(zé)對 FeedItems 進(jìn)行加載:
public class HomeFeedLoader {
// 通常由 下拉刷新 動(dòng)作觸發(fā)
public Observable<List<FeedItem>> loadNewestPage() { ... }
// 加載第一頁
public Observable<List<FeedItem>> loadFirstPage() { ... }
// 加載下一頁
public Observable<List<FeedItem>> loadNextPage() { ... }
// 加載某個(gè)分類的其它產(chǎn)品
public Observable<List<Product>> loadProductsOfCategory(String categoryName) { ... }
}
現(xiàn)在袜匿,讓我們一步步將這些點(diǎn)在Presenter
中進(jìn)行連接更啄。請注意,接下來Presenter
中展示的部分代碼居灯,在真實(shí)的開發(fā)中祭务,應(yīng)該被轉(zhuǎn)移到Interactor
(交互器)中(這并非是為了更好的可讀性)。首先怪嫌,我們先開始對初始化數(shù)據(jù)進(jìn)行加載:
class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {
private final HomeFeedLoader feedLoader;
@Override protected void bindIntents() {
// 在真實(shí)的開發(fā)中义锥,應(yīng)該被轉(zhuǎn)移到Interactor中
Observable<HomeViewState> loadFirstPage = intent(HomeView::loadFirstPageIntent)
.flatMap(ignored -> feedLoader.loadFirstPage()
.map(items -> new HomeViewState(items, false, null) )
.startWith(new HomeViewState(emptyList, true, null) )
.onErrorReturn(error -> new HomeViewState(emptyList, false, error))
subscribeViewState(loadFirstPage, HomeView::render);
}
}
到目前為止感覺良好,和上一章節(jié)我們實(shí)現(xiàn)的Search
界面相比喇勋,沒有什么太大的不同缨该。
現(xiàn)在我們嘗試添加對 下拉刷新 的支持:偎行、
class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {
private final HomeFeedLoader feedLoader;
@Override protected void bindIntents() {
// 在真實(shí)的開發(fā)中川背,應(yīng)該被轉(zhuǎn)移到Interactor中
Observable<HomeViewState> loadFirstPage = ... ;
Observable<HomeViewState> pullToRefresh = intent(HomeView::pullToRefreshIntent)
.flatMap(ignored -> feedLoader.loadNewestPage()
.map( items -> new HomeViewState(...))
.startWith(new HomeViewState(...))
.onErrorReturn(error -> new HomeViewState(...)));
Observable<HomeViewState> allIntents = Observable.merge(loadFirstPage, pullToRefresh);
subscribeViewState(allIntents, HomeView::render);
}
}
稍微等一下:feedLoader.loadNewestPage()
僅僅返回了新的條目數(shù)據(jù),但是之前我們已經(jīng)加載了的條目怎么辦蛤袒?
“傳統(tǒng)”的MVP
模式中熄云,我們可以調(diào)用類似view.addNewItems(newItems)
的方法,但是在 第一篇文章 中妙真,我們已經(jīng)探討了為什么這不是一個(gè)好主意(狀態(tài)問題)缴允。
我們當(dāng)前面臨的問題是,下拉刷新依賴了之前的狀態(tài)珍德,因?yàn)槲覀兿胍獙⑾吕⑿路祷氐臈l目和之前已經(jīng)加載的條目進(jìn)行 合并练般。
女士們,先生們锈候,現(xiàn)在薄料,讓我們熱情地歡迎狀態(tài)折疊器(State Reducer)的到來!
[圖片上傳失敗...(image-672708-1552483601199)]
State Reducer
是函數(shù)式編程中的一個(gè)概念泵琳,它 將前一個(gè)狀態(tài)作為輸入摄职,并根據(jù)前一個(gè)狀態(tài)計(jì)算得出一個(gè)新的狀態(tài)誊役,就像這樣:
public State reduce( State previous, Foo foo ){
State newState;
// ... 根據(jù)前一個(gè)狀態(tài)計(jì)算得出一個(gè)新的狀態(tài) ...
return newState;
}
因此上述問題的解決方案是,我們定義一個(gè)Foo
組件谷市,通過其類似reduce()
的函數(shù)蛔垢,結(jié)合之前的狀態(tài)計(jì)算出一個(gè)新的狀態(tài)。
這個(gè)名為Foo
的組件通常意味著我們希望對之前狀態(tài)所進(jìn)行的改變迫悠,在我們的案例中鹏漆,我們希望將 最初通過loadFirstPageIntent
計(jì)算得到的HomeViewState
和 下拉刷新得到的結(jié)果 進(jìn)行reduce
。
你猜怎么著创泄,RxJava
有一個(gè)名為 scan()
的操作符甫男,讓我們對我們的代碼進(jìn)行略微的重構(gòu),我們需要引入另外一個(gè)表示 部分改變 的類—— 上面我們將其稱之為Foo
验烧,它將用于計(jì)算新的狀態(tài)板驳。
class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {
private final HomeFeedLoader feedLoader;
@Override protected void bindIntents() {
Observable<PartialState> loadFirstPage = intent(HomeView::loadFirstPageIntent)
.flatMap(ignored -> feedLoader.loadFirstPage()
.map(items -> new PartialState.FirstPageData(items) )
.startWith(new PartialState.FirstPageLoading(true) )
.onErrorReturn(error -> new PartialState.FirstPageError(error))
Observable<PartialState> pullToRefresh = intent(HomeView::pullToRefreshIntent)
.flatMap(ignored -> feedLoader.loadNewestPage()
.map( items -> new PartialState.PullToRefreshData(items)
.startWith(new PartialState.PullToRefreshLoading(true)))
.onErrorReturn(error -> new PartialState.PullToRefreshError(error)));
Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh);
// 展示第一頁數(shù)據(jù)加載中...
HomeViewState initialState = ... ;
Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)
subscribeViewState(stateObservable, HomeView::render);
}
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
...
}
}
我們在這里做了什么?相比較直接返回Observable<HomeViewState>
碍拆,現(xiàn)在每個(gè)Intent
返回的是Observable<PartialState>
若治。這之后我們通過merge()
操作符將其全部合并為一個(gè)可觀察的流中,并最終應(yīng)用到了reducer
的函數(shù)中(即Observable.scan()
)感混。
這意味著端幼,無論何時(shí)用戶發(fā)起了一個(gè)intent
,這個(gè)intent
將會生產(chǎn)一個(gè)PartialState
的實(shí)例,然后被reduced
得到了HomeViewState
,最終弧满,被View
層進(jìn)行展示(HomeView.render(HomeViewState)
)婆跑。
唯一遺漏的部分應(yīng)該就是state reducer
的函數(shù)本身了,如上文中的定義一樣庭呜,HomeViewState
類本身并未發(fā)生了改變滑进,但是我們通過Builder
模式添加了一個(gè)Builder
,這樣我們就可以非常便捷地創(chuàng)建一個(gè)新的HomeViewState
實(shí)例募谎。
現(xiàn)在讓我們開始實(shí)現(xiàn)state reducer
的函數(shù):
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
if (changes instanceof PartialState.FirstPageLoading)
return previousState.toBuilder() // 根據(jù)當(dāng)前狀態(tài)復(fù)制一個(gè)內(nèi)部同樣狀態(tài)的對象
.firstPageLoading(true) // 展示progressBar
.firstPageError(null) // 不展示error
.build()
if (changes instanceof PartialState.FirstPageError)
return previousState.builder()
.firstPageLoading(false) // 隱藏progressBar
.firstPageError(((PartialState.FirstPageError) changes).getError()) // 展示error
.build();
if (changes instanceof PartialState.FirstPageLoaded)
return previousState.builder()
.firstPageLoading(false)
.firstPageError(null)
.data(((PartialState.FirstPageLoaded) changes).getData())
.build();
if (changes instanceof PartialState.PullToRefreshLoading)
return previousState.builder()
.pullToRefreshLoading(true) // 展示下拉刷新的UI指示器
.nextPageError(null)
.build();
if (changes instanceof PartialState.PullToRefreshError)
return previousState.builder()
.pullToRefreshLoading(false) // 隱藏下拉刷新的UI指示器
.pullToRefreshError(((PartialState.PullToRefreshError) changes).getError())
.build();
if (changes instanceof PartialState.PullToRefreshData) {
List<FeedItem> data = new ArrayList<>();
data.addAll(((PullToRefreshData) changes).getData()); // 將新的數(shù)據(jù)插入到當(dāng)前列表的頂部
data.addAll(previousState.getData());
return previousState.builder()
.pullToRefreshLoading(false)
.pullToRefreshError(null)
.data(data)
.build();
}
throw new IllegalStateException("Don't know how to reduce the partial state " + changes);
}
我知道扶关,這些代碼看起來并不優(yōu)雅,但這不是本文的重點(diǎn)——為什么博主會在他的文章中展示如此 “丑陋” 的代碼数冬?
因?yàn)槲蚁M軌蜿U述一個(gè)觀點(diǎn)节槐,我認(rèn)為 讀者并不應(yīng)該為源碼中錯(cuò)綜復(fù)雜的邏輯買單 ,比如拐纱,我們的購物車App
中铜异,也不需要讀者對某些設(shè)計(jì)模式有額外的知識儲備。
因此秸架,我認(rèn)為博客文章中最好避免出現(xiàn)設(shè)計(jì)模式揍庄,這的確會展示出更好的代碼,但其本身就意味著 更高的閱讀理解成本咕宿。
回顧本文币绩,其重點(diǎn)是對State Reducer
進(jìn)行配置蜡秽,通過上述的代碼,大家都能夠更快更準(zhǔn)確地去了解它是什么缆镣。但你會在實(shí)際開發(fā)中這樣編寫代碼嗎芽突?當(dāng)然不會,我會去使用設(shè)計(jì)模式或者其它的解決方案董瞻,比如使用 public HomeViewState computeNewState(previousState)
之類的方法將PartialState
定義為接口寞蚌。
好吧,我想你已經(jīng)了解了State Reducer
是如何工作的钠糊,讓我們實(shí)現(xiàn)剩下來的功能:分頁以及能夠加載某個(gè)指定分類更多的Item:
class HomePresenter extends MviBasePresenter<HomeView, HomeViewState> {
private final HomeFeedLoader feedLoader;
@Override protected void bindIntents() {
Observable<PartialState> loadFirstPage = ... ;
Observable<PartialState> pullToRefresh = ... ;
Observable<PartialState> nextPage =
intent(HomeView::loadNextPageIntent)
.flatMap(ignored -> feedLoader.loadNextPage()
.map(items -> new PartialState.NextPageLoaded(items))
.startWith(new PartialState.NextPageLoading())
.onErrorReturn(PartialState.NexPageLoadingError::new));
Observable<PartialState> loadMoreFromCategory =
intent(HomeView::loadAllProductsFromCategoryIntent)
.flatMap(categoryName -> feedLoader.loadProductsOfCategory(categoryName)
.map( products -> new PartialState.ProductsOfCategoryLoaded(categoryName, products))
.startWith(new PartialState.ProductsOfCategoryLoading(categoryName))
.onErrorReturn(error -> new PartialState.ProductsOfCategoryError(categoryName, error)));
Observable<PartialState> allIntents = Observable.merge(loadFirstPage, pullToRefresh, nextPage, loadMoreFromCategory);
// 展示第一頁正在加載
HomeViewState initialState = ... ;
Observable<HomeViewState> stateObservable = allIntents.scan(initialState, this::viewStateReducer)
subscribeViewState(stateObservable, HomeView::render);
}
private HomeViewState viewStateReducer(HomeViewState previousState, PartialState changes){
// ... 第一頁的部分狀態(tài)處理和下拉刷新 ...
if (changes instanceof PartialState.NextPageLoading) {
return previousState.builder().nextPageLoading(true).nextPageError(null).build();
}
if (changes instanceof PartialState.NexPageLoadingError)
return previousState.builder()
.nextPageLoading(false)
.nextPageError(((PartialState.NexPageLoadingError) changes).getError())
.build();
if (changes instanceof PartialState.NextPageLoaded) {
List<FeedItem> data = new ArrayList<>();
data.addAll(previousState.getData());
// 將新的數(shù)據(jù)添加到list的尾部
data.addAll(((PartialState.NextPageLoaded) changes).getData());
return previousState.builder().nextPageLoading(false).nextPageError(null).data(data).build();
}
if (changes instanceof PartialState.ProductsOfCategoryLoading) {
int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData());
AdditionalItemsLoadable ail = (AdditionalItemsLoadable) previousState.getData().get(indexLoadMoreItem);
AdditionalItemsLoadable itemsThatIndicatesError = ail.builder() // 創(chuàng)建所有item的副本
.loading(true).error(null).build();
List<FeedItem> data = new ArrayList<>();
data.addAll(previousState.getData());
data.set(indexLoadMoreItem, itemsThatIndicatesError); // 這將會展示一個(gè)loading的指示器
return previousState.builder().data(data).build();
}
if (changes instanceof PartialState.ProductsOfCategoryLoadingError) {
int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData());
AdditionalItemsLoadable ail = (AdditionalItemsLoadable) previousState.getData().get(indexLoadMoreItem);
AdditionalItemsLoadable itemsThatIndicatesError = ail.builder().loading(false).error( ((ProductsOfCategoryLoadingError)changes).getError()).build();
List<FeedItem> data = new ArrayList<>();
data.addAll(previousState.getData());
data.set(indexLoadMoreItem, itemsThatIndicatesError); // 這將會展示一個(gè)error和重試的button
return previousState.builder().data(data).build();
}
if (changes instanceof PartialState.ProductsOfCategoryLoaded) {
String categoryName = (ProductsOfCategoryLoaded) changes.getCategoryName();
int indexLoadMoreItem = findAdditionalItems(categoryName, previousState.getData());
int indexOfSectionHeader = findSectionHeader(categoryName, previousState.getData());
List<FeedItem> data = new ArrayList<>();
data.addAll(previousState.getData());
removeItems(data, indexOfSectionHeader, indexLoadMoreItem); // 移除指定分類下的所有item
// 添加指定分類下的所有item (包括之前已經(jīng)被移除的)
data.addAll(indexOfSectionHeader + 1,((ProductsOfCategoryLoaded) changes).getData());
return previousState.builder().data(data).build();
}
throw new IllegalStateException("Don't know how to reduce the partial state " + changes);
}
}
實(shí)現(xiàn)分頁加載和下拉刷新十分相似挟秤,異同之處僅僅在于前者是把加載到的數(shù)據(jù)添加在列表末尾,而下拉刷新則是把數(shù)據(jù)展示在界面頂部抄伍。
更有趣的是我們?nèi)绾吾槍δ硞€(gè)類別去加載更多條目:為了展示某個(gè)類別的加載指示器和錯(cuò)誤/重試的按鈕艘刚,我們只需在所有的FeedItems
列表中找到對應(yīng)的AdditionalItemsLoadable
對象,然后我們將其改變?yōu)檎故炯虞d指示器或者錯(cuò)誤/重試的按鈕截珍。
如果我們已成功加載某個(gè)類別的所有條目攀甚,我們將搜索SectionHeader
和AdditionalItemsLoadable
,并用新加載的列表替換這里的所有條目岗喉,僅此而已秋度。
結(jié)語
本文的目的是向您展示 狀態(tài)折疊器(State Reducer) 如何幫助我們通過 簡潔且易讀 的代碼構(gòu)建復(fù)雜的頁面。現(xiàn)在回過頭來思考钱床,“傳統(tǒng)”的MVP
或者MVVM
針對這些功能荚斯,在不使用State Reducer
的前提下是如何實(shí)現(xiàn)這些功能的。
顯然查牌,能夠使用State Reducer
的關(guān)鍵是我們有一個(gè)反映狀態(tài)的Model
類事期,這也印證了該系列的第一篇文章中所闡述的,為什么理解 Model 是那么的重要僧免。
此外刑赶,只有當(dāng)我們確定狀態(tài)(或準(zhǔn)確的Model
)來自單一的數(shù)據(jù)源時(shí),才能使用State Reducer
懂衩,因此單向數(shù)據(jù)流同樣非常重要。
我希望我們花費(fèi)在 閱讀 并 理解 前兩篇博客的時(shí)間是有意義的金踪,現(xiàn)在浊洞,所有的點(diǎn)都成功的連在了一起,是時(shí)候歡呼了胡岔。
如果還沒有法希,不用擔(dān)心,對此我也花了相當(dāng)長的時(shí)間才完全理解——還有很多次練習(xí)靶瘸、錯(cuò)誤和重試苫亦。
在第二篇博客中毛肋,針對搜索界面,我們并未使用State Reducer
屋剑。這是因?yàn)槿绻覀円阅撤N方式依賴于先前的狀態(tài)润匙,State Reducer
是有意義的。而在“搜索界面”中唉匾,我們不依賴于先前的狀態(tài)孕讳。
雖然在最后,但是我還是想重申巍膘,也許你還沒有注意到厂财,那就是我們的data
都是不可變的——我們總是創(chuàng)建HomeViewState
新的實(shí)例,而不是在已有的對象上調(diào)用其setter
方法峡懈,這也使得多線程不再是問題璃饱。
用戶可以在加載下一頁的同時(shí)開始下拉刷新并加載某個(gè)類別的更多條目,因?yàn)?code>State Reducer總是能夠產(chǎn)生正確的狀態(tài)肪康,卻不依賴于http響應(yīng)的任何特定順序帜平。另外,我們用純函數(shù)編寫了代碼梅鹦,沒有任何副作用裆甩。這使我們的代碼非常具有可測試性、可重現(xiàn)性齐唆、易于推演和高度可并行化(即多線程)嗤栓。
當(dāng)然,State Reducer
并非是MVI
發(fā)明的箍邮,您可以在多種編程語言的許多三方庫茉帅,框架和系統(tǒng)中找到其概念。它完全符合Model-View-Intent
的理念锭弊,具有單向的數(shù)據(jù)流和表示狀態(tài)的Model
堪澎。
在下一個(gè)部分中,我們將聚焦于如何通過MVI
構(gòu)建 可復(fù)用 和 響應(yīng)式 的UI組件味滞,敬請關(guān)注樱蛤。
系列目錄
《使用MVI打造響應(yīng)式APP》原文
《使用MVI打造響應(yīng)式APP》譯文
- [譯]使用MVI打造響應(yīng)式APP(一):Model到底是什么
- [譯]使用MVI打造響應(yīng)式APP[二]:View層和Intent層
- [譯]使用MVI打造響應(yīng)式APP[三]:狀態(tài)折疊器
- [譯]使用MVI打造響應(yīng)式APP[四]:獨(dú)立性UI組件
- [譯]使用MVI打造響應(yīng)式APP[五]:輕而易舉地Debug
- [譯]使用MVI打造響應(yīng)式APP[六]:恢復(fù)狀態(tài)
- [譯]使用MVI打造響應(yīng)式APP[七]:掌握時(shí)機(jī)(SingleLiveEvent問題)
- [譯]使用MVI打造響應(yīng)式APP[八]:導(dǎo)航
《使用MVI打造響應(yīng)式APP》實(shí)戰(zhàn)
關(guān)于我
Hello,我是卻把清梅嗅剑鞍,如果您覺得文章對您有價(jià)值昨凡,歡迎 ??,也歡迎關(guān)注我的博客或者Github蚁署。
如果您覺得文章還差了那么點(diǎn)東西便脊,也請通過關(guān)注督促我寫出更好的文章——萬一哪天我進(jìn)步了呢?