使用MVVM尝试开发Github客户端及对编程的一些思考

争取打造 Android Jetpack 讲解的最好的博客系列android

Android Jetpack 实战篇git

本文中我将尝试分享我我的 搭建我的MVVM项目 的过程当中的一些心得和踩坑经历,以及在这过程当中目前对 编程本质 的一些我的理解和感悟,特此分享以期讨论及学习进步。github

原因

最近在尝试搭建本身理解的 MVVM模式 的应用程序,在这近一个月中,我思考了不少,也参考了若干Github上MVVM项目源码,并从中获益匪浅。数据库

我根据所得搭建了一个MVVM开发模式的Github客户端,并托管在了本身的github上:编程

MVVM-Rhine: MVVM+Jetpack的Github客户端安全

建立这个项目的缘由是我想有一个本身写的 Github客户端 方便我查看,目前我基本实现了本身的目标,App总体的效果是这样的:网络

在开发过程当中,我根据本身对于编程的理解,在技术选型中,加了一些本身喜欢的库,写了一些本身比较满意的风格的代码,特此和你们一块儿分享个人所得,谬误之处,欢迎拍砖。架构

1.我为何选择Kotlin?

回顾近半年来,我博客中的编程语言使用的清一色是 Kotlin,这样作的最初目的是督促本身学习Kotlin。app

我曾在 某篇文章 中这样声明我用Kotlin的缘由:框架

不只如此,Kotlin语言国外已经有至关的热度了,只是目前相比Java,国内尚未彻底推广起来而已。

此外,Kotlin的一些特性可以让咱们实现Java实现不了的东西(不是空安全,无需findViewById这些基本的语法糖),对于某些设计点,Kotlin是Java没法替代的,这点我会在后文中提到。

2.MVVM的本质:异步观察者模式

不少朋友对RxJava的理解是 链式调用线程切换 等等,对我来讲,在RxJava的逐渐使用过程当中,我对它的理解慢慢趋于 异步 一词——RxJava 强迫开发者从思想上将异步代码同步代码归于一统,对于任何业务功能,均可以抽象为一个可观察的对象。

MVVM的本质亦是如此,DataBinding 帮咱们为 数据驱动视图 提供了可实现的方案,所以它成为了大多数MVVM项目中的核心库。

MVVM观察者模式的本质也意味着,即便没有DataBinding,咱们经过RxJava或者其余方式也可以实现 MVVM,只不过DataBinding更方便搭建MVVM而已。

这里不拿MVC、MVP和MVVM进行比较,由于不一样的架构思想,都有不一样的优劣势,我很是沉迷于RxJava和其优秀的思想,我认为它的思想至关一部分和MVVM不谋而合,所以我更倾向使用MVVM,配合以RxJava,可以让代码更加赏心悦目。

3.Android Jetpack: Architecture Components

Android Jetpack(下称Jetpack) 是Google今年IO大会上正式推出官方的新一代 组件、工具和架构指导 ,旨在加快开发者的 Android 应用开发速度:

这是一套很是迷人的架构组件,Google今年还同步(其实晚了2个月)开源了一个Jetpack的示例项目 Sunflower

这个示例项目有着丰富的学习价值,也很方便开发者迅速上手并熟悉Jetpack的组件——固然,只是上手固然知足不了个人需求,我想经过本身参与一个项目的实践来深刻了解并感觉这些组件,因而 我在这个项目中使用了这些组件

我简单经过我的感觉分别阐述一下这些组件真正融入MVVM项目中的感觉:

3.1 DataBinding

MVVM的 核心组件,经过良好的设计,个人项目中避免了95%以上的 冗余代码—— 它的做用简单直接,就是 数据驱动视图,我不再须要去经过控件设置UI,相反,全部UI的变更都交给了 被观察的成员属性 去驱动。

View的点击事件:

<ImageView android:id="@+id/btnEdit" android:layout_width="40dp" android:layout_height="40dp" android:src="@drawable/ic_edit_pencil" app:bind_onClick="@{ () -> delegate.edit() }" />
复制代码

ImageView的url加载:

<ImageView android:id="@+id/ivAvatar" android:layout_width="80dp" android:layout_height="80dp" app:bind_imageUrl_circle="@{ delegate.viewModel.user.avatarUrl }" />
复制代码

