不要打破鏈式呼叫!一個極低成本的RxJava全域性Error處理方案

卻把清梅嗅發表於2018-11-11

RxJava與CallbackHell

在正式鋪展開本文內容之前,我們先思考一個問題:

你認為 RxJava 真的好用嗎,它好用在哪?

CallbackHell,中文翻譯為 回撥地獄,在以往沒有依賴RxJava + Retrofit進行網路請求的程式碼中,這種程式碼並不少見(比如AsyncTask),我曾有幸見識並維護了各種3層4層AsyncTask回撥巢狀的專案——後來我一直拒絕閱讀AsyncTask的原始碼,我想這應該是一個很重要的原因。

很感謝 @prototypez《RxJava 沉思錄》 系列的文章,我個人認為它是 目前國內關於RxJava講解最好的系列 ,作者列舉了國內大多數文章中,關於RxJava好處的最常見的一些呼聲:

  • 用到了觀察者模式
  • 鏈式程式設計(一行程式碼實現XXX)
  • 清晰且簡潔的程式碼
  • 避免了Callback Hell

不可否認,這些的確都是RxJava優秀的閃光點,但我認為這不是核心,正如 這篇文章 所說的,其更重要的意義在於:

RxJava 給我們的事件驅動型程式設計帶來了新的思路,RxJavaObservable 一下子把我們的維度擴充到了時間和空間兩個維度

事件驅動型程式設計這個詞很準確,現在我重新組織我的語言,”不要打破鏈式呼叫!“,這句話更應該說,不要破壞RxJava事件驅動型的程式設計思想。

你到底想說什麼?

現在讓我們回到文章的標題上,Android開發中,網路請求的錯誤處理一直是一個無法迴避的需求,有了隨著RxJava + Retrofit的普及,難免會遇到這個問題:

Android開發中 RxJava+Retrofit 全域性網路異常捕獲、狀態碼統一處理

這是我17年年初總結的一篇部落格,那時我對於RxJava的理解比較有限,我閱讀了網上很多前輩的部落格,並總結了文中的這種方案,就是把全域性的error處理放在onError()中,並將Subscriber包裝成MySubscriber

public abstract class MySubscriber<T> extends Subscriber<T> {
&emsp;// ...
   @Override
    public void onError(Throwable e) {
         onError(ExceptionHandle.handleException(e));  // ExceptionHandle中就是全域性處理的邏輯,詳情參考上方文章
    }

    public abstract void onError(ExceptionHandle.ResponeThrowable responeThrowable);
}

