本文首发于 Sbingo666的博客

MVVM 架构图

谈到 MVVM 架构,不得不祭出官方的架构图,架构图能帮助我们更好地理解,如下所示:

在实践中,根据对架构组件 paging 的使用和理解,我将架构图扩展成下面这样:有背景颜色的3处是 paging 组件需要多用到的。

MVVM 和 MVP 的区别

MVPV 层和 P 层互相持有对方的引用,在V 层调用 P 层逻辑后,P 层回调V 层的相应方法更新 UI

而在 MVVM 中,上层只依赖直接下层,不能跨层持有引用,那 View 层调用 ViewModel 处理数据后,又如何更新自己呢?

答案就在 ViewModel 中的 LiveData,这是一种可观察的数据类型,在 View 层中观察者 Observer 对需要的数据进行订阅,当数据发生变化后,观察者 Observer 的回调方法 onChanged() 中会收到新的数据,从而可以更新 UI

LiveData 的相关代码如下:

//package androidx.lifecycle.LiveData;……
……
……@MainThread
protected void setValue(T value) {assertMainThread("setValue");mVersion++;mData = value;dispatchingValue(null);
}@SuppressWarnings("WeakerAccess") /* synthetic access */
void dispatchingValue(@Nullable ObserverWrapper initiator) {if (mDispatchingValue) {mDispatchInvalidated = true;return;}mDispatchingValue = true;do {mDispatchInvalidated = false;if (initiator != null) {considerNotify(initiator);initiator = null;} else {for (Iterator<Map.Entry<Observer<? super T>, ObserverWrapper>> iterator =mObservers.iteratorWithAdditions(); iterator.hasNext(); ) {considerNotify(iterator.next().getValue());if (mDispatchInvalidated) {break;}}}} while (mDispatchInvalidated);mDispatchingValue = false;
}private void considerNotify(ObserverWrapper observer) {if (!observer.mActive) {return;}if (!observer.shouldBeActive()) {observer.activeStateChanged(false);return;}if (observer.mLastVersion >= mVersion) {return;}observer.mLastVersion = mVersion;observer.mObserver.onChanged((T) mData);
}
复制代码

MVVM 架构解析

整个架构解析如下:

  1. View 层调用 ViewModel 获取数据
  2. ViewModel 调用 Repository 获取数据
  3. Repository 是数据仓库,根据实际业务,再通过 Dao 访问本地数据库或者 Retrofit 访问服务器。
  4. ViewModel 中的 LiveData 类型数据得到更新
  5. View 层的观察者 Observer 的回调方法 onChanged() 中收到新的数据,更新 UI
  6. 如果需要使用 paging 组件,就多了上图中的3处调用

Jetpack 架构组件

JetpackGoogle 为我们提供的架构组件,对于这些组件,我有以下理解和使用心得:

paging
  • 适用于列表页面,可以配置每页加载的数据量和预加载距离
  • 需要使用 PagedListAdapterPagedList
  • 加载下一页的逻辑就在 PagedListAdapter 调用 getItem() 时,这里会调用 PagedListloadAround() 方法
  • 相关参数要求:mEnablePlaceholderstruemPrefetchDistance 大于 0
DataBinding

适用于数据繁杂的页面,可以减少大量 java 代码,在列表页面不必使用。

Navigation
  • 适用于能触发两个明确页面之间跳转的操作
  • 不适用不能确定从哪个页面来或去往哪个页面的操作
ViewModel
  • 管理 ActivityFragment 的数据
  • 创建于ActivityFragment 内,页面被销毁前,ViewModel 会一直存在
  • 如果因配置变化导致页面销毁,ViewModel 不会销毁,它会被用于新的页面实例
  • 一般在 ViewModel 中配合 LiveData 使用
  • 一般用 ViewModelProviders 获取 ViewModelProvider,再用它的 get() 方法获取 ViewModel
  • get() 方法中会调用 Factorycreate() 方法创建 ViewModel
  • 创建的 ViewModel 被存入 ViewModelStoreHashMap 中,以便下次直接获取,不用再创建
  • ViewModelStore 是通过 ActivityFragment 获取的
  • ComponentActivity 的构造函数中有这么一段代码
  getLifecycle().addObserver(new GenericLifecycleObserver() {@Overridepublic void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {if (event == Lifecycle.Event.ON_DESTROY) {if (!isChangingConfigurations()) {getViewModelStore().clear();}}}});
