Architecture-Android
功能介绍
- 支持配置变更 后的还原
- 屏幕旋转
- 亮暗主题切换
- 语言切换(国际化)
- 字体大小更改
- 分屏
- ...
- 支持进程杀死后的还原
- 项目架构:模块化+组件化+MVI(UiState+ViewModel+Flow+Kotlin协程+Repository+DataSource+Retrofit)
- 支持多App开发
- 支持一键切换Feature模块单独运行
- 支持一键去除可移除功能代码
- 支持项目无反射实现(项目默认无反射实现,反射实现也提供,可供在两者选择)
- 支持EdgeToEdge (
targetSdk >= 35
(Android15),强制开启了,所以为了开启时兼任低版本,需要全部支持) - 支持动态主题 (
Android12+
支持此功能) - 支持刷新、自动预加载(自动预加载,如果用户滑动慢并且获取数据快,用户是感觉不到加载的)
本项目为一个Android架构 ,它遵循 Android 设计 和开发最佳实践 ,旨在为开发者提供实用参考。
本项目目标 是为了同时 支持Compose
和View
,以支持公司项目 目前已有的View
代码和之后的Compose
代码,目前本项目仅 支持View
,后续项目关注度 (Star点赞数) 高后 会支持Compose
。
本项目是以字节跳动 公司的抖音 App为参照,模拟开发的抖音 App。由于本项目,无抖音真正的网络数据 ,所以本项目使用的数据,是通过某些开源API 网络接口,模拟 转的网络数据。
本项目是在官方的架构 (nowinandroid(18.2k Star)、architecture-samples(44.9k Star))上做的升级和修改 ,如果大家对此架构模块的划分 不理解,建议大家先了解官方的 nowinandroid,然后再来看本项目。
本项目文档分为快速介绍 、详细介绍 (模块间架构 、模块内架构 )、使用 ,建议大家按照顺序阅读文档。如果你想快速 的了解,可以只看快速介绍 、使用 ,我提供了demo工程 ,里面有最简单 的使用案例,可以先阅读此代码。
欢迎大家一起来维护 项目,使其功能更加的强大 、健硕 。有问题,有需求,请提issue,或者私信我。
项目链接: architecture-android,欢迎大家点赞、收藏,以方便您后续查看。
下载
扫码下载
截图
App展示
主题展示
Themes | Light | Dark |
---|---|---|
抖音主题 | ||
动态主题1 | ||
动态主题2 |
快速介绍
下载
下载项目并运行
shell
git clone [email protected]:zrq1060/architecture-android.git
功能演示
本项目任何页面,都支持如下:
- 配置变更后的还原 (屏幕旋转 、亮暗模式切换 、语言切换 、字体大小更改 、分屏 等),配置变更会导致
Activity
、Fragment
会重新创建新的。- 进程被杀死后的还原(可打开,开发者选项-后台进程限制-不允许后台进程,以更好的测试进程被杀死。开启后,可在后台多打开一些无关的app,再切换打开此app即可演示此效果)。
本项目,目前仅支持如下功能:
-
登录页 :登录账号(手机号、邮箱)为 【任意】 ,登录密码(验证码、密码)为 【123456】 。可断网 ,或输入错误密码,查看页面效果。
-
Home首页 :顶部栏目的排序(长按首页-顶部栏目)
-
Main主页 :好友、商场栏目的切换(长按主页-底部栏目第2个)
-
Shop商城页 :支持刷新 、自动加载 ,点击商城条目 模拟的商城列表数据的增、删、改 操作。可断网,查看页面效果。
在此跟着上面,操作App支持的功能(记得开启屏幕自动旋转、切换亮暗模式、点击商城Item),以演示上面功能效果。
单独运行Feature模块演示
1、修改项目根目录 下gradle.properties
内isFeatureSingle
为true
,并Sync
同步Gradle
。
groovy
isFeatureSingle = true
2、执行安装全部命令
点击右侧Gradle
-Tasks
-install
-installDebug
,或执行如下命令:
groovy
.\gradlew installDebug
执行完后,会在手机桌面 出现所有Feature模块 的App
(如下图所示),点击 某个即可测试某单个Feature模块。
详细介绍
我们先讲模块间的架构 ,然后再讲模块内的架构 ,最后讲使用。
模块间架构
在不断变大的代码库中,可扩缩性 、可读性 和整体代码质量 通常会随着时间的推移而降低。这是因为代码库在不断变大,而其维护者未采取积极措施来保持易于维护的结构。模块化是一种行之有效的代码库构建方法,可帮助改善可维护性并避免此类问题。
模块化 相关,请看官方的 Android 应用模块化指南。
官方(标准版)模块划分demo 相关,请看官方的nowinandroid。
模块划分
标准版
本项目官方(标准版)的模块化 完成后,模块图(部分模块) 如下:
模块说明:
app
模块 :app
模块依赖于所有 的feature
模块和必需 的core
模块。feature:
模块 :feature
模块不应该依赖于其它的feature
模块,它们只依赖于所需 的core
模块。core:
模块 :core
模块可以依赖于其它的core
模块,但它们不应该依赖于feature
模块或app
模块。
本项目官方 的模块化 完成后,项目目录图 如下(标准版):
多App版
一般一个公司并非一个App ,比如商城功能App (一个用户端、一个商家端)、外卖功能App (一个用户端、一个骑手端)。以字节跳动 公司为例子,其中公司开发的App有抖音 、西瓜视频 、今日头条 、飞书 、剪映等。
上面官方(标准版)的模块 划分,导致内部的core
模块含有本App特有 的、所有App通用 的代码及资源,仅适用于单App 架构。如果要适用多App 架构,就需要把core
模块内所有App通用部分 提取出来,提取后的项目,项目目录图 如下(多App版):
变化说明:
- 把标准版 的抖音 的模块结构,存到了最外层
douyin
目录。- 把一些可所有App共用 的模块,存到了最外层
core
目录。- 把标准版 的西瓜视频 的模块结构,存到了最外层
xigua
目录。
目录说明:
- 最外层
core
目录 :为所有App 都可以使用的代码及资源,内部模块 被所有App 内的core
模块依赖。 - 最外层
douyin
目录 :为 抖音App自己独有(特有) 的相关代码及资源。core
模块 :依赖最外层core
目录 内的模块,反之不行。app
模块 、feature
模块 :直接依赖抖音内部core
模块即可,此为最外层core
目录 的功能定制 ,如:直接依赖抖音App 的:douyin:core:architecture
模块即可,此模块为抖音App 对:core:architecture
模块(所有App通用的-架构模块 )的定制。
- 最外层
xigua
目录 :为西瓜视频App的相关代码,规则同上(抖音)。
说明:还可以在最外层 继续开发其它App,如今日头条 、飞书 、剪映等,规则同上(抖音、西瓜视频)。
可移除版
在项目开发过程中,如果你不看好 要开发的功能,或者领导、产品告诉你,要开发的功能之后可能会移除,你可以使用此设计。
以抖音App 为例,最早 的抖音 是没有商城 功能的,如果以商城 功能之后会移除来开发,你可以使用以下模块设计。
现在的项目,项目目录图 如下(可移除版):
变化说明:
- 把可能要移除的shop功能 的模块结构,存到了最外层
douyin
目录。
新增的shop目录说明:
core
模块 :可以依赖抖音、商城core模块,但是抖音core模块不能依赖商城core模块(以便好在抖音内移除)。feature
模块 :同级feature间不能相互依赖,只能依赖抖音、商城core模块。
可以修改项目根目录 下gradle.properties
内isShopInclude
为false
、isRouterReflect
为true
,来演示此移除功能。
groovy
isShopInclude = false
isRouterReflect = true
说明:
isShopInclude
:为是否包括商城。
isRouterReflect
:为是否Router
反射实现。
- 本项目
Router
的实现分为了两种,Dagger实现 (正式 用)、反射实现 (测试 用),详细看router
模块。
模块包名
包名格式一般为:域名反转+项目名+功能名 ,以此字节跳动 (域名:www.bytedance.com )公司抖音项目为例,规则如下:
core
:com.bytedance.core.xxx (和特定App无关),如:com.bytedance.core.architecturedouyin
:com.bytedance.douyin.xxx (和特定App有关 )app
:com.bytedance.douyincore
:com.bytedance.douyin.core.xxx,如:com.bytedance.douyin.core.architecturefeature
:com.bytedance.douyin.feature.xxx,如:com.bytedance.douyin.feature.homeshop
:com.bytedance.douyin.shop .xxxcore
:com.bytedance.douyin.shop.core.xxx,如:com.bytedance.douyin.shop.core.datafeature
:com.bytedance.douyin.shop.feature.xxx,如:com.bytedance.douyin.shop.feature.shop
xigua
:规则同上(抖音)
模块功能
app
:项目的入口,含有MainActivity
、Application
等。core
architecture
:架构相关,包含一些基础类,如:最外层:core:architecture
模块包含通用的BaseViewsActivity
、BaseViewsFragment
等,抖音层:douyin:core:architecture
模块包含抖音 定制的AppViewsActivity
、AppViewsFragment
等。architecture-reflect
:架构反射实现相关,包含一些架构内的反射实现,如:reflectInflateViewBinding
(反射实现ViewBinding
)、reflectViewModels
(反射实现ViewModel
)。common
:通用相关,包含一些通用类、工具类等。designsystem
:设计系统相关,包含控件、主题等。model
:Model
类相关,包含Model
类等。network
:网络相关,包含NetworkDataSource
、网络工具类、图片加载等。test
:测试页面相关(为了给未实现的功能,占位用),包含TestActivity
、TestFragment
等。webview
:网页相关,包含网页的跳转、配置等。data
:数据相关,包含Repository
类等。datastore
:DataStore
存储相关,包含PreferencesDataSource
等。datastore-proto
:DataStore
的proto
配置相关,包含user.proto
配置等。feature-single
:单独模块运行通用配置相关,包含TestFragmentDetailsAndroidEntryPointActivity
等。login
:登录相关,包含登录检测、当前登录状态、退出登录等。router
:路由系统相关,包含Router
的Dagger实现 、反射实现等。
feature
:功能业务,包含UI、ViewModel
等。
Feature模块间通信介绍
Feature
模块间通信,使用router
模块的Router
类进行通信,以home
模块为例规则如下:
定义
kotlin
interface HomeRouter {
fun createHomeFragment(): Fragment
}
此HomeRouter
接口为home
模块对外暴露 的可供其它模块调用 部分,在router
模块内定义,如果还有其它的,可继续在此接口内添加,如:createXXXFragment
、startXXXActivity
方法等。
真的实现
kotlin
class DefaultHomeRouter : HomeRouter {
override fun createHomeFragment(): Fragment = HomeFragment.newInstance()
}
此DefaultHomeRouter
类为HomeRouter
接口真的实现 ,在home
模块内实现。
假的实现
kotlin
class FakeHomeRouter : HomeRouter {
override fun createHomeFragment(): Fragment = AppTestFragment.newInstance("Home")
}
此FakeHomeRouter
类为HomeRouter
接口假的实现 ,在router
模块的router-reflect
内实现,内部使用的AppTestFragment
仅是为了显示时占位用。
说明:
Router-Dagger实现 :使用
Dagger
找HomeRouter
的实现(目前提供的是DefaultHomeRouter
),如果找不到会报错。Router-反射实现 :使用反射 直接找
DefaultHomeRouter
,如果找不到会直接使用FakeHomeRouter
,不会报错。
调用
kotlin
val homeFragment = Router.Home.createHomeFragment()
单独运行Feature模块介绍
如果你只负责某个Feature
模块,或者想更解耦 、更快 的测试你的功能,你可以使用此单独运行Feature模块,步骤如下:
修改配置
修改项目根目录 下gradle.properties
内isFeatureSingle
为true
,并Sync
同步Gradle
。
groovy
isFeatureSingle = true
说明:
isFeatureSingle
:为是否单独运行Feature模块 。如果开启 ,则Router
使用反射实现 ,以使其调用其它模块 没有时不会报错 ,而是使用Fake
的实现(如:占位显示)。
添加测试入口点
此功能需要配合使用我的TestPoint库来实现,添加测试入口点 ,即会在测试列表页 增加一个按钮,点击按钮跳转到此Activity
、Fragment
,定制按钮点击 等详细使用请看TestPoint。
在目标类上添加TestEntryPoint
注解,如ShopFragment
:
kotlin
@TestEntryPoint("商城")
class ShopFragment{
}
运行
单个运行:
选择上面的一个,并运行,如:选择douyin.shop.feature.shop
,则运行抖音的商城功能 (可以测试商城的点击Item功能等)。
多个运行:
点击右侧Gradle
-Tasks
-install
-installDebug
,或执行如下命令:
groovy
.\gradlew installDebug
执行完后,会在手机桌面 出现所有Feature模块 的App
(如快速介绍-单独运行Feature模块演示图所示),点击 某个即可测试某单个Feature模块。
模块内架构
官方架构
模块内架构,使用官方 的推荐架构,有助于构建强大而优质的应用。
应用架构 相关,请看官方的 应用架构指南。
官方的架构概述图 如下:
官方架构分为了:UI层 、Domain层 (可选)、Data数据层。
项目架构
本项目,目前没有使用 Domain
层,也没有使用 Room
库,目前的项目架构图 如下:
使用
Activity、Fragment
以demo
模块的MainActivity
为例:
kotlin
package com.bytedance.demo.app.main
import android.view.LayoutInflater
import androidx.activity.viewModels
import com.bytedance.douyin.core.architecture.app.views.AppViewsActivity
import dagger.hilt.android.AndroidEntryPoint
// 设置as别名,一般都是设置这几个。
// 使用别名后,此类的模板,下面的不需要改了,只需要改上面as这里即可。
import com.bytedance.demo.app.main.MainUiState as UiState
import com.bytedance.demo.app.main.MainViewModel as ViewModel
import com.bytedance.demo.databinding.ActivityMainBinding as ViewBinding
/**
* 描述:
*
* @author zhangrq
* createTime 2025/3/24 11:14
*/
@AndroidEntryPoint
class MainActivity : AppViewsActivity<ViewBinding, UiState, ViewModel>() {
// 在父类AppViewsActivity中,可用反射实现(reflectViewModels()),省略此实现。
override val viewModel: ViewModel by viewModels()
// 在父类AppViewsActivity中,可用反射实现(reflectInflateViewBinding()),省略此实现。
override fun inflateViewBinding(inflater: LayoutInflater) = ViewBinding.inflate(inflater)
// 初始化View(可以在里面直接拿到当前页面布局控件)
override fun ViewBinding.initViews() {
// 设置TextView控件
content.textSize = 50f
// content.setTextColor(Color.BLACK)
}
// 初始化Listener(可以在里面直接拿到当前页面布局控件)
override fun ViewBinding.initListeners() {
// 设置TextView点击
content.setOnClickListener {
// 显示Toast,此Toast和当前页面的生命周期绑定,当前页面不可见,Toast关闭。
viewModel.showMessage("Long Toast", isShort = false)
}
}
// 初始化Observer(可以在里面直接拿到当前页面布局控件),用于观察(收集)ViewModel内的暴露的属性值(Flow值)。
override fun ViewBinding.initObservers() {
}
// 收集UiState的值(可以在里面直接拿到当前页面布局控件),用于设置当前页面的数据。
override fun ViewBinding.onUiStateCollect(uiState: UiState) {
// 设置TextView的值
content.text = uiState.tabs?.joinToString()
}
}
Activity
、Fragment
、DialogFragment
的使用规则相同 ,以Activity
为例,说明如下:
MainActivity
直接继承App级 的AppViewsActivity
,此类为抖音 项目对通用级 的BaseViewsActivity
的定制。ViewModel
、ViewBinding
的创建 ,由于本项目为了性能没有使用反射 ,所以需要在每个子类中自己实现 ,可以在App级 的AppViewsActivity
内使用reflectInflateViewBinding
、reflectViewModels
反射实现 ,这样就可以在每个子类 中省略ViewModel
、ViewBinding
的创建代码。- 初始化系列方法 ,使用
ViewBinding
扩展方法,是为了能让其在方法内直接获取到xxx
控件 ,而不用通过binding.xxx
获取,以更方便 的操作控件。XXXBinding
、XXXUiState
、XXXViewModel
,全部通过as别名 来命名,简化了名字长度 ,统一了代码样式一致性 ,这样新类 只需要修改模板类上面as别名即可。
ViewModel
以demo
模块的MainViewModel
为例:
kotlin
package com.bytedance.demo.app.main
import com.bytedance.douyin.core.architecture.app.AppViewModel
import com.bytedance.douyin.core.data.repository.interfaces.MainRepository
import com.bytedance.douyin.core.model.MainTabType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
// 设置as别名
import com.bytedance.demo.app.main.MainUiState as UiState
/**
* 描述:
*
* @author zhangrq
* createTime 2025/3/24 11:14
*/
@HiltViewModel
class MainViewModel @Inject constructor(mainRepository: MainRepository) : AppViewModel<UiState>() {
override val uiStateInitialValue: UiState = UiState() // UiState-初始化值
// 从MainRepository获取的本地流,本地数据改,UI改。
override val uiStateFlow: Flow<UiState> = mainRepository.getMainTabsStream().map {
// UiState-页面值
UiState(tabs = it)
}
}
// Main-UiState
data class MainUiState(
val tabs: List<MainTabType>? = null,
)
说明:
ViewModel
直接继承App级 的AppViewModel
,此类为抖音 项目对通用级 的BaseViewModel
的定制。uiStateInitialValue
为UiState
的初始化值,一般为默认的UiState
对象。uiStateFlow
为UiState
的Flow
流,它变化会影响到Activity
、Fragment
的onUiStateCollect()
,一般为Flow
(单个Flow
、使用combine()
观察多个Flow
)的map()
转为UiState
的Flow
。XXXUiState
,通过as别名 来命名,简化了名字长度 ,统一了代码样式一致性 ,这样新类 只需要修改模板类上面as别名即可。
生命周期Toast、Snackbar
直接显示 Toast
、Snackbar
,是没有生命周期控制 的(只负责显示 ),即使Activiy
、Fragment
不可见 (被销毁、回到后台),也还在显示 。我增加了生命周期消息显示 ,仅在Activiy
、Fragment
可见时显示。
指定消息的显示
指定消息的显示 ,是使用Toast
,还是Snackbar
,目前默认 为Toast
。
- 全局消息指定 ,在
BaseGlobalMessageInitializer
类设置。 - 生命周期消息指定 ,在
App
级的AppViewsActivity
、AppViewsFragment
、AppViewsDialogFragment
重写messageCollector
的实现。
使用消息
kotlin
// 全局消息,不受Activiy、Fragment的生命周期影响。
MessageManager.showGlobalMessage("Global Message")
// 生命周期消息,受viewModel的Activiy、Fragment的生命周期影响。
viewModel.showMessage("Short Message")
viewModel.showMessage("Long Message", isShort = false)
StateView
StateView
为包含多个状态形式View 的接口,状态包括:Loading
、Error
、Empty
、Success
。
定制UI
定制UI :目前实现StateView
接口的类是DefaultStateView
。
- 小改 :
DefaultStateView
,默认 实现了Loading
、Error
、Empty
状态 的View
,可修改指定某个 来定制UI。 - 大改 :可通过修改
createAppStateView()
、createAppListStateView()
方法,返回StateView
接口的其它实现类。
原理
- 列表使用 :是使用BaseRecyclerViewAdapterHelper的
stateView
实现,底层原理是给RecyclerView的Adapter添加了一条Item布局 。Empty状态 ,是通过返回的列表数据是否为空 来判断的,详细使用看BaseRefreshLoadMoreHelper
。 - 普通使用 :是使用Base类
Activity
、Fragment
的getStateViewReplaceView()
方法实现,底层原理是给此方法返回的View替换显示为StateView 。Empty状态 ,目前未判断 ,如需修改请看BaseViewModel.requestAsyncBase()
扩展方法。
使用
- 列表使用 :已封装好 ,目前已支持SmartRefreshLayout、SwipeRefreshLayout两个控件,详细使用请看
SmartRefreshLoadMoreHelper
、SwipeRefreshLoadMoreHelper
。 - 普通使用 :需要使用
BaseViewModel.requestAsyncBase()
扩展方法定制 。- 配置 :
Activity
、Fragment
需要实现getStateViewReplaceView()
,此为StateView
要替换的View (用于实现替换显示StateView 时,隐藏此View ),可通过覆写 此方法来修改StateView的显示范围 ,如果不覆写默认 为此Activity
、Fragment
的root
根布局 。详细使用,请看通用级 的BaseViewsActivity
、BaseViewsFragment
等。 - 使用 :请求异步 的UI 每人的需求不同(如:
Error
状态,有人想要显示Error
重试布局 ,有人想要只需要消息提示 ),定制 详细使用,请看BaseViewModel.requestAsyncBase()
扩展方法。
- 配置 :
刷新、自动加载
原理
- 刷新 :是使用SmartRefreshLayout或SwipeRefreshLayout实现。
- 自动加载 :是使用BaseRecyclerViewAdapterHelper的
setTrailingLoadStateAdapter()
实现,底层原理是通过ConcatAdapter.addAdapter(adapter)
增加了尾Adapter ,详细使用请看BaseRefreshLoadMoreHelper
。
使用
- UI层 :
Activity
、Fragment
实现类,需要使用SmartRefreshLoadMoreHelper
或SwipeRefreshLoadMoreHelper
初始化,详细看ShopFragment。 - ViewModel层 :
ViewModel
实现类,需要实现RefreshRepositoryOwner
接口,其onRefreshRepository()
方法需要返回刷新/刷新加载仓库。 - Repository层 :
Repository
实现类,需要实现RefreshRepository
(仅刷新 )或RefreshLoadMoreRepository
(刷新加载)接口。Repository
实现类,需要继承PageKeyedMemoryRefreshLoadMoreRepository
(通过page加载 )或ItemKeyedMemoryRefreshLoadMoreRepository
(通过Item加载)类。
网络
一个公司,可能有多个网络规则 ,可创建实现BaseNetworkModel
接口的XXXBaseNetworkModel 类,来实现此规则定制 功能,后续只需使用此类即可。目前项目内有2个规则 案例,请看ApiOpenBaseNetworkModel
、AppBaseNetworkModel
类。
定义XXXBaseNetworkModel
以开源接口ApiOpen为例,其返回格式模板为:
json
{"code": 200, "message": "成功!", "result": "string"}
code
为200代表公司的规则成功 ,message
为提示的消息 ,result
为结果(类型任意),以此创建类如下:
kotlin
@Serializable
data class ApiOpenBaseNetworkModel<T>(val code: Int, val message: String, val result: T? = null) :
BaseNetworkModel<T> {
override fun isRuleSuccess() = code == 200
override fun code() = code
override fun message() = message
override fun data() = result
}
使用XXXBaseNetworkModel
kotlin
interface FakeNetworkLoginApi {
/**
* 登录
*/
@POST("api/login")
@FormUrlEncoded
suspend fun login(
@Field("account") account: String,
@Field("password") password: String,
): ApiOpenBaseNetworkModel<FakeNetworkUser>
}
login()
方法,其返回值为ApiOpenBaseNetworkModel
,其泛型为json模板的result值。调用如下:
kotlin
loginApi.login(account, password)
loginApi.login()
方法,其返回值为ApiOpenBaseNetworkModel
,这个数据不仅包含了json模块的全部信息 ,而且我们还得需要判断其是否公司规则成功。
可以使用以下转换方法,转为自己想要的结果。
转换XXXBaseNetworkModel
kotlin
loginApi.login(account, password).toRuleSuccessData()
toRuleSuccessData()
方法,将ApiOpenBaseNetworkModel
,转换为公司规则成功 ,并且返回其内部的result ,并且此返回值不为空。
目前支持的,所有转换方法,如下:
kotlin
/**
* 网络成功-规则成功-内部数据-不可空
*/
fun <T> BaseNetworkModel<T>.toRuleSuccessData(): T {
if (!isRuleSuccess()) {
throw RuleException(code(), message())
}
return data()!!
}
/**
* 网络成功-规则成功-内部数据-可空
*/
fun <T> BaseNetworkModel<T>.toRuleSuccessDataNullable(): T? {
if (!isRuleSuccess()) {
throw RuleException(code(), message())
}
return data()
}
/**
* 网络成功-规则成功-全部数据
*/
fun <T> BaseNetworkModel<T>.toRuleSuccess(): BaseNetworkModel<T> {
if (!isRuleSuccess()) {
throw RuleException(code(), message())
}
return this
}
// 网络成功-全部数据。则不需要调用此转换方法,直接返回即可。
可根据自己的需求,使用自己想要的转换方法,一般为toRuleSuccessData()
(网络成功-规则成功-内部数据-不可空)。
未来支持
- 支持Compose
- 优化是否Login相关逻辑
- 优化WebView相关逻辑
其它
三方库
自己
三方
参考
项目链接: architecture-android,欢迎大家点赞、收藏,以方便您后续查看。