TextView的设置值:

<TextView android:id="@+id/tvNickname" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@{ delegate.viewModel.user.name }" />
复制代码

有同窗以为这太简单,那咱们换一些有说服力的。

你还在 Activity 代码配置 RecyclerView?直接xml里一次性配置RecyclerView,包括 滑动动画下拉刷新点击按钮列表滑动到顶部

<android.support.v4.widget.SwipeRefreshLayout android:layout_width="match_parent" android:layout_height="match_parent" app:onRefreshListener="@{ () -> delegate.viewModel.queryUserRepos() }" // 刷新监听 app:refreshing="@{ safeUnbox(delegate.viewModel.loading) }">    // 刷新状态

    <android.support.v7.widget.RecyclerView android:id="@+id/recyclerView" app:bind_adapter="@{ delegate.viewModel.adapter }" // 绑定Adapter app:bind_scrollStateChanges="@{ delegate.fabViewModel.stateChangesConsumer }" app:bind_scrollStateChanges_debounce="@{ 500 }" app:layoutManager="android.support.v7.widget.LinearLayoutManager" tools:listitem="@layout/item_repos_repo" />

</android.support.v4.widget.SwipeRefreshLayout>

<android.support.design.widget.FloatingActionButton android:id="@+id/fabTop" android:src="@drawable/ic_keyboard_arrow_up_white_24dp" app:bind_onClick="@{ () -> recyclerView.scrollToPosition(0) }" // 点击事件,列表直接回到顶部 app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" />
复制代码

还在配置 ViewPager+Fragment+BottomNavigationView的切换效果,包括ViewPager滑动切换监听,自动配置Adapter,BottomNavigation的点击监听, 咱们都在Xml声明好,交给DataBinding就好了:

<android.support.v4.view.ViewPager android:id="@+id/viewPager" app:onViewPagerPageChanged="@{ (index) -> delegate.onPageSelectChanged(index) }" app:viewPagerAdapter="@{ delegate.viewPagerAdapter }" app:viewPagerDefaultItem="@{ 0 }" app:viewPagerPageLimit="@{ 2 }" />

<android.support.design.widget.BottomNavigationView android:id="@+id/navigation" app:bind_onNavigationBottomSelectedChanged="@{ (menuItem) -> delegate.onBottomNavigationSelectChanged(menuItem) }" app:itemBackground="@color/colorPrimary" app:itemIconTint="@drawable/selector_main_bottom_nav_button" app:itemTextColor="@drawable/selector_main_bottom_nav_button" app:menu="@menu/menu_main_bottom_nav" />
复制代码

篇幅所限,省略了一些常见的属性,上述的全部源码,你均可以在个人项目中找到。

个人意思不是想说 DataBinding 多么强大(它确实能够实现足够多的功能),对我而言,它最强大的好处是—— 节省了足够多UI控件的设置代码,让我可以 抽出更多时间去写纯粹业务逻辑的代码。

有朋友以为DataBinding最大的问题就是很差Debug,个人解决方案是统一 状态管理,这个后文再提。

3.2 Lifecycle

Lifecycle 让我可以更专一于 业务逻辑 而非 生命周期,我认为这是不可代替的,若是你熟悉 Lifecycle,你能够看个人这篇文章:

Android官方架构组件Lifecycle:生命周期组件详解&原理分析

Lifecycle可以让我想要的组件也拥有 生命周期(其实是对生命周期容器的观察),好比,我再也不须要让Activity或者Fragment在onCreated()中去请求网络,取而代之的是:

class LoginViewModel(private val repo: LoginDataSourceRepository) : BaseViewModel() {

  override fun onCreate(lifecycleOwner: LifecycleOwner) {
          super.onCreate(lifecycleOwner)

          // 自动登陆
          autoLogin.toFlowable()
                .filter { it }
                .doOnNext { login() }
                .bindLifecycle(this)
                .subscribe()
    }
}
复制代码

上文的示例代码展现了,Login界面的自动登陆逻辑(固然也能够是网络请求展现数据的逻辑),ViewModel检测到了Activity的生命周期并自动调用了onCreate()函数——我并无经过Activity去调用它。

3.3 ViewModel