复制代码

可见当不是配置变化导致 Activity 销毁时,会调用 ViewModelStoreclear() 方法:

  public final void clear() {for (ViewModel vm : mMap.values()) {vm.clear();}mMap.clear();}
复制代码

这里会调用 ViewModelclear() 方法,其中又会调用 onCleared()方法,我们可以在这个方法中取消订阅,以防内存泄漏。

MVVM 案例实战

下面根据我的开源项目 WanAndroid-MVVM 进一步讲解 MVVM 架构的运用,以下所有代码均来自于该项目。

不同的 UI 状态

首先对于数据加载,一般有【加载中、加载成功、加载失败】这3种状态, UI 上需要有对应的变化。

不同于 MVPP 层回调 V 层的相应方法更新 UI 的方式, MVVMView 层只能通过观察数据的方式来更新 UI

所以需要一种数据结构来表示不同的数据加载状态,并在 View 层对其进行观察和响应,定义这种数据结构如下:

package com.sbingo.wanandroid_mvvm.base/*** Author: Sbingo666* Date:   2019/4/12*/enum class Status {LOADING,SUCCESS,ERROR,
}data class RequestState<out T>(val status: Status, val data: T?, val message: String? = null) {companion object {fun <T> loading(data: T? = null) = RequestState(Status.LOADING, data)fun <T> success(data: T? = null) = RequestState(Status.SUCCESS, data)fun <T> error(msg: String? = null, data: T? = null) = RequestState(Status.ERROR, data, msg)}fun isLoading(): Boolean = status == Status.LOADINGfun isSuccess(): Boolean = status == Status.SUCCESSfun isError(): Boolean = status == Status.ERROR
}
复制代码

可以看到,RequestState 对应了3种数据加载状态,接着看它的具体使用:

package com.sbingo.wanandroid_mvvm.repositoryimport androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.sbingo.wanandroid_mvvm.base.RequestState
import com.sbingo.wanandroid_mvvm.data.http.HttpManager
import com.sbingo.wanandroid_mvvm.model.Chapter
import com.sbingo.wanandroid_mvvm.utils.asyncSubscribe/*** Author: Sbingo666* Date:   2019/4/22*/
class WeChatRepository(private val httpManager: HttpManager) {fun getWXChapters(): LiveData<RequestState<List<Chapter>>> {val liveData = MutableLiveData<RequestState<List<Chapter>>>()//数据加载中liveData.value = RequestState.loading() httpManager.wanApi.getWXChapters().asyncSubscribe({//数据加载成功liveData.postValue(RequestState.success(it.data))}, {//数据加载失败liveData.postValue(RequestState.error(it.message))})return liveData}
}
复制代码

这里将 RequestState 作为 LiveData 的泛型参数,这样 View 层就可以对这个 LiveData 进行观察了。

为了简化代码,统一处理重复逻辑,我将观察代码写入了 base 中:

protected fun <T> handleData(liveData: LiveData<RequestState<T>>, action: (T) -> Unit) =liveData.observe(this, Observer { result ->if (result.isLoading()) {showLoading()} else if (result?.data != null && result.isSuccess()) {finishLoading()action(result.data)} else {finishLoading()}})fun showLoading() {
}fun finishLoading() {
}
复制代码

根据自己的业务需求,方便地实现 showLoading()finishLoading() 的逻辑,数据处理就在每个页面传入的 action 中。

到这里,完整的数据加载显示流程就走通了!!!

异步加载数据

本项目中使用了 RxJava2 来异步加载数据,调用的代码很简单。

如果对线程切换的原理感兴趣,可以看我之前的一篇文章:【源码分析】RxJava 1.2.2 实现简单事件流的原理

但每个调用的地方都要异步切换也挺麻烦的,因此我对 Observable 做了一个扩展,如下:

package com.sbingo.wanandroid_mvvm.utilsimport com.sbingo.wanandroid_mvvm.data.http.RxHttpObserver
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers/*** Author: Sbingo666* Date:   2019/4/23*/fun <T> Observable<T>.async(): Observable<T> {return this.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
}fun <T> Observable<T>.asyncSubscribe(onNext: (T) -> Unit, onError: (Throwable) -> Unit) {this.async().subscribe(object : RxHttpObserver<T>() {override fun onNext(it: T) {super.onNext(it)onNext(it)}override fun onError(e: Throwable) {super.onError(e)onError(e)}})
}
复制代码

这两个方法都可以使用,具体就看是否想用 RxHttpObserver 这个自定义的观察者咯。

如果使用 asyncSubscribe() 方法,调用方只需传入数据加载成功和失败的逻辑,非常简单,就像这样:

httpManager.wanApi.getWXChapters().asyncSubscribe({liveData.postValue(RequestState.success(it.data))}, {liveData.postValue(RequestState.error(it.message))})
复制代码

统一处理接口数据

刚才说到自定义的观察者 RxHttpObserver ,这又是啥呢?

package com.sbingo.wanandroid_mvvm.data.httpimport com.sbingo.wanandroid_mvvm.R
import com.sbingo.wanandroid_mvvm.WanApplication
import com.sbingo.wanandroid_mvvm.utils.ExecutorUtils
import com.sbingo.wanandroid_mvvm.utils.NetUtils
import com.sbingo.wanandroid_mvvm.utils.ToastUtils
import io.reactivex.Observer
import io.reactivex.disposables.Disposableabstract class RxHttpObserver<T> : Observer<T> {override fun onSubscribe(d: Disposable) {if (!NetUtils.isConnected(WanApplication.instance)) {onError(RuntimeException(WanApplication.instance.getString(R.string.network_error)))}}override fun onError(e: Throwable) {e.message?.let {ExecutorUtils.main_thread(Runnable { ToastUtils.show(it) })}}override fun onNext(it: T) {//业务失败val result = it as? HttpResponse<*>if (result?.errorCode != 0) {onError(RuntimeException(if (result?.errorMsg.isNullOrBlank())WanApplication.instance.getString(R.string.business_error)else {result?.errorMsg}))}}override fun onComplete() {}
}
复制代码

这个自定义的观察者,主要干了三件事:

  1. 在网络请求前,判断网络是否连接,没有连接就调用错误处理方法。
  2. 根据 errorCode 的值判断业务处理是否成功,失败就调用错误处理方法。
  3. 在错误处理方法中向用户展示错误。

加入 paging 组件

之前提到过,如果加入了 paging 组件,架构流程略微不同。

paging 组件主要用于列表页面,根据列表页面的特性,我对其进行了一些封装,主要封装逻辑如下:

package com.sbingo.wanandroid_mvvm.base.pagingimport androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel/*** Author: Sbingo666* Date:   2019/4/12*/
open class BasePagingViewModel<T>(repository: BasePagingRepository<T>) : ViewModel() {private val pageSize = MutableLiveData<Int>()private val repoResult = Transformations.map(pageSize) {repository.getData(it)}val pagedList = Transformations.switchMap(repoResult) { it.pagedList }val networkState = Transformations.switchMap(repoResult) { it.networkState }val refreshState = Transformations.switchMap(repoResult) { it.refreshState }fun refresh() {repoResult.value?.refresh?.invoke()}fun setPageSize(newSize: Int = 10): Boolean {if (pageSize.value == newSize)return falsepageSize.value = newSizereturn true}fun retry() {repoResult.value?.retry?.invoke()}
}
复制代码

BasePagingViewModel 中的逻辑很好理解,repoResult 根据 pageSize 变化,其他数据又根据repoResult 变化,最后在 View 层对这些数据进行观察就可以了。

package com.sbingo.wanandroid_mvvm.base.pagingimport androidx.lifecycle.Transformations
import androidx.paging.Config
import androidx.paging.toLiveData/*** Author: Sbingo666* Date:   2019/4/12*/
abstract class BasePagingRepository<T> {fun getData(pageSize: Int): Listing<T> {val sourceFactory = createDataBaseFactory()val pagedList = sourceFactory.toLiveData(config = Config(pageSize = pageSize,enablePlaceholders = false,initialLoadSizeHint = pageSize * 2))val refreshState = Transformations.switchMap(sourceFactory.sourceLivaData) { it.refreshStatus }val networkStatus = Transformations.switchMap(sourceFactory.sourceLivaData) { it.networkStatus }return Listing(pagedList,networkStatus,refreshState,refresh = {sourceFactory.sourceLivaData.value?.invalidate()},retry = {sourceFactory.sourceLivaData.value?.retryFailed()})}abstract fun createDataBaseFactory(): BaseDataSourceFactory<T>
}
复制代码

BasePagingRepository 中对 PagedList 配置了每页数据量大小,初始加载量等参数,最后包装成数据结构 Listing 返回,这种结构如下:

package com.sbingo.wanandroid_mvvm.base.pagingimport androidx.lifecycle.LiveData
import androidx.paging.PagedList
import com.sbingo.wanandroid_mvvm.base.RequestState/*** Author: Sbingo666* Date:   2019/4/12*/
data class Listing<T>(//数据val pagedList: LiveData<PagedList<T>>,//上拉加载更多状态val networkState: LiveData<RequestState<String>>,//下拉刷新状态val refreshState: LiveData<RequestState<String>>,//刷新逻辑val refresh: () -> Unit,//重试逻辑,刷新或加载更多val retry: () -> Unit
)复制代码

而数据源来自 BaseDataSourceFactory:

package com.sbingo.wanandroid_mvvm.base.pagingimport androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource/*** Author: Sbingo666* Date:   2019/4/12*/
abstract class BaseDataSourceFactory<T> : DataSource.Factory<Int,T>() {val sourceLivaData = MutableLiveData<BaseItemKeyedDataSource<T>>()override fun create(): BaseItemKeyedDataSource<T> {val dataSource: BaseItemKeyedDataSource<T> = createDataSource()sourceLivaData.postValue(dataSource)return dataSource}abstract fun createDataSource(): BaseItemKeyedDataSource<T>}
复制代码

这里的 sourceLivaDataBaseItemKeyedDataSource 作为值,而 BaseItemKeyedDataSource才是真正获取数据的地方:

package com.sbingo.wanandroid_mvvm.base.pagingimport androidx.lifecycle.MutableLiveData
import androidx.paging.ItemKeyedDataSource
import com.sbingo.wanandroid_mvvm.base.RequestState
import com.sbingo.wanandroid_mvvm.utils.ExecutorUtils/*** Author: Sbingo666* Date:   2019/4/12*/
abstract class BaseItemKeyedDataSource<T> : ItemKeyedDataSource<Int, T>() {private var retry: (() -> Any)? = nullprivate var retryExecutor = ExecutorUtils.NETWORK_IOval networkStatus by lazy {MutableLiveData<RequestState<String>>()}val refreshStatus by lazy {MutableLiveData<RequestState<String>>()}fun retryFailed() {val preRetry = retryretry = nullpreRetry.let {retryExecutor.execute {it?.invoke()}}}//初始加载(包括刷新)时,系统回调此方法override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<T>) {refreshStatus.postValue(RequestState.loading())onLoadInitial(params, callback)}//加载更多时,系统回调此方法override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<T>) {networkStatus.postValue(RequestState.loading())onLoadAfter(params, callback)}override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<T>) {}fun refreshSuccess() {refreshStatus.postValue(RequestState.success())retry = null}fun networkSuccess() {retry = nullnetworkStatus.postValue(RequestState.success())}fun networkFailed(msg: String?, params: LoadParams<Int>, callback: LoadCallback<T>) {networkStatus.postValue(RequestState.error(msg))retry = {loadAfter(params, callback)}}fun refreshFailed(msg: String?, params: LoadInitialParams<Int>, callback: LoadInitialCallback<T>) {refreshStatus.postValue(RequestState.error(msg))retry = {loadInitial(params, callback)}}override fun getKey(item: T) = setKey(item)abstract fun setKey(item: T): Intabstract fun onLoadAfter(params: LoadParams<Int>, callback: LoadCallback<T>)abstract fun onLoadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<T>)
}
复制代码

子类只需要复写父类的 onLoadInitial()onLoadAfter() 方法就能执行刷新和加载更多的逻辑了。

这里实现了【重试】的逻辑和【加载中、加载成功、加载失败】这3种状态,这3种状态使用了之前提到的数据结构 RequestState,不过加载成功后数据并不会在这里的 RequestState 中,这里的 RequestState 只表示加载状态。那数据怎么更新呢?

我们来看一个 BaseItemKeyedDataSource 的子类吧:

package com.sbingo.wanandroid_mvvm.paging.sourceimport com.sbingo.wanandroid_mvvm.base.paging.BaseItemKeyedDataSource
import com.sbingo.wanandroid_mvvm.data.http.HttpManager
import com.sbingo.wanandroid_mvvm.model.Article
import com.sbingo.wanandroid_mvvm.utils.asyncSubscribe/*** Author: Sbingo666* Date:   2019/4/23*/
class WXDataSource(private val httpManager: HttpManager, private val wxId: Int) : BaseItemKeyedDataSource<Article>() {var pageNo = 1override fun setKey(item: Article) = item.idoverride fun onLoadAfter(params: LoadParams<Int>, callback: LoadCallback<Article>) {httpManager.wanApi.getWXArticles(wxId, pageNo).asyncSubscribe({pageNo += 1networkSuccess()callback.onResult(it.data?.datas!!)}, {networkFailed(it.message, params, callback)})}override fun onLoadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Article>) {httpManager.wanApi.getWXArticles(wxId, pageNo).asyncSubscribe({pageNo += 1refreshSuccess()callback.onResult(it.data?.datas!!)}, {refreshFailed(it.message, params, callback)})}
}
复制代码

可以看到和之前 WeChatRepository 中类似,数据也是从服务器上获取的,只不过获取的数据是通过 callback.onResult() 方法返回给 View 层的。

View 层这边,列表的【重试】按钮是封装在 BasePagingAdapter 中的, 根据观察到的 networkState,动态设置按钮的显示与隐藏,相关代码如下:

private fun hasFooter() =if (requestState == null)falseelse {!requestState?.isSuccess()!!}override fun getItemViewType(position: Int): Int {return if (hasFooter() && position == itemCount - 1) {TYPE_FOOTER} else {TYPE_ITEM}
}override fun getItemCount(): Int {return super.getItemCount() + if (hasFooter()) 1 else 0
}fun setRequestState(newRequestState: RequestState<Any>) {val previousState = this.requestStateval hadExtraRow = hasFooter()this.requestState = newRequestStateval hasExtraRow = hasFooter()if (hadExtraRow != hasExtraRow) {if (hadExtraRow) {notifyItemRemoved(super.getItemCount())} else {notifyItemInserted(super.getItemCount())}} else if (hasExtraRow && previousState != newRequestState) {notifyItemChanged(itemCount - 1)}
}
复制代码

根据这些封装类,在业务中实现它们的子类,就能轻松使用 paging 组件啦!!!

到这里,MVVM 架构的理论与实践都已打通!

转载于:https://juejin.im/post/5ce6612ef265da1b94212208

MVVM 架构解析及 Jetpack 架构组件的使用相关推荐