api.requestHttp()  //網路請求
      .subscribeOn(Schedulers.io())
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe(new MySubscriber<Model>(context) {        // 包裝了全域性error處理邏輯的MySubscriber
          @Override
          public void onNext(Model model) { // ... }

          @Override
           public void onError(ExceptionHandle.ResponeThrowable throwable) {
                 // .......
            }
      });
複製程式碼

這種解決方案於我當時看來沒有問題,我認為這應該就是 完美的解決方案 了吧。

很快我就意識到了另外一個問題,就是這種方案成功地驅動我寫出了 RxJava版本的Callback Hell。

RxJava版本的Callback Hell

我不想你們笑話我的程式碼,因此我決定先不把它們丟擲來,來看一個常見的需求:

請求一個API,如果發生異常,彈出一個Dialog,詢問使用者是否重試,如果重試,重新請求這個API。

讓我們看看可能很多開發者 第一直覺 會寫出的程式碼(為了保證程式碼不那麼囉嗦,這裡我使用了Kotlin):

api.requestHttp()
    .subscribe(
          onNext = {
                // ...
          },
          onError = {
                  AlertDialog.Builder(context)    // 彈出一個dialog,提示使用者是否重試
                    .xxxx
                    .setPositiveButton("重試") { _, _ ->  //  點選重試按鈕,重新請求
                              api.requestHttp()                  
                                 .subscribe(
                                       onNext = {   ...   },
                                       onError = {    ...   }
                                  )
                    }
                    .setNegativeButton("取消") { _, _ -> // 啥都不做 }
                    .show()
          }
    )
複製程式碼

瞧!我們寫出了什麼!

現在你也許明白了我當時的處境,onError()onComplete()意味著這次訂閱事件的終止,如果全域性的異常處理都放在onError()中,接下來如果還有其他的需求(比如網路請求),就意味著你要在這個回撥方法中再新增一層回撥。

在一邊高呼RxJava 鏈式呼叫簡潔好用避免了CallbackHell 時,我們將 響應式程式設計 扔到了一旁,然後繼續 按照日常的思維 寫著 如出一轍的程式碼

如果你覺得這種操作完全可以接受,我們可以將需求升級一下:

如果發生異常,彈出dialog提示使用者重試,這種dialog最多可彈出3次。

好的,如果說,最多重試一次,讓程式碼額外增加了1層回撥的巢狀(實際上是2層,Dialog的點選事件本身也是一層回撥),那麼最多重試3次,就是.....4層回撥:

api.requestHttp()
    .subscribe(
          onNext = {
                // ...
          },
          onError = {
                      api.requestHttp()                  
                         .subscribe(
                               onNext = {
                                     // ...
                                },
                               onError = {                         
                                      api.requestHttp()                  
                                        .subscribe(
                                              onNext = {   ...   },
                                              onError = {    ...   }     // 還有一層
                                       )   
                               }
                        )
          }
    )
複製程式碼

你可以說,我把這個請求封裝成一個函式,然後每次只呼叫函式就行了,話雖如此,你依然不能否認這種 CallbackHell 並不優雅。

現在,如果有一種優雅的解決方案,那麼這種方案最好有哪些優點?

如有可能,我希望它能做到的是:

1.輕量級

輕量級意味著 較低的依賴成本,如果一個工具庫,它又要依賴若干個三方庫,首先apk體積的急速膨脹就令人無法接受。

2.靈活

靈活意味著 更低的遷移成本,我不希望,新增 或者 移除 這個工具令我的整個專案發生巨大的改動,甚至是重構。

如有可能,不要在已有的業務邏輯程式碼上進行修改

3.低學習成本

低的學習成本 可以讓開發者更快的上手這個工具。

4.可高度擴充套件

如有可能,請讓這個工具庫能夠為所欲為

這樣看來,上文中通過繼承的方式對全域性error的處理方案,存在著一定的侷限性,拋開令人瞠目結舌的回撥地獄之外,不能用lambda表示式 就已經讓我難以忍受。

RxWeaver: 一個輕量且靈活的全域性Error處理中介軟體

我花了一些時間開源了這個工具:

RxWeaver: A lightweight and flexible error handler tools for RxJava2.

Weaver 翻譯過來叫做 織布鳥,我最初的目的也正是讓這個工具能夠對邏輯程式碼正確地組織,達到實現RxJava全域性Error處理的需求。

怎麼用?可以做到什麼程度?

為了程式碼的足夠簡潔,我選擇使用Kotlin作為示範程式碼,我保證你可以看懂並理解它們——如果你的專案中適用的開發語言是Java,也請不用擔心, RxWeaver 同樣提供了Java版本的依賴和示例程式碼,你可以在這裡找到它。

RxWeaver的配置非常簡單,你只需要配置好對應的GlobalErrorTransformer類,然後在需要處理error的網路請求程式碼中,通過compose()操作符,將GlobalErrorTransformer交給RxJava, 請注意,僅僅需要一行程式碼

private fun requestHttp() {
        serviceManager.requestHttp()     // 網路請求
                .compose(RxUtils.handleGlobalError<UserInfo>(this))    // 加上這行程式碼
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe( // ....)
}
複製程式碼

RxUtils.handleGlobalError<UserInfo>(this)類似Java中的靜態工具方法,它會返回一個對應GlobalErrorTransformer的一個例項——裡面儲存的是對應的error處理邏輯,這個類並不是 RxWeaver 的一部分,而是根據不同專案的不同業務,自己實現的一個類:

object RxUtils {

    fun  handleGlobalError(activity: FragmentActivity): GlobalErrorTransformer {
          // ....
    }
}
複製程式碼

現在我們需要知道的是,這樣一行程式碼,可以做到什麼樣的程度

讓我們從3個不同梯度的需求看看這個工具的韌性:

1.當接受到某種Error時,Toast對應的資訊展示給使用者

這是最常見的一種需求,當出現某種特殊異常(本案例以JSONException為例),我們會通過Toast提示這樣的訊息給使用者:

全域性異常捕獲-Json解析異常!

fun test() {
        Observable.error(JSONException("JSONException"))
                .compose(RxUtils.handleGlobalError<UserInfo>(this))
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe {
                    // ...
                }
}
複製程式碼

毫無疑問,當沒有加compose(RxUtils.handleGlobalError<UserInfo>(this))這行程式碼時,這次訂閱的結果必然是彈出一個 “onError:xxxx”的 toast。

現在我們加上了compose的這行程式碼,讓我們拭目以待:

1.gif

看起來成功了,即使我們在onError()裡面針對Exception做出了單獨的處理,但是這個JSONException依然被全域性捕獲了,並彈出了一個額外的toast :“全域性異常捕獲-Json解析異常!” 。

這似乎是一個很簡單的需求,我們提升一點難度:

2.當接收到某種Error時,彈出Dialog

這次需求是:

若接收到一個ConnectException(連線異常),我們讓彈出一個dialog,這個dialog只會彈一次,若使用者選擇重試,重新請求API

又回到了上文中這個可能會引發 Callback Hell 的需求,我們疑問,如何保證 Dialog和重試邏輯正確執行的同時,不打破Observable流的連續性(鏈式呼叫)

fun test2() {
  Observable.error(ConnectException())        // 這次我們把異常換成了`ConnectException`
                .compose(RxUtils.handleGlobalError<UserInfo>(this))
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe {
                    // ...
                }
}
複製程式碼

依然是熟悉的程式碼,這次我們把異常換成了ConnectException,我們直接看結果:

因為我們資料來源是一個固定的ConnectException,因此我們無論怎麼重試,必然都只會接收到ConnectException,這不重要,你發現沒有,即使是一個複雜的需求(彈出dialog,使用者選擇後,決定是否重新請求這個流),RxWeaver 依然可以勝任。

2.gif

最後一個案例,讓我們再來一個更復雜的。

3.當接收到Token失效的Error時,跳轉login介面。

詳細需求是:

當接收到Token失效的Error時,跳轉login介面,使用者重新登入成功後,返回初始介面,並重新請求API;如果使用者登入失敗或取消登入,彈出錯誤資訊。

顯然這個邏輯有點複雜了, 對於實現這個需求來講,似乎不太現實,這次是否會束手無策呢?

fun test3() {
    Observable.error(TokenExpiredException())
                .compose(RxUtils.handleGlobalError<UserInfo>(this))
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                subscribe {
                    // ...
                }
}
複製程式碼

這次我們把異常換成了TokenExpiredException(因為直接例項化一個HttpException過於複雜,所以我們自定義一個異常模擬代替它),我們直接看結果:

3.gif

當然,無論怎麼重試,資料來源始終只會發射TokenExpiredException,但是我們成功實現了這個看似複雜的需求。

4. 我想說明什麼?

我認為RxWeaver達到了我心目中的設計要求:

  • 輕量級

你不需要擔心 RxWeaver 的體積,它足夠的輕量,輕量到所有類加起來只有不到200行程式碼,同時,除了RxJavaRxAndroid,它 沒有任何其它的依賴 ,體積大小隻有3kb。

  • 靈活

RxWeaver 的配置不需要 修改 或者 刪除 任意一行已經存在的業務程式碼——它是完全可插拔的。

  • 低學習成本

它的原理也是非常 簡單 的,只要熟悉了onErrorResumeNextretryWhendoOnError這幾個關鍵的操作符,你就可以馬上上手對應的配置。

  • 高擴充套件性

可以通過介面實現任意複雜的需求實現。

原理

這似乎本末倒置了,對於一個工具來說,熟練使用API 往往比 閱讀原始碼並瞭解原理 優先順序更高一些。但是我的想法是,如果你先了解了原理,這個工具的使用你會更加得心應手。

RxWeaver的原理複雜嗎?

實際上,RxWeaver的原始碼非常簡單,簡單到元件內部 沒有任何Error處理邏輯,所有的邏輯都交給使用者進行配置,它只是一個 中介軟體

它的原理也是非常 簡單 的,只要熟悉了onErrorResumeNextretryWhendoOnError這幾個關鍵的操作符,你就可以馬上上手對應的配置。

1.compose操作符

對於全域性異常的處理,我只需要在既有程式碼的 鏈式呼叫 加上一行程式碼,配置一個 GlobalErrorTransformer<T> 交給 compose() 操作符————這個操作符是 RxJava 給我們提供的可以面向 響應式資料型別 (Observable/Flowable/Single等等)進行 AOP 的介面, 可以對響應式資料型別 加工修飾 ,甚至 替換

這意味著,在既有的程式碼上,使用compose()操作符,我可以將一段特殊處理的邏輯程式碼插入到這個Observable中,這實在太方便了。

對compose操作符不瞭解的同學,請參考 【譯】避免打斷鏈式結構:使用.compose()操作符 @by小鄧子

compose() 操作符需要我傳入一個對應 響應式型別 (Observable/Flowable/Single等等)的Transformer介面,但是問題是不同的 響應式型別 對應不同的 Transformer 介面,不同的於是我們實現了一個通用的 GlobalErrorTransformer<T> 介面以 相容不同響應式型別的事件流

class GlobalErrorTransformer<T> constructor(
        private val globalOnNextRetryInterceptor: (T) -> Observable<T> = { Observable.just(it) },
        private val globalOnErrorResume: (Throwable) -> Observable<T> = { Observable.error(it) },
        private val retryConfigProvider: (Throwable) -> RetryConfig = { RetryConfig() },
        private val globalDoOnErrorConsumer: (Throwable) -> Unit = { },
        private val upStreamSchedulerProvider: () -> Scheduler = { AndroidSchedulers.mainThread() },
        private val downStreamSchedulerProvider: () -> Scheduler = { AndroidSchedulers.mainThread() }
) : ObservableTransformer<T, T>, FlowableTransformer<T, T>, SingleTransformer<T, T>,  MaybeTransformer<T, T>, CompletableTransformer {
      // ...
}
複製程式碼

現在我們思考一下,如果我們想把error處理的邏輯放在GlobalErrorTransformer裡面,把這個GlobalErrorTransformer交給compose() 操作符,就等於把error處理的邏輯全部 插入 到既有的Observable事件流中了:

fun test() {
    observable
          .compose(RxUtils.handleGlobalError<UserInfo>(this))   // 插入異常處理邏輯
          .subscribeOn(Schedulers.io())
          .observeOn(AndroidSchedulers.mainThread())
          subscribe {
              // ...
          }
}
複製程式碼

同理,如果某個API不需要追加全域性異常處理的邏輯,只需要把這行程式碼刪掉即可,不會影響其他的業務程式碼。

這是一個不錯的思路,接下來,我們需要思考的是,如何將不同的異常處理邏輯加進GlobalErrorTransformer中?

2.簡單的全域性異常處理:doOnError操作符

這個操作符的作用實在非常明顯了,就是當我們接收到某個 Throwable 時,想要做的邏輯:

image

這實在很適合大部分簡單的錯誤處理需求,就像上文的需求1一樣,當我們接收到某種指定的異常,彈出對應的message提示使用者,邏輯程式碼如下:

when (error) {
    is JSONException -> {
        Toast.makeText(activity, "全域性異常捕獲-Json解析異常!", Toast.LENGTH_SHORT).show()
    }
    else -> {

    }
}
複製程式碼

這種錯誤的處理方式, 不會對既有的Observable進行變換 ,也就是說,JSONException 依然會最終傳遞到subscribe的 onError() 的回撥中——你依然需要實現 onError() 的回撥,哪怕什麼都不做,如有必要,再進行特殊的處理,否則會發生崩潰。

這種方式很簡單,但是涉及複雜的需求就無能為力了,這時候我們就需要藉助onErrorResumeNext操作符了。

3.複雜的非同步Error處理:onErrorResumeNext操作符

以上文的需求2為例,若接收到一個指定的異常,我們需展示一個Dialog,提示使用者是否重試—— 這種情況下,doOnError操作符明顯無能為力,因為它不具有 對Observable進行變換的能力

這時就需要 onErrorResumeNext 操作符上場了,它的作用是:當流的事件傳遞過程中發生了錯誤,我們可以將一個新的流交個 onErrorResumeNext 操作符,以保證事件流的繼續傳遞。

image

這是一個被嚴重低估的操作符,這個操作符意味著,只要你給一個Observable<T>的,就能繼續往下傳遞事件,那麼,這和需求中的 展示一個Dialog供使用者選擇 有關係嗎?

當然有關係,我們只需要把Dialog的事件轉換成對應的Observable即可:

object RxDialog {

    /**
     * 簡單的示例,彈出一個dialog提示使用者,將使用者的操作轉換為一個流並返回
     */
    fun showErrorDialog(context: Context,
                        message: String): Single<Boolean> {

        return Single.create<Boolean> { emitter ->
            AlertDialog.Builder(context)
                    .setTitle("錯誤")
                    .setMessage("您收到了一個異常:$message,是否重試本次請求?")
                    .setCancelable(false)
                    .setPositiveButton("重試") { _, _ -> emitter.onSuccess(true) }
                    .setNegativeButton("取消") { _, _ -> emitter.onSuccess(false) }
                    .show()
        }
    }
}
複製程式碼

RxDialog的 showErrorDialog() 函式將會展示一個Dialog,返回值為一個 Single<Boolean> 的流,當使用者點選 確定 按鈕,訂閱者會接收到一個 true 事件,反之,點選 取消 按鈕,則會收到一個 false 事件。

RxJava還能這麼用?

當然,RxJava所代表的是一種響應式的程式設計正規化,在剛接觸RxJava的時候,我們都見過這樣一種說法:RxJava 非常強大的一點便是 非同步

現在我們回過頭來,網路請求的資料流 代表的是一種非同步,難道 彈出一個dialog,等待的使用者選擇結果 難道不也是一種非同步嗎?

換句話說,網路請求 的流中事件意味著 網路請求的結果,那麼上文中的 Single<Boolean> 代表著流中的事件是 ** Dialog的點選事件**。

其實RxJava發展的這些年來,Github上的RxJava擴充套件庫層出不窮,比如RxPermission,RxBinding等等等等,前者是將 許可權請求 的結果作為事件,交給了Observable進行傳遞;後者則是將 **View對應的事件 ** (比如點選事件,長按事件等等)交給了Observable

回過頭來,我們現在通過RxDialog建立了一個 響應式的Dialog,並獲取到了使用者的選擇結果Single<Boolean>,接下來我們需要做的就只是根據Single<Boolean>中事件的值來判斷 是否重新請求網路資料 了。

4.重試的處理:retryWhen操作符

RxJava提供了 retryWhen() 操作符,交給我們去處理是否重新執行流的訂閱(本文中就是指重新進行網路請求):

image

篇幅所限,我不會針對這個操作符進行太多的講解,關於 retryWhen() 操作符,請參考:

【譯】對RxJava中.repeatWhen()和.retryWhen()操作符的思考 by 小鄧子

繼續上文的思路,我們到了Dialog對應的Single<Boolean>流,當使用者選擇後,例項化一個RetryConfig 物件,並把選擇的結果Single<Boolean>交給了 condition 屬性:

RetryConfig(condition = RxDialog.showErrorDialog(params))

data class RetryConfig(
        val maxRetries: Int = DEFAULT_RETRY_TIMES,  // 最大重試次數,預設1
        val delay: Int = DEFAULT_DELAY_DURATION,    // 重試延遲,預設1000ms
        val condition: () -> Single<Boolean> = { Single.just(false) }  // 是否重試
)
複製程式碼

現在讓我們來重新整理一下思路:

1.當使用者接收到一個指定的異常時,彈出一個Dialog,其選擇結果為Single<Boolean>
2.RetryConfig 內部儲存了一個Single<Boolean> 的屬性,這是一個決定了是否重試的函式;
3.當使用者選擇了確認按鈕,將Single(true)交給並例項化一個RetryConfig ,這意味著會重試,如果選擇了取消,則為Single(false),意味著不會重試。

5.似乎...完成了?

看來,僅僅需要這幾個操作符,Error處理複雜的需求我們已經能夠實現了?

的確如此,實際上,GlobalErrorTransformer內部的處理,也正是呼叫這幾個操作符:

class GlobalErrorTransformer<T> constructor(
        private val globalOnNextRetryInterceptor: (T) -> Observable<T> = { Observable.just(it) },
        private val globalOnErrorResume: (Throwable) -> Observable<T> = { Observable.error(it) },
        private val retryConfigProvider: (Throwable) -> RetryConfig = { RetryConfig() },
        private val globalDoOnErrorConsumer: (Throwable) -> Unit = { },
        private val upStreamSchedulerProvider: () -> Scheduler = { AndroidSchedulers.mainThread() },
        private val downStreamSchedulerProvider: () -> Scheduler = { AndroidSchedulers.mainThread() }
) : ObservableTransformer<T, T>,
        FlowableTransformer<T, T>,
        SingleTransformer<T, T>,
        MaybeTransformer<T, T>,
        CompletableTransformer {

    override fun apply(upstream: Observable<T>): Observable<T> =
            upstream
                    .flatMap {
                        globalOnNextRetryInterceptor(it)
                    }
                    .onErrorResumeNext { throwable: Throwable ->
                        globalOnErrorResume(throwable)
                    }
                    .observeOn(upStreamSchedulerProvider())
                    .retryWhen(ObservableRetryDelay(retryConfigProvider))
                    .doOnError(globalDoOnErrorConsumer)
                    .observeOn(downStreamSchedulerProvider())

    // 其他響應式型別同理...                  
}                    
複製程式碼

這也正是 RxWeaver 這個工具為什麼如此 輕量 的原因,即使是 核心類 GlobalErrorTransformer 也並沒有更復雜的邏輯,僅僅是對幾個操作符的組合使用而已。

此外的幾個類,也無非是對重試邏輯介面的封裝罷了。

6.如何實現介面的跳轉?

看到這裡,有的小夥伴可能已經有這樣一個疑問了:

需求2中的,Dialog的邏輯我能夠理解,那麼,需求3中,Token失效,跳轉login並返回重試是如何實現的?

實際上,無論是 網路請求 , 還是 彈出Dialog , 亦或者 跳轉Login,其終究只是一個 事件的流 而已,前者能通過介面返回一個 Observble<T> 或者 Single<T>, 跳轉Login 當然也可以:

class NavigatorFragment : Fragment() {

    fun startLoginForResult(activity: FragmentActivity): Single<Boolean> {
        // ....
    }
}
複製程式碼

篇幅所限,本文不進行實現程式碼的展示,原始碼請參考這裡

其原理和 RxPermissionsRxLifecycle 還有筆者的 RxImagePicker 完全一樣,依靠一個不可見的Fragment 對資料進行傳遞。

小結:RxJava,複雜還是簡單

在本文的開始,我簡單介紹了 RxWeaver 的幾個優點,其中一個是 極低的學習成本

本文釋出之前,我把我的工具介紹給了一些剛接觸 RxJava 的開發者,他們接觸之後,反饋竟然出奇的統一:

你這個東西太難了!

對於這個結果,我很詫異,因為這畢竟只是一個加起來還不到200行的工具庫,後來我仔細的思考,我終於得出了一個結論,那就是:

本文的內容理解起來很 簡單 ,但首先需要你對RxJava有一定的理解,這比較 困難

RxJava的學習曲線非常陡峭!正如 @prototypez 在他的 這篇文章 中所說的一樣:

RxJava 是一個 “夾帶了私貨” 的框架,它本身最重要的貢獻是提升了我們思考事件驅動型程式設計的維度,但是它與此同時又逼迫我們去接受了函數語言程式設計。

正如本文一開始所說的,我們已經習慣了 程式式程式設計 的思維,因此文中的一些 抽象的操作符 會讓我們陷入一定的迷茫,但是這也正是 RxJava 的魔力所在——它讓我不斷想要將新的需求 從更高層級進行抽象,嘗試寫出更簡潔的程式碼(至少在我看來)。

我非常喜歡 RxWeaver , 有朋友說說它程式碼有點少,但我卻認為 輕量 是它最大的優點,它的本質目的也正是幫助開發者 對業務邏輯進行組織,使其能夠寫出更 Reactive 和 Functional 的程式碼

--------------------------廣告分割線------------------------------

關於我

Hello,我是卻把清梅嗅,如果您覺得文章對您有價值,歡迎 ❤️,也歡迎關注我的部落格或者Github

如果您覺得文章還差了那麼點東西,也請通過關注督促我寫出更好的文章——萬一哪天我進步了呢?

相關文章