首页 科技创业正文

我是怎么把业务代码越写越复杂的?

admin 科技创业 2020-09-14 02:47:13 2 0

原标题:我是怎么把业务代码越写越复杂的?

本文作者

作者:唐子玄

稳住今天是周末,给大家推一篇值得思考和品味的文章。

本文以一个真实项目的业务场景为载体,描述了经历一次次重构后,代码变得越来越复杂(you ya)的过程。

本篇 Demo 的业务场景是:从服务器拉取新闻并在列表展示。

1

GodActivity

刚接触 Android 时,我是这样写业务代码的(省略了和主题无关的 Adapter 和 Api 细节):

classGodActivity: AppCompatActivity{

privatevarrvNews: RecyclerView? = null

privatevarnewsAdapter = NewsAdapter

// 用 retrofit 拉取数据

privatevalretrofit = Retrofit.Builder

.baseUrl( "https://api.apiopen.top")

.addConverterFactory(MoshiConverterFactory.create)

.client(OkHttpClient.Builder.build)

.build

privatevalnewsApi = retrofit.create(NewsApi:: class. java)

// 数据库操作异步执行器

privatevardbExecutor = Executors.newSingleThreadExecutor

overridefunonCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

setContentView(R.layout.news_activity)

展开全文

initView

fetchNews

}

privatefuninitView{

rvNews = findViewById(R.id.rvNews)

rvNews?.layoutManager = LinearLayoutManager( this)

}

// 列表展示新闻

privatefunshowNews(news : List< News>) {

newsAdapter.news = news

rvNews?.adapter = newsAdapter

}

// 获取新闻

privatefunfetchNews{

// 1. 先从数据库读老新闻以快速展示

queryNews.let{ showNews(it) }

// 2. 再从网络拉新闻替换老新闻

newsApi.fetchNews(

mapOf( "page"to "1", "count"to "4")

).enqueue( object: Callback<NewsBean> {

overridefunonFailure(call: Call< NewsBean>, t: Throwable) {

Toast.makeText( this@GodActivity, "network error", Toast.LENGTH_SHORT).show

}

overridefunonResponse(call: Call< NewsBean>, response: Response< NewsBean>) {

response.body?.result?.let {

// 3. 展示新新闻

showNews(it)

// 4. 将新闻入库

dbExecutor.submit { insertNews(it) }

}

}

})

}

// 从数据库读老新闻(伪代码)

privatefunqueryNews: List<News> {

valdbHelper = NewsDbHelper( this, ...)

valdb = dbHelper.getReadableDatabase

valcursor = db.query(...)

varnewsList = mutableListOf<News>

while(cursor.moveToNext) {

...

newsList.add(news)

}

db.close

returnnewsList

}

// 将新闻写入数据库(伪代码)

privatefuninsertNews(news : List< News>) {

valdbHelper = NewsDbHelper( this, ...)

valdb = dbHelper.getWriteableDatabase

news.foreach {

valcv = ContentValues.apply { ... }

db.insert(cv)

}

db.close

}

}

毕竟当时的关注点是实现功能,首要解决的问题是“如何绘制布局”、“如何操纵数据库”、“如何请求并解析网络数据”、“如何将数据填充在列表中”。待这些问题解决后,也没时间思考架构,所以就产生了上面的God Activity。Activity 管的太多了!Activity 知道太多细节:

异步细节

访问数据库细节

访问网络细节

1. 如果大量 “细节” 在同一个层次被铺开,就显得啰嗦,增加理解成本。

拿说话打个比方:

你问 “晚饭吃了啥?”

“我用勺子一口一口地吃了鸡生下的蛋和番茄再加上油一起炒的菜。”

听了这样地回答,你还会和他做朋友吗?其实你并不关心他吃的工具、吃的速度、食材的来源,以及烹饪方式。

2. 与 “细节” 相对的是 “抽象”,在编程中 “细节” 易变,而 “抽象” 相对稳定。

比如 “异步” 在 Android 中就有好几种实现方式:线程池、HandlerThread、协程、IntentService、RxJava。

3. “细节” 增加耦合。

GodActivity 引入了大量本和它无关的类:Retrofit、Executors、ContentValues、Cursor、SQLiteDatabase、Response、OkHttpClient。Activity 本应该只和界面展示有关。

2

将界面展示和获取数据分离

既然 Activity 知道太多,那就让Presenter来为它分担:

// 构造 Presenter 时传入 view 层接口 NewsView

classNewsPresenter( varnewsView: NewsView): NewsBusiness {

privatevalretrofit = Retrofit.Builder

.baseUrl( "https://api.apiopen.top")

.addConverterFactory(MoshiConverterFactory.create)

.client(OkHttpClient.Builder.build)

.build

privatevalnewsApi = retrofit.create(NewsApi:: class. java)

privatevarexecutor = Executors.newSingleThreadExecutor

overridefunfetchNews{

// 将数据库新闻通过 view 层接口通知 Activity

queryNews.let{ newsView.showNews(it) }

newsApi.fetchNews(

mapOf( "page"to "1", "count"to "4")

).enqueue( object: Callback<NewsBean> {

overridefunonFailure(call: Call< NewsBean>, t: Throwable) {

newsView.showNews( null)

}

overridefunonResponse(call: Call< NewsBean>, response: Response< NewsBean>) {

response.body?.result?.let {

// 将网络新闻通过 view 层接口通知 Activity

newsView.showNews(it)

dbExecutor.submit { insertNews(it) }

}

}

})

}

// 从数据库读老新闻(伪代码)

privatefunqueryNews: List<News> {

// 通过 view 层接口获取 context 构造 dbHelper

valdbHelper = NewsDbHelper(newsView.newsContext, ...)

valdb = dbHelper.getReadableDatabase

valcursor = db.query(...)

varnewsList = mutableListOf<News>

while(cursor.moveToNext) {

...

newsList.add(news)

}

db.close

returnnewsList

}

// 将新闻写入数据库(伪代码)

privatefuninsertNews(news : List< News>) {

valdbHelper = NewsDbHelper(newsView.newsContext, ...)

valdb = dbHelper.getWriteableDatabase

news.foreach {

valcv = ContentValues.apply { ... }

db.insert(cv)

}

db.close

}

}

无非就是复制 + 粘贴,把 GodActivity 中的“异步”、“访问数据库”、“访问网络”、放到了一个新的Presenter类中。

这样 Activity 就变简单了:

classRetrofitActivity: AppCompatActivity, NewsView {

// 在界面中直接构造业务接口实例

privatevalnewsBusiness = NewsPresenter( this)

privatevarrvNews: RecyclerView? = null

privatevarnewsAdapter = NewsAdapter

overridefunonCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

setContentView(R.layout.news_activity)

initView

// 触发业务逻辑

newsBusiness.fetchNews

}

privatefuninitView{

rvNews = findViewById(R.id.rvNews)

rvNews?.layoutManager = LinearLayoutManager( this)

}

// 实现 View 层接口以更新界面

overridefunshowNews(news: List< News>?) {

newsAdapter.news = news

rvNews?.adapter = newsAdapter

}

overridevalnewsContext: Context

get= this

}

Presenter的引入还增加了通信成本:

interfaceNewsBusiness{

funfetchNews

}

这是MVP模型中的业务接口,描述的是业务动作。它由Presenter实现,而界面类持有它以触发业务逻辑。

interfaceNewsView{

// 将新闻传递给界面

funshowNews(news: List< News>?)

// 获取界面上下文

abstractvalnewsContext:Context

}

在MVP模型中,这称为View 层接口。Presenter持有它以触发界面更新,而界面类实现它以绘制界面。

这两个接口的引入,意义非凡:

接口把 做什么(抽象) 和 怎么做(细节) 分离。这个特性使得 关注点分离 成为可能:接口持有者只关心 做什么,而 怎么做 留给接口实现者关心。

Activity 持有业务接口,这使得它不需要关心业务逻辑的实现细节。Activity 实现View 层接口,界面展示细节都内聚在 Activity 类中,使其成为MVP中的V。

Presenter 持有View 层接口,这使得它不需要关心界面展示细节。Presenter 实现业务接口,业务逻辑的实现细节都内聚在 Presenter 类中,使其成为MVP中的P。

这样做最大的好处是降低代码理解成本,因为不同细节不再是在同一层次被铺开,而是被分层了。阅读代码时,“浅尝辄止”或“不求甚解”的阅读方式极大的提高了效率。

这样做还能缩小变更成本,业务需求发生变更时,只有Presenter类需要改动。界面调整时,只有V层需要改动。同理,排查问题的范围也被缩小。