  1. Android Jetpack架构组件(一)带你了解Android Jetpack

    本文首发于微信公众号「后厂村码农」 前言 Android已经发展了11年,可以说是比较成熟的技术了,一开始时框架很少,也没有什么规范,所有的代码都是要自己写,比如网络请求,数据库请求,数据解析等等.后 ...

  2. Jetpack架构组件 (一)-- Android Jetpack 简介

    前言 Android 已经发展十多年了,可以说是比较成熟的技术了,一开始时框架很少,也没有什么规范,所有的代码都是要自己写,比如网络请求,数据库操作,数据解析等等.后来出现了一些框架来帮助开发者快速进 ...

  3. 案例精选 | 蘑菇街、滴滴、淘宝、微信的组件化架构解析

    导读:前段时间公司项目打算重构,准确来说应该是按之前的产品逻辑重写一个项目.在重构项目之前涉及到架构选型的问题,我和组里小伙伴一起研究了一下组件化架构,打算将项目重构为组件化架构.当然不是直接拿来照搬 ...

  4. vue2.0 class声明组件_案例精选 | 蘑菇街、滴滴、淘宝、微信的组件化架构解析

    导读:前段时间公司项目打算重构,准确来说应该是按之前的产品逻辑重写一个项目.在重构项目之前涉及到架构选型的问题,我和组里小伙伴一起研究了一下组件化架构,打算将项目重构为组件化架构.当然不是直接拿来照搬 ...