ViewModel可以检测到持有者的 生命周期,并避免了 横竖屏切换时额外的代码的配置,它的内部是经过一个不可见的 Fragment 对数据进行持有,并在真正该销毁数据的时候去销毁它们。

同时,它是MVVM中的 核心组件,我在项目的规范定义中,layout中全部的属性配置都应该依赖于ViewModel中的MutableLiveData属性:

class LoginViewModel(
        private val repo: LoginDataSourceRepository
) : BaseViewModel() {
 
    val username: MutableLiveData<String> = MutableLiveData()  // 用户名输入框
    val password: MutableLiveData<String> = MutableLiveData()  // 密码输入框

    val loading: MutableLiveData<Boolean> = MutableLiveData()   // ProgressBar
    val error: MutableLiveData<Option<Throwable>> = MutableLiveData()  // Errors

    val userInfo: MutableLiveData<LoginUser> = MutableLiveData()   // 用户信息

    private val autoLogin: MutableLiveData<Boolean> = MutableLiveData() // 是否自动登陆

    // ......
}
复制代码

3.4 LiveData

参照 RxJava 丰富的生态圈, LiveData 看起来彷佛实在鸡肋,可是DataBinding在最近的版本中提供了对 LiveData 的支持,考虑再三,我采用了 LiveData,正如上文示例代码,配合以 ViewModel, UI完整的驱动系统被搭建起来。

LiveData并不是一无可取,它确实值得我做为依赖添加进本身的项目中,缘由有二:

  • 原生支持 DataBinding 和 Room

实际上 Paging 也是支持的,可是我没有用到Paging

  • 安全的数据更新

RxJava在子线程进行UI的更新依赖于 observerOn(AndroidSchedudler.mainThread()),可是LiveData不须要,你只须要经过 postValue(),就能安全的进行数据更新,就像这样:

val loading: MutableLiveData<Boolean> = MutableLiveData()

this.loading.postValue(value)    // 数据的设置会在主线程上
复制代码

可是我仍然须要面临一个问题,就是LiveData的生态圈实在没办法和 RxJava 相关的库对比,想要经过LiveData的操做符进行业务处理实在不靠谱,所以我选择将LiveDataobserve()变成RxJavaFlowable

private val autoLogin: MutableLiveData<Boolean> = MutableLiveData()

 autoLogin.toFlowable()   // 变成了一个Flowable
                .filter { it }
                .doOnNext { login() }
                .bindLifecycle(this)
                .subscribe()
复制代码

得益于 kotlin 强大的 扩展函数,二者之间的融合如 丝滑般的流畅

fun <T> LiveData<T>.toFlowable(): Flowable<T> = Flowable.create({ emitter ->
    val observer = Observer<T> { data ->
        data?.let { emitter.onNext(it) }
    }
    observeForever(observer)

    emitter.setCancellable {
        object : MainThreadDisposable() {
            override fun onDispose() = removeObserver(observer)
        }
    }
}, BackpressureStrategy.LATEST)
复制代码

如今,咱们一边享受着 LiveData 安全的数据更新和DataBinding的原生支持,一边享受 RxJava 无以伦比 强大的操做符和函数式编程思想,这简直让我如沐春风。

3.5 Room

ORM数据库,市面上太多了不解释,我选择使用它的缘由有二:

  • 1.Google爸爸官方出品,无脑用
  • 2.原生支持RxJavaLiveData, 无脑用

真香。

3.6 Navigation

Google官方 单Activity多Fragment 的架构组件,若是你不是很熟悉,能够参考这篇文章:

Android官方架构组件Navigation:大巧不工的Fragment管理框架

很感谢文章吹来以后,不少同窗对文章的确定,我也相信不少同窗已经熟悉甚至尝试上手了这个库,我此次尝试在项目中使用它,缘由是,我想试试 它是否是真的像我文章吹的那么好用

经实战,初步结果是:

能够用,但不必。

在大多数状况下,Navigation都显得很是稳健,可是 框架是死的,可是需求是变幻无穷的,我老是不可避免去面对一些问题:

  • 1.官方提供了NavigationToolbarBottomNavigationView的原生支持,可是令我啼笑皆非的是,Navigation内部对Fragment的切换采用的是replace(),这意味着,每次点击底部导航控件,我都会销毁当前的Fragment,而且实例化一个新的Fragment

  • 2.不少APP采用了Home界面,双击返回才会退出Application的需求,正常咱们能够重写Activity的onBackPress()方法,而使用了Navigation,咱们不得不把导航的返回行为委托给了Navigation