这样还方便了自测,如果想测试各种临界数据产生时界面的表现,则可以实现一个PresenterForTest。如果想覆盖业务逻辑的各种条件分支,则可以方便地给Presenter写单元测试(和界面隔离后,Presenter 是纯 Kotlin 的,不含有任何 Android 代码)。

但NewsPresenter也不单纯!它除了包含业务逻辑,还包含了访问数据的细节,应该用同样的思路,抽象出一个访问数据的接口,让Presenter持有,这就是MVP中的M。它的实现方式可以参考下一节的Repository。

3

数据视图互绑 + 长生命周期数据

即使将访问数据的细节剥离出Presenter,它依然不单纯。因为它持有View 层接口,这就要求Presenter需了解 该把哪个数据传递给哪个接口方法,这就是 数据绑定,它在构建视图时就已经确定(无需等到数据返回),所以这个细节可以从业务层剥离,归并到视图层。

Presenter的实例被 Activity 持有,所以它的生命周期和 Activiy 同步,即业务数据和界面同生命周期。在某些场景下,这是一个缺点,比如横竖屏切换。此时,如果数据的生命周期不依赖界面,就可以免去重新获取数据的成本。这势必 需要一个生命周期更长的对象(ViewModel)持有数据。

生命周期更长的 ViewModel

上一节的例子中,构建Presenter是直接在Activity中new,而构建ViewModel是通过ViewModelProvider.get:

publicclassViewModelProvider{

// ViewModel 实例商店

privatefinalViewModelStore mViewModelStore;

public<T extends ViewModel> T get( @NonNullString key, @NonNullClass<T> modelClass) {

// 从商店获取 ViewModel实例

ViewModel viewModel = mViewModelStore. get(key);

if(modelClass.isInstance(viewModel)) {

return(T) viewModel;

} else{

...

}

// 若商店无 ViewModel 实例 则通过 Factory 构建

if(mFactory instanceof KeyedFactory) {

viewModel = ((KeyedFactory) (mFactory)).create(key, modelClass);

} else{

viewModel = (mFactory).create(modelClass);

}

// 将 ViewModel 实例存入商店

mViewModelStore.put(key, viewModel);

return(T) viewModel;

}

}

ViewModel实例通过ViewModelStore获取:

// ViewModel 实例商店

public classViewModelStore{

// 存储 ViewModel 实例的 Map

private finalHashMap< String, ViewModel> mMap = newHashMap<>;

// 存

finalvoidput( Stringkey, ViewModel viewModel) {

ViewModel oldViewModel = mMap.put(key, viewModel);

if(oldViewModel != null) {

oldViewModel.onCleared;

}

}

// 取

finalViewModel get( Stringkey) {

returnmMap. get(key);

}

...

}

ViewModelStore将ViewModel实例存储在HashMap中。

而ViewModelStore通过ViewModelStoreOwner获取:

publicclassViewModelProvider{

// ViewModel 实例商店

privatefinalViewModelStore mViewModelStore;

// 构造 ViewModelProvider 时需传入 ViewModelStoreOwner 实例

publicViewModelProvider( @NonNullViewModelStoreOwner owner, @NonNullFactory factory) {

// 通过 ViewModelStoreOwner 获取 ViewModelStore

this(owner.getViewModelStore, factory);

}

publicViewModelProvider( @NonNullViewModelStore store, @NonNullFactory factory) {

mFactory = factory;

mViewModelStore = store;

}

}

那ViewModelStoreOwner实例又存储在哪?

// Activity 基类实现了 ViewModelStoreOwner 接口

publicclassComponentActivityextendsandroidx. core. app. ComponentActivityimplements

LifecycleOwner,

ViewModelStoreOwner,

SavedStateRegistryOwner,

OnBackPressedDispatcherOwner{

// Activity 持有 ViewModelStore 实例

privateViewModelStore mViewModelStore;

publicViewModelStore getViewModelStore{

if(mViewModelStore == null) {

// 获取配置无关实例

NonConfigurationInstances nc =(NonConfigurationInstances) getLastNonConfigurationInstance;

if(nc != null) {

// 从配置无关实例中恢复 ViewModel商店

mViewModelStore = nc.viewModelStore;

}

if(mViewModelStore == null) {

mViewModelStore = newViewModelStore;

}

}

returnmViewModelStore;

}

// 静态的配置无关实例

staticfinalclassNonConfigurationInstances{

// 持有 ViewModel商店实例

ViewModelStore viewModelStore;

...

}

}