  5. 蘑菇街、滴滴、淘宝、微信的组件化架构解析,附Demo和PDF

    前段时间公司项目打算重构,准确来说应该是按之前的产品逻辑重写一个项目?.在重构项目之前涉及到架构选型的问题,我和组里小伙伴一起研究了一下组件化架构,打算将项目重构为组件化架构.当然不是直接拿来照搬,还 ...

  6. Android Jetpack架构组件之 Room(使用、源码篇)

    2019独角兽企业重金招聘Python工程师标准>>> 1.前言 最近简单看了下google推出的框架Jetpack,感觉此框架的内容可以对平时的开发有很大的帮助,也可以解决很多开发 ...

  7. ios navigation的返回按钮长按_Android Jetpack架构组件 — Navigation入坑详解 [转]

    前言 这是最近看见的觉得比较有意思的文,希望对大家的学习有帮助. Navigation 直接翻译即为导航,它是 Android Jetpack 组件之一,让单 Activity 应用成为首选架构.应用 ...

  8. android 使用4大组件的源码,Android Jetpack架构组件之 Paging(使用、源码篇)

    1.前言 最近简单看了下google推出的框架Jetpack,感觉此框架的内容可以对平时的开发有很大的帮助,也可以解决很多开发中的问题,对代码的逻辑和UI界面实现深层解耦,打造数据驱动型UI界面. A ...