class MainActivity : BaseActivity<ActivityMainBinding>() {

    override val layoutId = R.layout.activity_main

    override fun onSupportNavigateUp(): Boolean =
            findNavController(R.id.navHostFragment).navigateUp()

     // ...
}
复制代码

固然,这些问题都是有解决方案的,以BottomNavigationView每次切换都会销毁当前Fragment并实例化新的Fragment为例,个人建议是:

对根布局的View使用Navigation,界面内部的布局采用常规实现方式(好比ViewPager+Fragment)。

好比我在MainActivity中声明NavHostFragment:

<android.support.constraint.ConstraintLayout android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent">

        <fragment android:id="@+id/navHostFragment" android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="0dp" android:layout_height="0dp" app:defaultNavHost="true" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" app:navGraph="@navigation/navigation_main" />

    </android.support.constraint.ConstraintLayout>
复制代码

个人BottomNavigationView导航界面,则是一个MainFragment:

<android.support.constraint.ConstraintLayout android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent">

    <android.support.v4.view.ViewPager android:id="@+id/viewPager" android:layout_width="0dp" android:layout_height="0dp"" />

    <android.support.design.widget.BottomNavigationView android:id="@+id/navigation" android:layout_width="0dp" android:layout_height="wrap_content" app:menu="@menu/menu_main_bottom_nav" />

</android.support.constraint.ConstraintLayout>
复制代码

我保证 只有根布局的页面经过Navigation进行导航,至于NavigationBottomNavigationView的原生支持,我选择无视......

总而言之,对因而否使用Navigation,个人建议是持保守态度,由于这个东西和其它三方库不一样,Navigation的配置是 项目级 的。

4. 天马行空:RxJava

关于项目中RxJava相关库的配置,我选择了这些:

我是RxJava的重度依赖使用者,它让我沉迷于 业务逻辑的抽象,尝试将全部代码归 异步 于一统,所以我依赖了这些库。

5. 依赖注入:Kodein

编程的乐趣在于 探索,对于Android开发者来讲,Dagger2 可能会是更多开发者的首选,但对于一个 探索性质更多 的项目来讲,Dagger2 并非最优选,最终我选择了Kodein:

Kodein官网:Painless Kotlin Dependency Injection

若是您完整的阅读了 **《Kotlin 实战》**这本书,你能在书末的附录中找到选择它的缘由:

常见的Java依赖注入框架,好比 Spring/Guide/Dagger,都能很好地和Kotlin一块儿工做,若是你对原生的Kotin方案感兴趣,试试 Kodein, 它 提供了一套漂亮的DSL来配置依赖,并且它的实现也很是高效。

总结一下我我的的感觉:

  • 更Kotlin,整个框架都由Kotlin实现
  • 实现方式依赖于 Kotlin 的 属性委托
  • 很简洁,相比复杂的Dagger,上手更简单
  • 超级漂亮的DSL && 说出去更唬人......

Http网络请求 相关为例,来看看依赖注入的代码:

很漂亮,对吧?

固然,对于依赖注入库,Dagger2是一个不会错的选择,可是若是仅仅只是我的项目,或者您已经厌倦了Dagger的配置,Kodein是一个不错的建议。

若是你对 Kodein 感兴趣,能够参考这篇文章,参考本文的项目代码,相信很快就能上手:

告别Dagger2,Android的Kotlin项目中使用Kodein进行依赖注入

6.函数式支持库:Arrow

对于Kotlin的各类优势,函数是第一等公民 是一个没法忽视的闪光点,它与其余简单的语法糖不一样,它可以让你的代码更加优雅。

Arrow是提供了一些简单函数式编程的特性,利用Arrow提供的各类各样的函子,你的代码能够更加简洁而且优雅。

好比,配合RxJava,你能够实现这样的代码以免各类分支的处理,好比随时都有可能的if..else(),并将这些额外的操做放在最终的操做符中(Terminal Operator)去处理:

interface ILoginLocalDataSource : ILocalDataSource {

    fun fetchPrefsUser(): Flowable<Either<Errors, LoginEntity>>
}

class LoginLocalDataSource(
        private val database: UserDatabase,
        private val prefs: PrefsHelper
) : ILoginLocalDataSource {

    override fun fetchPrefsUser(): Flowable<Either<Errors, LoginEntity>> =
            Flowable.just(prefs)
                    .map {
                        when (it.username.isNotEmpty() && it.password.isNotEmpty()) {
                            true -> Either.right(LoginEntity(1, it.username, it.password))
                            false -> Either.left(Errors.EmptyResultsError)
                        }
                    }
}
复制代码

如今咱们将特殊的分支(数据错误)也一样像正常的流程同样交给了 Either<Errors, LoginEntity>统一返回,只有咱们在真正须要使用它们时,它们才会被解析:

fun login() {
        when (username.value.isNullOrEmpty() || password.value.isNullOrEmpty()) {
            true -> applyState(isLoading = false, error = Errors.EmptyInputError.some())
            false -> repo
                    .login(username.value!!, password.value!!)   // 返回的是 Flowable<Either<Errors, LoginUser>>
                    .compose(globalHandleError()) 
                    .map { either ->      // 用到的时候再处理它
                        either.fold({
                            SimpleViewState.error<LoginUser>(it)
                        }, {
                            SimpleViewState.result(it)
                        })
                    }
                    .startWith(SimpleViewState.loading())
                    .startWith(SimpleViewState.idle())
                    .onErrorReturn { it -> SimpleViewState.error(it) }
                    .bindLifecycle(this)
                    .subscribe { state ->
                        // ...
                    }
        }
    }
复制代码

在函数式编程的领域,我只是一个满怀敬意且不断学习探索的新人,可是它的好处在于,即便没有彻底理解 函数式编程 的思想,我也能够经过运用一些简单的函子写出更加Functional的代码。

7. 其余库

除上述库以外,我还引用了目前比较优秀的三方库:

基于OkHttp的 网络请求库Retrofit,不赘述。

Glide 和 Timber,已经被大众所熟知的 图片加载库 和 小巧精致的 日志打印库,不赘述。

DslAdapter 是低调的Yumenokanata开发的RecyclerViewAdapter,API的DSL设计加上对 DataBinding 的支持,我认为我还远远没达到写这个库的水平,所以在阅读完源码以后,我选择使用它。

8. 面向工具编程:模版插件

不管是MVP仍是MVVM,对于一种开发模式而言,代码规范是很重要的,这意味着界面的实现老是须要用 同一种开发模式 进行规范化。

以MVP为例,标准的MVP,实现一个Activity的容器页面,咱们须要定义Contract和其对应的ViewPresenter,Model层的接口及其实现类,这就引起了另一个问题,相似这种死板的开发模式的流程是否太繁琐(即简单的界面是否就没写这么多接口类的必要)?

我不这样认为,模版代码意味着开发的规范,这在团队开发中尤为重要,这样可以保证项目品质的稳定性和一致性,而且便于扩展,对于繁琐的生成重复性模版代码的状况,我认为MVP的表明性框架 MVPArms作出了很是值得学习的方案,即配置模版插件

所以我也花了一点时间配置了一套属于本身MVVM开发模式的模版插件,对于每一个界面的初始化,能够很方便一键生成:

就这样几步,Activity/Fragment,ViewModel,ViewDelegate以及依赖注入的KodeinModule类,都经过模版插件自动生成,我只须要关注UI的绘制和业务逻辑的编写便可。

不管是哪一种开发模式,我认为模版插件都是一个能大大提升开发效率的工具,并且它的学习成本并不高,以我我的经验,即便没有相关经验,也只须要3~4小时,就能开发出一套属于本身的模版插件。

9.没有使用的一些尝试

9.1 组件化/模块化开发

从我我的经验来看,对于简单的项目并不须要进行复杂的模块化配置,由于开发者和维护者也只有我一我的。

9.2 Paging和WorkManager

这两个也是 Android Jetpack 的架构组件,但我并无使用它们。

