写业务不用架构会怎么样?(二)
复杂度
软件的首要技术使命是“管理复杂度” —— 《代码大全》
因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。
架构的目的在于“将复杂度分层”
复杂度为什么要被分层?
若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。
举一个复杂度不分层的例子:
小李:“你会做什么菜?”
小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”
听了小明的回答,你还会和他做朋友吗?
小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。
小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。
这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。
再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:
物理层
数据链路成
网络层
传输层
应用层
其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。
这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。
有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。
引子
为了降低客户端领域开发的复杂度,架构也在不断地演进。从 MVC 到 MVP,再到 MVVM,目前已经发展到 MVI。
MVVM 仍然是当下最常用的 Android 端架构,曾经的榜一大哥 MVP 已日落西山。
下图是 Google Trends 关于 “android mvvm” 和 “android mvp”的对比图,剪刀差发生在2018年:
2018 年到底发生了什么使得架构改朝换代?
MVI 在架构设计上又做了哪些新的尝试?它是否能在将来取代 MVVM?
被如此多新名词弄得头晕脑胀的我,不由得倔强反问:“不是用架构又会怎么样?”
该系列以实战项目中的搜索场景为剧本,演绎了如何运用不同架构进行重构的过程,并逐个给出上述问题自己的理解。
搜索是 App 中常见的业务场景,该功能示意图如下:
业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史直接发起搜索跳转到结果页。
搜索页面框架设计如下:
搜索页用Activity
来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment
承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。
上一篇用无架构的方式实现了搜索条,这一篇接着用这种方式实现搜索历史界面,看看无架构会产生什么痛点。
搜索历史界面如下图所示:
它以一个 Fragment 的形式嵌入到搜索页 Activity 中:
class SearchHistoryFragment : Fragment() {
private lateinit var tvHistory: TextView
private lateinit var ivDelete: ImageView
private lateinit var flowSearchHistory: LineFeedLayout
private lateinit var ivSwitch: ImageView
private val contentView by lazy(LazyThreadSafetyMode.NONE) {
ConstraintLayout {
layout_width = match_parent
layout_height = match_parent
// 搜索历史
tvHistory = TextView {
layout_id = "tvHistory"
layout_width = wrap_content
layout_height = wrap_content
textSize = 16f
textColor = "#F0F2FB"
text = "搜索历史"
gravity = gravity_center
start_toStartOf = parent_id
top_toTopOf = parent_id
margin_start = 16
margin_top = 18
}
// 删除按钮
ivDelete = ImageView {
layout_id = "ivDelete"
layout_width = 20
layout_height = 20
scaleType = scale_fit_xy
end_toEndOf = parent_id
align_vertical_to = "tvHistory"
margin_end = 16
src = R.drawable.search_delete_history
onClick = {
showDeleteConfirmDialog()
}
}
// 搜索历史标签
flowSearchHistory = LineFeedLayout {
layout_id = "fSearchHistory"
layout_width = match_parent
layout_height = 70
top_toBottomOf = "tvHistory"
margin_horizontal = 16
margin_top = 14
verticalGap = 10.dp
horizontalGap = 8.dp
}
// 折叠开关
ivSwitch = ImageView {
layout_id = "ivSwitch"
layout_width = 30
layout_height = 30
scaleType = scale_fit_xy
imageDrawable = StateListDrawable().apply {
addState(intArrayOf(state_selected), ContextCompat.getDrawable(context, R.drawable.template_history_off))
addState(intArrayOf(state_unselected), ContextCompat.getDrawable(context, R.drawable.template_history_on))
}
top_toBottomOf = "fSearchHistory"
center_horizontal = true
margin_top = 20
isSelected = false
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return contentView
}
}
上述代码使用了 Kotlin 的 DSL 使得可以用声明式的语法动态的构建视图,避免了 XML 的解析并加载到内存,以及 findViewById() 遍历查找时间复杂度,性能略好,但缺点是无法预览。
关于 运用 Kotlin DSL 动态构建布局的详细讲解可以点击
这套构建布局的 DSL 源码可以在这里找到
其中的 LineFeedLayout 是历史标签的容器,一个横向铺开自动换行的自定义控件:
class LineFeedLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
var horizontalGap: Int = 0
var verticalGap: Int = 0
var onNewLine: ((Int) -> Unit)? = null
private var lines = 0
// 用挂起的方式获取行数
suspend fun getLines() = suspendCancellableCoroutine<Int> { continuation ->
post { continuation.resume(lines) }
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
measureChildren(widthMeasureSpec, heightMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
var height = 0
var remainWidth = width
lines = if (childCount > 0) 1 else 0
onNewLine?.invoke(lines)
// 遍历孩子逐个测量
(0 until childCount).map { getChildAt(it) }.forEach { child ->
val lp = child.layoutParams as? MarginLayoutParams
val appendWidth = child.measuredWidth + lp?.marginStart.orZero + lp?.marginEnd.orZero
if (isNewLine(appendWidth, remainWidth)) {
remainWidth = width - child.measuredWidth
height += (lp?.topMargin.orZero + lp?.bottomMargin.orZero + child.measuredHeight + verticalGap)
++lines
onNewLine?.invoke(lines)
} else {
remainWidth -= child.measuredWidth
if (height == 0) height =
(lp?.topMargin.orZero + lp?.bottomMargin.orZero + child.measuredHeight + verticalGap)
}
remainWidth -= (lp?.leftMargin.orZero + lp?.rightMargin.orZero + horizontalGap)
}
if (heightMode == MeasureSpec.EXACTLY) {
height = MeasureSpec.getSize(heightMeasureSpec)
}
// 待孩子测量完后,决定自己的宽高
setMeasuredDimension(width, height)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var left = 0
var top = 0
var lastBottom = 0
// 遍历孩子逐个布局
(0 until childCount).map { getChildAt(it) }.forEach { child ->
val lp = child.layoutParams as? MarginLayoutParams
val appendWidth = child.measuredWidth + lp?.marginStart.orZero + lp?.marginEnd.orZero
if (isNewLine(appendWidth, r - l - left)) {
left = -lp?.leftMargin.orZero
top = lastBottom
lastBottom = 0
}
val childLeft = left + lp?.leftMargin.orZero
val childTop = top + lp?.topMargin.orZero
child.layout(
childLeft,
childTop,
childLeft + child.measuredWidth,
childTop + child.measuredHeight
)
if (lastBottom == 0) lastBottom = child.bottom + lp?.bottomMargin.orZero + verticalGap
left += child.measuredWidth + lp?.leftMargin.orZero + lp?.rightMargin.orZero + horizontalGap
}
}
private fun isNewLine(usedWidth: Int, remainWidth: Int): Boolean = usedWidth > remainWidth
}
val Int?.orZero: Int
get() = this ?: 0
其实借助于 ConstraintLayout + Flow 的组合也能实现自动换行效果。但若想获取换行控件的行数则不是件容易的事。产品要求默认只展示两行历史,若超过了两行则显示展开按钮,该按钮的展示与否依赖行数。
遂自定义了一个容器控件,这样控件的换行对我们来说就不再是黑盒了。
关于该自定义控件源码的详细解析可以点击 。上述代码,在这篇文章的基础上,新增了一个属性 lines 和获取它的 suspend 方法:
private var lines = 0
suspend fun getLines() = suspendCancellableCoroutine<Int> { continuation ->
post { continuation.resume(lines) }
}
lines 表示子标签横向铺开后的行数,之所以要用 suspend 方法获取它,是因为行数的计算是发生在未来的,即得等到 View 树遍历完成后,lines 才被赋值。
界面间耦合的通信
产品要求:新的搜索词会展示在最前面,且最多展示11条历史(先进先出)
新增搜索词有两个入口,分别是搜索页及键盘上的搜索按钮。它们的点击事件都发生在搜索页 Activity 中,而搜索历史展示在历史页 Frgment 中,这是一个跨界面通信的场景:Activity 中的一个动作将改变 Fragment 中的展示。
Activity 和 Fragment 之间有诸多通信方式。最直接的方式莫过于“直接方法调用”,因为 Fragment 和 Activity 都能方便地拿到对方的引用,这样就能直接调用对方的方法。
为历史页 Fragment 新增公共方法addHistory()
:
// SearchHistoryFragment.kt
private val historys = mutableListOf<String>() // 所有历史列表
private var showAllHistory = false // 是否显示所有历史开关
fun addHistory(keyword: String) {
if(historys.contains(keyword)) { // 若已包含关键词,则置顶
historys.remove(keyword)
historys.add(0, keyword)
} else { // 若不包含关键词,则头插入
historys.add(0, keyword)
if (historys.size >= 12) historys.removeLast() // 历史尾删除,控制历史数量
}
// 根据历史列表重新构建历史标签
flowSearchHistory.apply {
removeAllViews()
historys.forEach { addView(getHistoryTagView(it)) }
}
// 显示搜索历史以及删除图标
tvHistory.visibility = visible
ivDelete.visibility = visible
lifecycleScope.launch {
// 以挂起方式获取历史标签行数
val lines = flowSearchHistory.getLines()
// 若历史标签超过2行,根据开关调整历史控件高度
if (lines > 2) {
flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = if (showAllHistory) wrap_content else 70.dp
}
}
// 若超过2行,则显示开关
ivSwitch.apply {
visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible
isSelected = showAllHistory
}
}
}
// 动态构建历史标签
private fun getHistoryTagView(tag: String) =
BTextView {
layout_id = tag
layout_width = wrap_content
layout_height = wrap_content
textSize = 12f
textColor = "#ffffff"
text = tag
gravity = gravity_center
padding_horizontal = 12
padding_vertical = 7
maxLines = 1
ellipsize = ellipsize_end
shape = shape {
corner_radius = 25
solid_color = "#2C2D3E"
}
}
然后在搜索页 Activity 的两个入口增加通信逻辑:
class TemplateSearchActivity : AppCompatActivity() {
private lateinit var etSearch: EditText
private lateinit var tvSearch: TextView
private fun initView() {
// 通知历史页新增关键词
tvSearch.onClick = { addHistory(etSearch.text.toString()) }
// 通知历史页新增关键词
etSearch.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch.text?.toString() ?: ""
if (input.isNotEmpty()) addHistory(input)
true
} else false
}
}
// 在 Activity 中获取 Fragment 对象并调用其方法
private fun addHistory(keyword: String) {
supportFragmentManager
.fragments[0]
.childFragmentManager
.findFragmentById(R.id.SearchHistoryFragment).addHistory(keyword)
}
}
产品需求:在点击清空历史时收起键盘
这种通信方式是耦合的,因为双方都持有“具体的对方”。假设另一个搜索业务场景的历史页长得和它不同,则新历史页无法和搜索页 Activity 合作。
更解耦的方式是广播,即 Activity 发送广播,Fragment 监听广播:
class TemplateSearchActivity : AppCompatActivity() {
private lateinit var etSearch: EditText
private lateinit var tvSearch: TextView
private fun initView() {
tvSearch.onClick = { addHistory(etSearch.text.toString()) }
etSearch.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch.text?.toString() ?: ""
if (input.isNotEmpty()) addHistory(input)
true
} else false
}
}
private fun addHistory(keyword: String) {
// 发广播
LocalBroadcastManager
.getInstance(context)
.sendBroadcast(
Intent("add-history").apply { putExtra("keyword", keyword) }
)
}
}
Fragment 监听广播:
class SearchHistoryFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
context?.let {
LocalBroadcastManager
.getInstance(it)
.registerReceiver(HistoryReceiver(), IntentFilter("add-history"))
}
}
inner class HistoryReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "add-history") {
addHistory(intent.getStringExtra("keyword").orEmpty())
}
}
}
}
若更换历史页 Fragment,只需要监听广播,就能和搜索页 Activity 的业务逻辑衔接上。
当前业务场景中,必须在 Fragment.onCreate() 注册广播,且在 Fragment.onDestroy() 注销。因为点击联想词也会产生搜索历史,而此时历史页被联想页覆盖,若在 Fragment.onPause() 注销的话,则无法收到联想页发来的广播(广播不是粘性的,即老值不会分发给新观察者)。
但 androidx.localbroadcastmanager 已经在 1.1.0-alpha01 版本被废弃了。若还想用广播的方式完成界面间通信,只能使用 EventBus 了,对于当前场景来说,大可不必。
若能有一个媒介,Activity 和 Fragment 都能轻松地获取它,它就能承载跨界面通信的功能。
这个媒介在 MVP 架构中是 P,即 Presenter。它会在 Activity 中被构建,Fragment 通过获取 Activity 的实例就能访问到它。(该系列后续会展开实现细节)
若采用 MVVM 或 MVI 架构,Activity 和其子 Fragment 之间的通信就可以通过 ViewModel 以更轻松的方式实现。(该系列后续会展开实现细节)
在 Activity 中存取数据
产品需求:进入搜索页时,展示历史搜索,默认展示两行历史,超过两行的内容可进行折叠/展开
得把搜索历史持久化,它是一个字符串列表,使用 MMKV 就能满足要求。关于 MMKV 的详细介绍可以点击
SearchHistoryFragment.addHistory() 是历史发生变更的点,遂在其中增加持久化逻辑:
// SearchHistoryFragment.kt
private var historys = mutableListOf<String>()
fun addHistory(keyword: String) {
if (historys.contains(keyword)) {
historys.remove(keyword)
historys.add(0, keyword)
} else {
historys.add(0, keyword)
if (historys.size >= 12) historys.removeLast()
}
// 当历史发生变更时,持久化它
val bundle = Bundle().apply { putStringArray("historys", historys.toTypedArray()) }
MMKV.mmkvWithID("template-search")?.encode("search-history", bundle)
flowSearchHistory.apply {
removeAllViews()
historys.forEach { addView(getHistoryTagView(it)) }
}
tvHistory.visibility = visible
ivDelete.visibility = visible
lifecycleScope.launch {
val lines = flowSearchHistory.getLines()
if (lines > HISTORY_FOLDED_MAX_LINES) {
flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = if (showAllHistory) wrap_content else 70.dp
}
}
ivSwitch.apply {
visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible
isSelected = showAllHistory
}
}
}
持久化的方式是将历史列表存储在 Bundle 中,然后再将 Bundle 存储在 MMKV 中。之所以增加了一层 Bundle 是为了保持历史搜索的顺序。
还得在页面启动时,从 MMKV 读取内容并以此重绘界面:
// SearchHistoryFragment.kt
private var historys = mutableListOf<String>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 从 MMKV 读搜索历史
val historyBundle = MMKV.mmkvWithID("template-search")?.decodeParcelable("search-history", Bundle::class.java)
historyBundle?.let {
val historys = it.getStringArray("historys") ?: emptyArray()
if (historys.isNotEmpty()) {
// 将搜索历史存储在 Fragment 的成员 historys 中
this.historys = historys.toMutableList()
// 重绘界面
flowSearchHistory.apply {
removeAllViews()
historys.forEach { addView(getHistoryTagView(it)) }
}
tvHistory.visibility = visible
ivDelete.visibility = visible
lifecycleScope.launch {
val lines = flowSearchHistory.getLines()
if (lines > HISTORY_FOLDED_MAX_LINES) {
flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = if (showAllHistory) wrap_content else 70.dp
}
}
ivSwitch.apply {
visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible
isSelected = showAllHistory
}
}
}
}
}
这段代码写完,Activity 和一个新的类发生了耦合:MMKV
。
调用 MMKV 的 api 实现数据存取,这属于数据存取的细节。
如果大量的细节在同一层次被铺开,代码就显得啰嗦,增加的理解成本。项目中超过 1000 行的 Activity 就是这样被堆砌出来的。在编辑器打开这些上帝类得卡半天。
细节通常容易发生变化。拿持久化数据举例,从刚开始的 SharedPreference,到性能更好的 MMKV,再到更符合 MAD 的 DataStore。(MAD = Modern Android Development)。
发生细节的变更时,应该将影响面控制到最小,以尽可能地实现“更安全地变更”。当所有的细节都在 Activity 中被铺开时,一个小细节变更的影响面就被放大。比如你修改了 Activity 中持久化数据的细节,而同事修改了 Activity 界面展示的细节,很不巧合代码时,发生冲突了,然后。。。。。(你一定知道我省略了什么,因代码冲突引入的bug还少吗?)
这样安排代码也违反了单一职责原则,即类应该尽量单纯,最好只做一件事情。
若使用合适的架构,这耦合是可以避免的。(实现细节会在后续文章展开)
尘不归尘,土不归土
产品需求:当历史超过两行时,会展示折叠开关,点击它可进行展开或折叠:
// SearchHistoryFragment.kt
private var showAllHistory = false
ivSwitch.onClick = {
isSelected = isSelected.not() // 变换开关状态
showAllHistory = isSelected // 将开关状态记录在 Fragment 的成员变量中
// 以挂起方式获取控件行数
lifecycleScope.launch {
val lines = flowSearchHistory.getLines()
// 根据行数重绘控件高度(70.dp 表示两行高度,wrap_content 表示完全铺开)
if (lines > HISTORY_FOLDED_MAX_LINES) {
flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = if (showAllHistory) wrap_content else 70.dp
}
}
// 根据行数判断是否展示开关
ivSwitch.apply {
visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible
isSelected = showAllHistory
}
}
}
产品需求:删除历史记录弹窗确认。
// SearchHistoryFragment.kt
ivDelete.onClick = { showDeleteConfirmDialog() }
private fun showDeleteConfirmDialog() {
DialogHelper.createBDialog(requireContext())
.setMessage("确认删除?")
.setPositiveButton("确认") {
historys.clear()
tvHistory.visibility = gone
ivDelete.visibility = gone
ivSwitch.visibility = gone
flowSearchHistory.removeAllViews()
}
.setNegativeButton("取消") { }
.show()
}
写完这段代码之后,控制折叠控件展示的逻辑已经分散在 4 个地方了:1. 新增关键词时 2. 启动历史页读取持久化历史时 3. 点击历史开关时 4. 清空历史时
当产品希望默认展示 1 行历史搜索时,需要改 3 个地方。
一个简单改善方法是将历史控件的重绘逻辑抽象为一个方法,然后分别在 3 个地方调用它。但其实这个抽象没这么好做,因为每一处的刷新逻辑不完全一样:
// SearchHistoryFragment.kt
private fun updateFlowHeightAndSwitch() {
lifecycleScope.launch {
val lines = flowSearchHistory.getLines()
if (lines > HISTORY_FOLDED_MAX_LINES) {
flowSearchHistory.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = if (showAllHistory) wrap_content else 70.dp
}
}
ivSwitch.apply {
visibility = if (lines <= HISTORY_FOLDED_MAX_LINES || historys.isEmpty()) gone else visible
isSelected = showAllHistory
}
}
}
这是根据行数刷新历史控件高度以及展示折叠开关的逻辑。这段逻辑会在折叠历史时调用,并且还会在新增历史,以及启动历史页时调用,不过后面两处调用还得带上新的逻辑:
// SearchHistoryFragment.kt
private fun showFlow() {
flowSearchHistory.apply {
removeAllViews()
this@SearchHistoryFragment.historys.forEach { addView(getHistoryTagView(it))}
}
tvHistory.visibility = visible
ivDelete.visibility = visible
updateFlowHeightAndSwitch()
}
即使已经抽象出了两个方法,尽可能地让刷新历史控件的逻辑内聚,但依然有一段零散的逻辑在清空历史的地方:
flowSearchHistory.removeAllViews()
这段代码犯了和上一篇同样的错误,即支离破碎的刷新逻辑。但因为这次业务逻辑更复杂,所以即使尽了最大努力,依然无法做到将历史控件的刷新逻辑内聚到一个方法内部。
这是因为“从业务视角做视图构建的抽象”,代码分别定义了新增历史、启动历史页、历史折叠、清空历史时的视图构建逻辑。为了代码的复用,抽象了一些“奇怪”的构建方法,从它们名字就可以看出这点。
那些介于同一变量多个引用点之间的代码称为“攻击窗口”(window of vulnerability)。可能有新的代码加到这些窗口中,不当地修改了这个变量。所以应该尽量缩小变量的作用域,把它的引用点尽可能集中在一起是一个很好的做法。——《代码大全》
我看到上面描述后的感想是:“除非万不得已,不然就不要声明成员变量”。成员变量的作用域相较于局部变量要扩大了很多,因为所有的成员方法都可以无障碍的访问它,若类还公开了对成员变量的修改方法,就等于又给变量打开了一个攻击窗口。
Android 中将布局的构建写在 xml 中,然后在 Activity 中获取 View 的引用,天然地造就了 Activity 持有了很多 View 的引用。若对 View 的更新又不内聚在一个方法中,则 View 天然就拥有了很多攻击窗口,一不小心就会出现意料之外的界面状态。
构建视图及对视图刷新逻辑本不该分开,理想状态下 View 应该以观察者的身份观察一个 Model,它的构建和刷新都依赖于 Model 的变更。这样可以避免界面状态的不一致。先进的 UI 框架都遵循了这个思想,比如 Flutter,Compose。
“视图应该长什么样?”和“哪些事件会触发它重绘?”是两个独立的变化源。
比如美术希望更换每个搜索历史标签的背景色,再比如产品希望按搜索总次数降序排列历史。
这是两个可以分离的关注点,它们应该被安排在不同的类中。这样做有诸多好处:
更安全地变更:其中一个的变化不会破坏另一个原有的功能。
更低的复杂度:视图构建逻辑和业务逻辑在两个层次被铺开,各自变得更纯粹,理解难度降低。
更大的复用性:当视图和业务逻辑分开时,各自都增加了被复用的可能性,因为它们更纯粹了。
关于如何利用架构实现关注点分离,会在后续的文章中展开。