不要打破鏈式調用!一個極低成本的RxJava全局Error處理方案

RxJava與CallbackHell

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

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

CallbackHell,中文翻譯爲 回調地獄,在以往沒有依賴RxJava + Retrofit進行網絡請求的代碼中,這種代碼並很多見(好比AsyncTask),我曾有幸見識並維護了各類3層4層AsyncTask回調嵌套的項目——後來我一直拒絕閱讀AsyncTask的源碼,我想這應該是一個很重要的緣由。git

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

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

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

RxJava 給咱們的事件驅動型編程帶來了新的思路,RxJavaObservable 一會兒把咱們的維度拓展到了時間和空間兩個維度api

事件驅動型編程這個詞很準確,如今我從新組織個人語言,」不要打破鏈式調用!「,這句話更應該說,不要破壞RxJava事件驅動型的編程思想。網絡

你到底想說什麼?

如今讓咱們回到文章的標題上,Android開發中,網絡請求的錯誤處理一直是一個沒法迴避的需求,有了隨着RxJava + Retrofit的普及,不免會遇到這個問題:app

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

若是您以爲文章還差了那麼點東西,也請經過關注督促我寫出更好的文章——萬一哪天我進步了呢?

相關文章
相關標籤/搜索