  9. Android业务架构 · 基础篇 · Jetpack四件套

    一.序言 2017年,Google发布了Android Architecture Components,包括Room.LiveData.ViewModel和Paging等组件,旨在帮助开发者更轻松地实 ...

  10. 特斯拉Tesla Model 3整体架构解析(上)

    特斯拉Tesla Model 3整体架构解析(上) 一辆特斯拉 Model 3型车在硬件改造后解体 Sensors for ADAS applications 特斯拉 Model 3型设计的传感器组件 ...

最新文章

  1. 磁盘的顺序读写与随机读写详解
  2. 2.页面布局示例笔记
  3. linux iio 设备驱动,Linux设备驱动之IIO子系统——IIO框架数据读取-Go语言中文社区...
  4. Google Guava,牛逼的脚手架
  5. mysql 多个值求和_SQL优化大神玩转MySQL函数系列(2)LEAST,SUM的应用
  6. WinForm 的定时器使用
  7. java如何使用md5加密_Java中MD5加密
  8. 计算机系统概论 第二版 doc,计算机系统概论.doc
  9. ms10_002(极光漏洞)渗透步骤——MSF搭建钓鱼网站
  10. win64位MySQL5.7.32下载、安装及配置
  11. centos部署mosquitto
  12. 如何以正确的顺序重新安装驱动程序
  13. 2db多少功率_功率换算(dB与W).doc
  14. 【贪心】Songs Compression
  15. 计算机学院的横幅,毕业横幅标语(精选50句)
  16. 17.项目开发中遇到的问题(this.$parent.$parent子组件调父组件的父组件的方法不可用问题)
  17. 【互联网代理方案】——Zookeeper
  18. python运行excel宏_从python运行excel宏
  19. 推荐一个小巧强大的代码编辑器
  20. AMD领先英特尔发表工作频率3.4THz的晶体管 (转)

热门文章

  1. scikit keras_使用Scikit-Learn,Scikit-Opt和Keras进行超参数优化
  2. spring-boot-starter-parent和spring-boot-dependencies的作用
  3. jieba 结巴结巴结巴
  4. java连接mysql并在textarea输出_Java面试宝典Java IO篇
  5. 服务器的维护记录在哪查看,教你巧用事件查看器维护服务器安全 -电脑资料
  6. Druid 在有赞的使用场景及应用实践
  7. 新版本安装包需求汇总
  8. 采用python的pyquery引擎做网页爬虫,进行数据分析
  9. 【Docker】问题汇总
  10. python dash html.table_阅读 Python dash 代码的时候有个问题, 那个包的调用有问题?