Activity就是ViewModelStoreOwner实例,且持有ViewModelStore实例,该实例还会被保存在一个静态类中,所以ViewModel生命周期比Activity更长。这样 ViewModel 中存放的业务数据就可以在Activity销毁重建时被复用。

数据绑定

MVVM中Activity 属于V层,布局构建以及数据绑定都在这层完成:

classMvvmActivity: AppCompatActivity{

privatevarrvNews: RecyclerView? = null

privatevarnewsAdapter = NewsAdapter

// 构建布局

privatevalrootView bylazy {

ConstraintLayout {

TextView {

layout_id = "tvTitle"

layout_width = wrap_content

layout_height = wrap_content

textSize = 25f

padding_start = 20

padding_end = 20

center_horizontal = true

text = "News"

top_toTopOf = parent_id

}

rvNews = RecyclerView {

layout_id = "rvNews"

layout_width = match_parent

layout_height = wrap_content

top_toBottomOf = "tvTitle"

margin_top = 10

center_horizontal = true

}

}

}

// 构建 ViewModel 实例

privatevalnewsViewModel bylazy {

// 构造 ViewModelProvider 实例, 通过其 get 获得 ViewModel 实例

ViewModelProvider( this, NewsFactory(applicationContext)). get(NewsViewModel:: class. java) }

overridefunonCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)

setContentView(rootView)

initView

bindData

}

// 将数据绑定到视图

privatefunbindData{

newsViewModel.newsLiveData.observe( this, Observer {

newsAdapter.news = it

rvNews?.adapter = newsAdapter

})

}

privatefuninitView{

rvNews?.layoutManager = LinearLayoutManager( this)

}

}

Android性能优化 | 把构建布局用时缩短 20 倍

它省去了原先V层( Activity + xml )中的xml。

代码中的数据绑定是通过观察ViewModel中的LiveData实现的。这不是数据绑定的完全体,所以还需手动地观察observe数据变化(只有当引入data-binding包后,才能把视图和控件的绑定都静态化到 xml 中)。

但至少它让ViewModel无需主动推数据了:

在 MVP 模式中,Presenter持有View 层接口并主动向界面推数据。

MVVM模式中,ViewModel不再持有View 层接口,也不主动给界面推数据,而是界面被动地观察数据变化。

这使得ViewModel只需持有数据并根据业务逻辑更新之即可:

// 数据访问接口在构造函数中注入

classNewsViewModel( varnewsRepository: NewsRepository) : ViewModel {

// 持有业务数据

valnewsLiveData bylazy { newsRepository.fetchNewsLiveData }

}

// 定义构造 ViewModel 方法

classNewsFactory(context: Context) : ViewModelProvider.Factory {

// 构造 数据访问接口实例

privatevalnewsRepository = NewsRepositoryImpl(context)

overridefun<T : ViewModel?>create(modelClass: Class< T>) : T {

// 将数据接口访问实例注入 ViewModel

returnNewsViewModel(newsRepository) asT

}

}

// 然后就可以在 Activity 中这样构造 ViewModel 了

classMvvmActivity: AppCompatActivity{

// 构建 ViewModel 实例

privatevalnewsViewModel bylazy {

ViewModelProvider( this, NewsFactory(applicationContext)). get(NewsViewModel:: class. java) }

}

ViewModel只关心业务逻辑和数据,不关心获取数据的细节,所以它们都被数据访问接口隐藏了。

Demo 业务场景中,ViewModel 只有一行代码,那它还有存在的价值吗?

有!即使在业务逻辑如此简单的场景下还是有!因为ViewModel生命周期比 Activity 长,其持有的数据可以在 Activity 销毁重建时复用。

真实项目中的业务逻辑复杂度远高于 Demo,应该将业务逻辑的细节隐藏在ViewModel中,让界面类无感知。比如 “将服务器返回的时间戳转化成年月日” 就应该写在ViewModel中。

业务数据访问接口

// 业务数据访问接口

interfaceNewsRepository{

// 拉取新闻并以 LiveData 方式返回

funfetchNewsLiveData:LiveData<List<News>?>

}

// 实现访问网络和数据库的细节