Paging是一个优秀的库,我曾举出它的优势(参考个人这篇文章),可是正若有朋友提到的,它的缺点很明显,那就是Paging自己是对RecyclerView.Adapter的继承,这意味着使用了Paging,就必须抛弃其余的Adapter库,或者本身造轮子,最终我选择了搁置。

WorkManager的缘由就很简单了,项目中的功能暂时用不到它....

9.3 事件总线

说到事件总线,国内比较容易被说起的有 EventBusRxBus,此外以前还看到某位大佬曾经分享过 LiveDataBus,印象很深入,可是文章找不到了。

没有采用事件总线的缘由是,我已经有RxJava了。

有同窗说既然你有RxJava,为何不使用RxBus呢,由于对于依赖来讲并无额外的负担?

对此我推荐这篇文章放弃RxBus,拥抱RxJava:为何避免使用EventBus/RxBus

引用文章中做者@W_BinaryTree对Jake Wharton对RxBus的评价翻译:

W_BinaryTree的相关文章写的都颇有深度,我读完很受启发,冒昧推荐一下这位做者。

我认为RxJava自己就是对发布-订阅者模式最优秀的体现,我尽可能保证个人工程中到处都由RxJava去串联就够了。

于我我的而言,我彻底赞同没有引入RxJava的项目中使用EventBus,可是我确实不推荐RxBus,由于这意味着业务模块之间层级设计得不清晰,才会致使所有交由RxJava中全局的Subject的订阅状况的产生。

9.4 协程

协程的总体替换也在我下一步的学习计划中。

这须要一段时间的发展,由于我认为目前协程尚未发展足够的生态环境——我更期待更多相似 retrofit2-kotlin-coroutines-adapter这样优秀的拓展库,可以让我下决定把全部RxJava的代码给替换掉。

目前项目中,Room,网络请求以及Databinding依赖的LiveData,都是经过RxJava进行编织串在一块儿的,这些代码糅合很深,所以Kotlin1.3发布后(协程从实验性的功能正式Release),我只先尝试性的使用了相似 Result 这样的API在异常处理上代替ArrowEither, 而协程则处于观察状态。

此外,我尚未开始深刻学习协程,重新手角度来看,可能还须要一段时间学习深刻并理解它,所以我期待更多关于协程的分析和相关分享的文章。

10.关于状态管理

状态的管理一直是争论不休的话题,甚至基于状态管理还引伸了 MVI (Model-View-Intent)的开发模式,关于MVI中文相关的博客我推荐这篇文章:

从状态管理(State Manage)到MVI(Model-View-Intent)

这是一篇分析很是透彻的文章,阅读之如饮甘怡,其中最重要的优点即是对状态额统一管理,读后收获甚丰,并作出了一些实验性的尝试,篇幅所限,再也不赘述,详情请参考 项目中ViewModel 的源码。

11.感觉

MVVM模式和设计理念相关博客已经烂大街了,并且我也不认为我可以讲的比别人更透彻。

我写本文的缘由是分享本身对于编程本质的理解,于我对编程的认知,探索过程当中所带来的乐趣成就感才是最重要的,追究本质多是探索创造

我不喜欢拘泥于固定的开发模式,日复一日的重复操做让我想起了工厂的流水线,编程不一样,每一个人的代码风格的迥异背后表明着思想的碰撞,这是不少工做不能给予个人。

回顾本文,我但愿本文的每一小节都能给您带来有益的东西,它多是一种积极状态的传递,也可能某小节涉及的知识点让您感兴趣,或是其余——项目自己意义和这种收获 相比反而不大,由于每一个人的思想不一样,对于MVVM的理解也不一样。

所以,我不敢妄言这个项目表明了MVVM的规范,但至少目前我对它的设计很满意(对您来讲可能嘈点满满),它表明了我是这一阶段持续学习的结果,,很期待不久以后的我可以用怀疑的眼光去看待这个项目,那将意味着下一阶段的进步。

项目地址:github.com/qingmei2/MV…

--------------------------广告分割线------------------------------

关于我

Hello,我是却把清梅嗅,若是您以为文章对您有价值,欢迎 ❤️,也欢迎关注个人博客或者Github

若是您以为文章还差了那么点东西,也请经过关注督促我写出更好的文章——万一哪天我进步了呢?

相关文章
相关标签/搜索