MVVM 架构解析及 Jetpack 架构组件的使用
本文首发于 Sbingo666的博客
MVVM 架构图
谈到 MVVM
架构,不得不祭出官方的架构图,架构图能帮助我们更好地理解,如下所示:
在实践中,根据对架构组件 paging
的使用和理解,我将架构图扩展成下面这样:有背景颜色的3处是 paging
组件需要多用到的。
MVVM 和 MVP 的区别
MVP
中 V
层和 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 架构解析
整个架构解析如下:
View
层调用ViewModel
获取数据ViewModel
调用Repository
获取数据Repository
是数据仓库,根据实际业务,再通过Dao
访问本地数据库或者Retrofit
访问服务器。ViewModel
中的LiveData
类型数据得到更新View
层的观察者Observer
的回调方法onChanged()
中收到新的数据,更新UI
。- 如果需要使用
paging
组件,就多了上图中的3处调用
Jetpack 架构组件
Jetpack
是 Google
为我们提供的架构组件,对于这些组件,我有以下理解和使用心得:
paging
- 适用于列表页面,可以配置每页加载的数据量和预加载距离
- 需要使用
PagedListAdapter
和PagedList
- 加载下一页的逻辑就在
PagedListAdapter
调用getItem()
时,这里会调用PagedList
的loadAround()
方法 - 相关参数要求:
mEnablePlaceholders
为true
或mPrefetchDistance
大于 0
DataBinding
适用于数据繁杂的页面,可以减少大量 java
代码,在列表页面不必使用。
Navigation
- 适用于能触发两个明确页面之间跳转的操作
- 不适用不能确定从哪个页面来或去往哪个页面的操作
ViewModel
- 管理
Activity
或Fragment
的数据 - 创建于
Activity
或Fragment
内,页面被销毁前,ViewModel
会一直存在 - 如果因配置变化导致页面销毁,
ViewModel
不会销毁,它会被用于新的页面实例 - 一般在
ViewModel
中配合LiveData
使用 - 一般用
ViewModelProviders
获取ViewModelProvider
,再用它的get()
方法获取ViewModel
- 在
get()
方法中会调用Factory
的create()
方法创建ViewModel
- 创建的
ViewModel
被存入ViewModelStore
的HashMap
中,以便下次直接获取,不用再创建 ViewModelStore
是通过Activity
或Fragment
获取的- 在
ComponentActivity
的构造函数中有这么一段代码
getLifecycle().addObserver(new GenericLifecycleObserver() {@Overridepublic void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {if (event == Lifecycle.Event.ON_DESTROY) {if (!isChangingConfigurations()) {getViewModelStore().clear();}}}});
复制代码
可见当不是配置变化导致 Activity
销毁时,会调用 ViewModelStore
的 clear()
方法:
public final void clear() {for (ViewModel vm : mMap.values()) {vm.clear();}mMap.clear();}
复制代码
这里会调用 ViewModel
的 clear()
方法,其中又会调用 onCleared()
方法,我们可以在这个方法中取消订阅,以防内存泄漏。
MVVM 案例实战
下面根据我的开源项目 WanAndroid-MVVM 进一步讲解 MVVM
架构的运用,以下所有代码均来自于该项目。
不同的 UI 状态
首先对于数据加载,一般有【加载中、加载成功、加载失败】这3种状态, UI
上需要有对应的变化。
不同于 MVP
中 P
层回调 V
层的相应方法更新 UI
的方式, MVVM
中 View
层只能通过观察数据的方式来更新 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() {}
}
复制代码
这个自定义的观察者,主要干了三件事:
- 在网络请求前,判断网络是否连接,没有连接就调用错误处理方法。
- 根据
errorCode
的值判断业务处理是否成功,失败就调用错误处理方法。 - 在错误处理方法中向用户展示错误。
加入 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>}
复制代码
这里的 sourceLivaData
将 BaseItemKeyedDataSource
作为值,而 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 架构组件的使用相关推荐
- Android Jetpack架构组件(一)带你了解Android Jetpack
本文首发于微信公众号「后厂村码农」 前言 Android已经发展了11年,可以说是比较成熟的技术了,一开始时框架很少,也没有什么规范,所有的代码都是要自己写,比如网络请求,数据库请求,数据解析等等.后 ...
- Jetpack架构组件 (一)-- Android Jetpack 简介
前言 Android 已经发展十多年了,可以说是比较成熟的技术了,一开始时框架很少,也没有什么规范,所有的代码都是要自己写,比如网络请求,数据库操作,数据解析等等.后来出现了一些框架来帮助开发者快速进 ...
- 案例精选 | 蘑菇街、滴滴、淘宝、微信的组件化架构解析
导读:前段时间公司项目打算重构,准确来说应该是按之前的产品逻辑重写一个项目.在重构项目之前涉及到架构选型的问题,我和组里小伙伴一起研究了一下组件化架构,打算将项目重构为组件化架构.当然不是直接拿来照搬 ...
- vue2.0 class声明组件_案例精选 | 蘑菇街、滴滴、淘宝、微信的组件化架构解析
导读:前段时间公司项目打算重构,准确来说应该是按之前的产品逻辑重写一个项目.在重构项目之前涉及到架构选型的问题,我和组里小伙伴一起研究了一下组件化架构,打算将项目重构为组件化架构.当然不是直接拿来照搬 ...
- 蘑菇街、滴滴、淘宝、微信的组件化架构解析,附Demo和PDF
前段时间公司项目打算重构,准确来说应该是按之前的产品逻辑重写一个项目?.在重构项目之前涉及到架构选型的问题,我和组里小伙伴一起研究了一下组件化架构,打算将项目重构为组件化架构.当然不是直接拿来照搬,还 ...
- Android Jetpack架构组件之 Room(使用、源码篇)
2019独角兽企业重金招聘Python工程师标准>>> 1.前言 最近简单看了下google推出的框架Jetpack,感觉此框架的内容可以对平时的开发有很大的帮助,也可以解决很多开发 ...
- ios navigation的返回按钮长按_Android Jetpack架构组件 — Navigation入坑详解 [转]
前言 这是最近看见的觉得比较有意思的文,希望对大家的学习有帮助. Navigation 直接翻译即为导航,它是 Android Jetpack 组件之一,让单 Activity 应用成为首选架构.应用 ...
- android 使用4大组件的源码,Android Jetpack架构组件之 Paging(使用、源码篇)
1.前言 最近简单看了下google推出的框架Jetpack,感觉此框架的内容可以对平时的开发有很大的帮助,也可以解决很多开发中的问题,对代码的逻辑和UI界面实现深层解耦,打造数据驱动型UI界面. A ...
- Android业务架构 · 基础篇 · Jetpack四件套
一.序言 2017年,Google发布了Android Architecture Components,包括Room.LiveData.ViewModel和Paging等组件,旨在帮助开发者更轻松地实 ...
- 特斯拉Tesla Model 3整体架构解析(上)
特斯拉Tesla Model 3整体架构解析(上) 一辆特斯拉 Model 3型车在硬件改造后解体 Sensors for ADAS applications 特斯拉 Model 3型设计的传感器组件 ...
最新文章
- 磁盘的顺序读写与随机读写详解
- 2.页面布局示例笔记
- linux iio 设备驱动,Linux设备驱动之IIO子系统——IIO框架数据读取-Go语言中文社区...
- Google Guava,牛逼的脚手架
- mysql 多个值求和_SQL优化大神玩转MySQL函数系列(2)LEAST,SUM的应用
- WinForm 的定时器使用
- java如何使用md5加密_Java中MD5加密
- 计算机系统概论 第二版 doc,计算机系统概论.doc
- ms10_002(极光漏洞)渗透步骤——MSF搭建钓鱼网站
- win64位MySQL5.7.32下载、安装及配置
- centos部署mosquitto
- 如何以正确的顺序重新安装驱动程序
- 2db多少功率_功率换算(dB与W).doc
- 【贪心】Songs Compression
- 计算机学院的横幅,毕业横幅标语(精选50句)
- 17.项目开发中遇到的问题(this.$parent.$parent子组件调父组件的父组件的方法不可用问题)
- 【互联网代理方案】——Zookeeper
- python运行excel宏_从python运行excel宏
- 推荐一个小巧强大的代码编辑器
- AMD领先英特尔发表工作频率3.4THz的晶体管 (转)
热门文章
- scikit keras_使用Scikit-Learn,Scikit-Opt和Keras进行超参数优化
- spring-boot-starter-parent和spring-boot-dependencies的作用
- jieba 结巴结巴结巴
- java连接mysql并在textarea输出_Java面试宝典Java IO篇
- 服务器的维护记录在哪查看,教你巧用事件查看器维护服务器安全 -电脑资料
- Druid 在有赞的使用场景及应用实践
- 新版本安装包需求汇总
- 采用python的pyquery引擎做网页爬虫,进行数据分析
- 【Docker】问题汇总
- python dash html.table_阅读 Python dash 代码的时候有个问题, 那个包的调用有问题?