classNewsRepositoryImpl(context: Context) : NewsRepository {

// 使用 Retrofit 构建请求访问网络

privatevalretrofit = Retrofit.Builder

.baseUrl( "https://api.apiopen.top")

.addConverterFactory(MoshiConverterFactory.create)

// 将返回数据组织成 LiveData

.addCallAdapterFactory(LiveDataCallAdapterFactory)

.client(OkHttpClient.Builder.build)

.build

privatevalnewsApi = retrofit.create(NewsApi:: class. java)

privatevarexecutor = Executors.newSingleThreadExecutor

// 使用 room 访问数据库

privatevarnewsDatabase = NewsDatabase.getInstance(context)

privatevarnewsDao = newsDatabase.newsDao

privatevarnewsLiveData = MediatorLiveData<List<News>>

overridefunfetchNewsLiveData: LiveData<List<News>?> {

// 从数据库获取新闻

vallocalNews = newsDao.queryNews

// 从网络获取新闻

valremoteNews = newsApi.fetchNewsLiveData(

mapOf( "page"to "1", "count"to "4")

).let {

Transformations.map(it) { response: ApiResponse<NewsBean>? ->

when(response) {

isApiSuccessResponse -> {

valnews = response.body.result

news?.let {

// 将网络新闻入库

executor.submit { newsDao.insertAll(it) }

}

news

}

else-> null

}

}

}

// 将数据库和网络响应的 LiveData 合并

newsLiveData.addSource(localNews) {

newsLiveData.value = it

}

newsLiveData.addSource(remoteNews) {

newsLiveData.value = it

}

returnnewsLiveData

}

}

这就是MVVM中的M,它定义了如何获取数据的细节。

Demo 中 数据库和网络都返回 LiveData 形式的数据,这样合并两个数据源只需要一个MediatorLiveData。所以使用了 Room 来访问数据库。并且定义了LiveDataCallAdapterFactory用于将 Retrofit 返回结果也转化成 LiveData。(其源码可以在这里找到)

https://github.com/android/architecture-components-samples/blob/master/GithubBrowserSample/app/src/main/java/com/android/example/github/util/LiveDataCallAdapterFactory.kt

这里也存在耦合:Repository需要了解 Retrofit 和 Room 的使用细节。

当访问数据库和网络的细节越来越复杂,甚至又加入内存缓存时,再增加一层抽象,分别把访问内存、数据库、和网络的细节都隐藏起来,也是常见的做法。这样Repository中的逻辑就变成:“运用什么策略将内存、数据库和网络的数据进行组合并返回给业务层”。

4

Clean Architecture

经多次重构,代码结构不断衍化,最终引入了ViewModel和Repository。层次变多了,表面上看是越来越复杂了,但其实理解成本越来越低。因为 所有复杂的细节并不是在同一层次被展开。

最后用 Clean architecture 再审视一下这套架构:

Entities

它是业务实体对象,对于 Demo 来说 Entities 就是新闻实体类News。

Use Cases

它是业务逻辑,Entities 是名词,Use Cases 就是用它造句。对于 Demo 来说 Use Cases 就是 “展示新闻列表” 在 Clean Architecture 中每一个业务逻辑都会被抽象成一个 UseCase 类,它被Presenters持有,详情可以去这里了解

Repository

它是业务数据访问接口,抽象地描述获取和存储 Entities。和 Demo 中的 Repository 一模一样,但在 Clean Architecture 中,它由 UseCase 持有。

Presenters

它和MVP模型中 Presenter 几乎一样,由它触发业务逻辑,并把数据传递给界面。唯一的不同是,它持有 UseCase。

DB & API

它是抽象业务数据访问接口的实现,和 Demo 中的NewsRepositoryImpl一模一样。

UI

它是构建布局的细节,就像 Demo 中的 Activity。

Device

它是和设备相关的细节,DB 和 UI 的实现细节也和设备有关,这里的 Device是指除了数据和界面之外的和设备相关的细节,比如如何在通知栏展示通知。

依赖方向

洋葱圈的内三层都是 抽象,而只有最外层才包含实现 细节(和 Android 平台相关的实现细节。比如访问数据库的细节、绘制界面的细节、通知栏提醒消息的细节、播放音频的细节)

洋葱圈向内的箭头意思是:外层知道相邻内层的存在,而内层不知道外层的存在。即外层依赖内层,内层不依赖外层。也就说应该尽可能把业务逻辑抽象地实现,业务逻辑只需要关心做什么,而不该关心怎么做。这样的代码对扩展友好,当实现细节变化时,业务逻辑不需要变。返回,科技大学

评论