MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(二)

简介: MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(二)

复杂度


Android 架构演进系列是围绕着复杂度向前推进的。


软件的首要技术使命是“管理复杂度” —— 《代码大全》


因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。


架构的目的在于“将复杂度分层”


复杂度为什么要被分层?


若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。


举一个复杂度不分层的例子:


小李:“你会做什么菜?”


小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”


听了小明的回答,你还会和他做朋友吗?


小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。


小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。


这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。


再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:


  1. 物理层


  1. 数据链路成


  1. 网络层


  1. 传输层


  1. 应用层


其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。


这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。


有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。


引子


该系列的前三篇结合“搜索”这个业务场景,讲述了不使用架构写业务代码会产生的痛点:


  1. 低内聚高耦合的绘制:控件的绘制逻辑散落在各处,散落在各种 Activity 的子程序中(子程序间相互耦合),分散在现在和将来的逻辑中。这样的设计增加了界面刷新的复杂度,导致代码难以理解、容易改出 Bug、难排查问题、无法复用。


  1. 耦合的非粘性通信:Activity 和 Fragment 通过获取对方引用并互调方法的方式完成通信。这种通信方式使得 Fragment 和 Activity 耦合,从而降低了界面的复用度。并且没有一种内建的机制来轻松的实现粘性通信。


  1. 上帝类:所有细节都在界面被铺开。比如数据存取,网络访问这些和界面无关的细节都在 Activity 被铺开。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大。


  1. 界面 & 业务:界面展示和业务逻辑耦合在一起。“界面该长什么样?”和“哪些事件会触发界面重绘?”这两个独立的变化源没有做到关注点分离。导致 Activity 代码不单纯、高耦合、代码量大、复杂度高、变化源不单一、改动影响范围大、易改出 Bug、界面和业务无法单独被复用。


详细分析过程可以点击下面的链接:


  1. 写业务不用架构会怎么样?(一)


  1. 写业务不用架构会怎么样?(二)


  1. 写业务不用架构会怎么样?(三)


这一篇试着引入 MVP 架构(Model-View-Presenter)进行重构,看能不能解决这些痛点。


在重构之前,先介绍下搜索的业务场景,该功能示意图如下:


image.png


https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/84508632b7bc488f9d5ae0386090e567~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?


业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史可直接发起搜索跳转到结果页。


将搜索业务场景的界面做了如下设计:


image.png


搜索页用Activity来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。


Fragment 之间的切换采用 Jetpack 的Navigation。关于 Navigation 详细的介绍可以点击关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门  |  Android 开发者  |  Android Developers


业务和访问数据分离


上一篇使用 MVP 重构了搜索条,引出了 MVP 中的一些基本概念,比如业务接口,View 层接口,双向通信。


这一篇开始对搜索联想进行重构,它的交互如下:


image.png


https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3fce346155ff45f39dc251112741e4c0~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?


输入关键词的同时请求网络拉取联想词并展示为列表,点击联想词跳转到搜索结果页。再次点击输入框时,对当前词触发联想。


新增了一个业务场景,就在 SearchPresenter 中新增接口:


interface SearchPresenter {
    fun init()
    fun backPress()
    fun touchSearchBar(text: String, isUserInput: Boolean)
    fun clearKeyword()
    fun search(keyword: String, from: SearchFrom)
    fun inputKeyword(input: Input)
    // 拉取联想词
    suspend fun fetchHint(keyword: String): List<String>
    // 展示联想页
    fun showHintPage(hints: List<SearchHint>)
}


若每次输入框内容发生变化都请求网络则浪费流量,所以得做限制。使用响应式编程使得问题的求解变得简单,详细讲解可以点击写业务不用架构会怎么样?(三)


现套用这个解决方案,并将它和 Presenter 结合使用:


// TemplateSearchActivity.kt
etSearch.textChangeFlow { isUserInput, char -> Input(isUserInput, char.toString()) }
    // 键入内容后高亮搜索按钮并展示 X
    .onEach { searchPresenter.inputKeyword(it) }
    .filter { it.keyword.isNotEmpty() }
    .debounce(300)
    // 拉取联想词
    .flatMapLatest { flow { emit(searchPresenter.fetchHint(it.keyword)) } }
    .flowOn(Dispatchers.IO)
    // 跳转到联想页并展示联想词列表
    .onEach { searchPresenter.showHintPage(it.map { SearchHint(etSearch.text.toString(), it) }) }
    .launchIn(lifecycleScope)


其中textChangeFlow() 是一个 EditText 的扩展方法,该方法把监听输入框内容变化的回调转换为一个Flow,而Input是一个 data class:


fun <T> EditText.textChangeFlow(elementCreator: (Boolean, CharSequence?) -> T): Flow<T> = callbackFlow {
    val watcher = object : TextWatcher {
        private var isUserInput = true
        override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
        }
        override fun onTextChanged(char: CharSequence?, p1: Int, p2: Int, p3: Int) {
            isUserInput = this@textChangeFlow.hasFocus()
        }
        override fun afterTextChanged(p0: Editable?) {
            trySend(elementCreator(isUserInput, p0?.toString().orEmpty()))
        }
    }
    addTextChangedListener(watcher)
    awaitClose { removeTextChangedListener(watcher) }
}
//用于表达用户输入内容
data class Input(val isUserInput: Boolean, val keyword: String)


SearchPresenter.fetchHint()对界面屏蔽了访问网络的细节:


class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
    private val retrofit = Retrofit.Builder()
            .baseUrl("https://XXX")
            .addConverterFactory(MoshiConverterFactory.create())
            .client(OkHttpClient.Builder().build())
            .build()
    private val searchApi = retrofit.create(SearchApi::class.java)
    override suspend fun fetchHint(keyword: String): List<String> = suspendCancellableCoroutine { continuation ->
        searchApi.fetchHints(keyword)
            .enqueue(object : Callback<SearchHintsBean>() {
                override fun onResponse(call: Call<SearchHintsBean>, response: Response<SearchHintsBean>) {
                    if (response.body()?.result?.hints?.isNotEmpty() == true) {
                        val hints = if (result.data.hints.contains(keyword)) 
                            result.data.hints 
                        else listOf(keyword, *result.data.hints.toTypedArray())
                        continuation.resume(hints, null)
                    } else {
                        continuation.resume(listOf(keyword), null)
                    }
                }
                override fun onFailure(call: Call<SearchHintsBean>, t: Throwable) {
                    continuation.resume(listOf(keyword), null)
                }
        })
    }
}


访问网络的细节包括如何将 url 转换为请求对象、如何发起 Http 请求、怎么变换响应、如何将响应的异步回调转换为 suspend 方法。这些细节都被隐藏在 Presenter 层,界面无感知,它只要关心如何绘制。


按照这个思路,访问数据库,访问文件的细节也都不应该让界面感知。有没有必要把这些访问数据的细节再抽取出来成为新的一层叫“数据访问层”?


这取决于数据访问是否可供其他模块复用,或者数据访问的细节是否会发生变化。


若另一个 Presenter 也需要做同样的网络请求(新业务界面请求老接口还是挺常见的),像上面这种写,请求的细节就无法被复用。此时只能祭出复制粘贴。


而且搜索可以发生在很多业务场景,这次是搜索模板,下次可能是搜索素材。它们肯定不是一个服务端接口。这就是访问的细节发生变化。若新的搜索场景想复用这次的 SearchPresenter,则访问网络的细节就不该出现在 Presenter 层。


为了增加 Presenter 和网络请求细节的复用性,通常的做法是新增一层 Repository:


class SearchRepository {
    private val retrofit = Retrofit.Builder()
            .baseUrl("https://XXX")
            .addConverterFactory(MoshiConverterFactory.create())
            .client(OkHttpClient.Builder().build())
            .build()
    private val searchApi = retrofit.create(SearchApi::class.java)
    override suspend fun fetchHint(keyword: String): List<String> = suspendCancellableCoroutine { continuation ->
        searchApi.fetchHints(keyword)
            .enqueue(object : Callback<SearchHintsBean>() {
                override fun onResponse(call: Call<SearchHintsBean>, response: Response<SearchHintsBean>) {
                    if (response.body()?.result?.hints?.isNotEmpty() == true) {
                        val hints = if (result.data.hints.contains(keyword)) result.data.hints else listOf(keyword, *result.data.hints.toTypedArray())
                        continuation.resume(hints, null)
                    } else {
                        continuation.resume(listOf(keyword), null)
                    }
                }
                override fun onFailure(call: Call<SearchHintsBean>, t: Throwable) {
                    continuation.resume(listOf(keyword), null)
                }
        })
    }
}


然后 Presenter 通过持有 Repository 具备访问数据的能力:


class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
    private val searchRepository: SearchRepository = SearchRepository()
    // 将访问数据委托给 repository
    override suspend fun fetchHint(keyword: String): List<String> {
        return searchRepository.fetchSearchHint(keyword)
    }
}


又引入了一个新的复杂度数据访问层,它封装了所有访问数据的细节,比如怎样读写内存缓存、怎样访问网络、怎样访问数据库、怎样读写文件。数据访问层通常向上层提供“原始数据”,即不经过任何业务封装的数据,这样的设计使得它更容易被复用于不同的业务。Presenter 会持有数据访问层并将所有访问数据的工作委托给它,并将数据做相应的业务转换,最终传递给界面。


Model 去哪了?


至此业务架构表现为如下状态:


image.png


业务架构分为三层:


  1. 界面层:是 MVP 中的 V,它只描述了界面如何绘制,通过实现 View 层接口表达。它会持有 Presenter 的实例,用以发送业务请求。


  1. 业务层:是 MVP 中的 P,它只描述业务逻辑,通过实现业务接口表达。它会持有 View 层接口的实例,以指导界面如何绘制。它还会持有带有数据存储能力的 Repository。


  1. 数据存取层:它在 MVP 中找不到自己的位置。它描述了操纵数据的能力,包括读和写。它向上层屏蔽了读写数据的细节,是从网络读,还是从文件,数据库,上层都不需要关心。


MVP 中的 M 在哪里?难道是 Repository 吗?我不觉得!


若 Repository 代表 M,那就意味着 M 不仅代表了数据本身,还包含了获取数据的方式。


但 M 明明是 Model,模型(名词)。Trygve Reenskaug,MVC 概念的发明者,在 1979 年就对 MVC 中的 M 下过这样的结论:


The View observes the Model for changes


M 是用来被 View 观察的,而 Repository 获取的数据是原始数据,需要经过一次包装或转换才能指导界面绘制。


按照这个定义当前架构中的 M 应该如下图所示:


image.png


每一个从 Presenter 通过 View 层接口传递出去的参数才是 Model,因为它才直接指导界面该如何绘制。


正因为 Presenter 向界面提供了多个 Model,才导致上一节“有限内聚的界面绘制”,界面绘制无法内聚到一点的根本原因是因为有多个 Model。MVI 在这一点上做了一次升级,叫“唯一可信数据源”,真正地做到了界面绘制内聚于一点。(后续篇章会展开分析)


下面这个例子再一次展示出“多 Model 导致有限内聚的界面刷新”的缺点。


当前输入框的 Flow 如下:

image.png


整个流上有两个刷界面的点,一个在流的上游,一个在流的下游。所以不得不把上游切换到主线程执行,否则会报:


E CrashReport: android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.


这也是“有限的内聚”引出的没有必要的线程切换,理想状态下,刷界面应该内聚在一点且处于整个流的末端。(后续篇章会展开)


跨界面通信?


触发拉取联想词的动作在搜索页 Activity 中发生,联想接口的拉取也在 Activity 中进行。这就产生了一个跨界面通信场景,得把 Activity 中获取的联想词传递给联想页 Fragment。


当拉取联想词结束后,数据会流到 SearchPresenter.showHintPage():


class SearchPresenterImpl(private val searchView: SearchView) : SearchPresenter {
    override fun showHintPage(hints: List<SearchHint>) {
        searchView.gotoHintPage(hints) // 跳转到联想页
    }
}
interface SearchView {
    fun gotoHintPage(hints: List<SearchHint>) // 跳转到联想页
}


为 View 层接口新增了一个界面跳转的方法,待 Activity 实现之:


class TemplateSearchActivity : AppCompatActivity(), SearchView {
    override fun gotoHintPage(hints: List<SearchHint>) {
        // 跳转到联想页,联想词作为参数传递给联想页
        findNavController(NAV_HOST_ID.toLayoutId())
            .navigate(R.id.action_to_hint, bundleOf("hints" to hints))
    }
}


为了将联想词传递给联想页,得序列化之:


@Parcelize // 序列化注解
data class SearchHint( val keyword: String, val hint: String ):Parcelable


然后在联想页通过 getArguement() 就能获取联想词:


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    // 获取联想词
    val hints = arguments?.getParcelableArrayList<SearchHint>("hints").orEmpty()
}


当前传递的数据简单,若复杂数据采用这种方式传递,可能发生性能上的损耗,首先序列化和反序列化是耗时的。再者当通过 Intent 传递大数据时可能发生TransactionTooLargeException


展示联想词的场景是“界面跳转”和“数据传递”同时发生,可以借用界面跳转携带数据。但有些场景下不发生界面跳转也得传递数据。比如下面这个场景:


image.png


https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/373cfd8836a644ec81ac6e6d0a728d4c~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?


点击联想词也记为一次搜索,也得录入搜索历史。


当点击联想词时发生的界面跳转是从联想页 Fragment 跳到搜索结果 Fragment,但数据传递却需要从联想页到历史页。在这种场景下无法通过界面跳转来携带参数。


因为 Activity 和 Fragment 都能轻松地拿到对方的引用,所以通过直接调对方的方法实现参数传递也不是不可以。只是这让 Activity 和 Fragment 耦合在一起,使得它们无法单独被复用。


正如写业务不用架构会怎么样?(三)中描述的那样,界面之间需要一种解耦的、高性能的、最好还带粘性能力的通信方式。


MVP 并未内建这种通信机制,只能借助于第三方库 EventBus:


class TemplateSearchActivity : AppCompatActivity(), SearchView {
    override fun sendHints(searchHints: List<SearchHint>) {
        findNavController(NAV_HOST_ID.toLayoutId()).navigate(R.id.action_to_hint, bundleOf("hints" to hints))
        EventBus.getDefault().postSticky(SearchHintsEvent(searchHints))// 发送粘性广播
    }
}
// 将联想词封装成实体类便于广播发送
data class SearchHintsEvent(val hints: List<SearchHint>)
class SearchHintFragment : BaseSearchFragment() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        EventBus.getDefault().register(this) // 注册
    }
    override fun onDestroy() {
        super.onDestroy()
        EventBus.getDefault().unregister(this)// 注销
    }
    @Subscribe(threadMode = ThreadMode.MAIN,sticky = true)
    fun onHints(event: SearchHintsEvent) {
        hintsAdapter.dataList = event.hints // 接收粘性消息并刷新列表
    }
}


而 MVVM 和 MVI 就内建了粘性通信机制。(会在后续文章展开)


一切从头来过


产品需求:增加搜索条的过渡动画


image.png


https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e7bd16805a414ffe9ffb7dd6e0965883~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp?


搜索业务的入口是另一个 Activity,其中也有一个长得一模一样的搜索条,点击它会跳转到搜索页 Activity。在跳转过程中,两个 Activity 的搜索条有一个水平+透明度的过渡动画。


这个动画的加入引入了一个 Bug:进入搜索页键盘不再自动弹起,搜索历史页没加载出来。


那是因为原先初始化是在 onCreate() 中触发的:


// TemplateSearchActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    searchPresenter.init()
}


加入过渡动画后,onCreate() 执行的时候,动画还未完成,即初始化时机就太早了。解决方案是监听过渡动画结束后才初始化:


// TemplateSearchActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    window?.sharedElementEnterTransition?.doOnEnd {
        searchPresenter.init()
    }
}


做了这个调整之后,又引入了一个新 Bug:当在历史页横竖屏切换后,历史不见了。


那是因为横竖屏切换会重新构建 Activity,即重新执行 onCreate() 方法,但这次并没有产生过渡动画,所以初始化方法没有调用。解决办法如下:


// TemplateSearchActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    window?.sharedElementEnterTransition?.doOnEnd {
        searchPresenter.init()
    }
    // 横竖屏切换时也得再次初始化
    if(savedInstanceState != null) searchPresenter.init()
}


即当发生横竖屏切换时,也手动触发一下初始化。


虽然这样写代码就有点奇怪,因为有两个不同的初始化时机(增加了初始化的复杂度),不过问题还是是解决了。


但每一次横竖屏切换都会触发一次读搜索历史的 IO 操作。当前场景数据量较小,也无大碍。若数据量大,或者初始化操作是一个网络请求,这个方案就不合适了。


究其原因是因为没有一个生命周期比 Activity 更长的数据持有者在横竖屏切换时暂存数据,待切换完成后恢复之。


很可惜 Presenter 无法成为这样的数据持有者,因为它在 Activity 中被构建并被其持有,所以它的生命周期和 Activity 同步,即横竖屏切换时,Presenter 也重新构建了一次。


而 MVVM 和 MVI 就没有这样的烦恼。(后续篇章展开分析)


总结


  • 在 MVP 中引入数据访问层是有必要的,这一层封装了存取数据的细节,使得访问数据的能力可以单独被复用。


  • MVP 中没有内建一种解耦的、高性能的、带粘性能力的通信方式。


  • MVP 无法应对横竖屏切换的场景。当横竖屏切换时,一切从头来过。


  • MVP 中的 Model 表现为若干 View 层接口中传递的数据。这样的实现导致了“有限内聚的界面绘制”,增加了界面绘制的复杂度。


推荐阅读


写业务不用架构会怎么样?(一)


写业务不用架构会怎么样?(二)


写业务不用架构会怎么样?(三)


MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(一)


MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(二)


MVP 架构最终审判 —— MVP 解决了哪些痛点,又引入了哪些坑?(三)


“无架构”和“MVP”都救不了业务代码,MVVM能力挽狂澜?(一)


目录
相关文章
|
6月前
|
设计模式 存储 前端开发
MVVM、MVC、MVP三种常见软件架构设计模式的区别
MVC、MVP 和 MVVM 是三种常见的软件架构设计模式,主要通过分离关注点的方式来组织代码结构,优化开发效率。
141 12
|
28天前
|
前端开发 测试技术 数据处理
Kotlin教程笔记 - MVP与MVVM架构设计的对比
Kotlin教程笔记 - MVP与MVVM架构设计的对比
49 4
|
2月前
|
前端开发 测试技术 数据处理
Kotlin教程笔记 - MVP与MVVM架构设计的对比
Kotlin教程笔记 - MVP与MVVM架构设计的对比
43 2
|
7月前
|
设计模式 前端开发 Android开发
Android应用开发中的MVP架构模式解析
【5月更文挑战第25天】本文深入探讨了在Android应用开发中广泛采用的一种设计模式——Model-View-Presenter (MVP)。文章首先概述了MVP架构的基本概念和组件,接着分析了它与传统MVC模式的区别,并详细阐述了如何在实际开发中实现MVP架构。最后,通过一个具体案例,展示了MVP架构如何提高代码的可维护性和可测试性,以及它给开发者带来的其他潜在好处。
|
1月前
|
前端开发 Java 测试技术
android MVP契约类架构模式与MVVM架构模式,哪种架构模式更好?
android MVP契约类架构模式与MVVM架构模式,哪种架构模式更好?
49 0
|
2月前
|
前端开发 Java 测试技术
android MVP契约类架构模式与MVVM架构模式,哪种架构模式更好?
android MVP契约类架构模式与MVVM架构模式,哪种架构模式更好?
26 2
|
7月前
|
Android开发
mvp架构
mvp架构
129 9
|
15天前
|
弹性计算 API 持续交付
后端服务架构的微服务化转型
本文旨在探讨后端服务从单体架构向微服务架构转型的过程,分析微服务架构的优势和面临的挑战。文章首先介绍单体架构的局限性,然后详细阐述微服务架构的核心概念及其在现代软件开发中的应用。通过对比两种架构,指出微服务化转型的必要性和实施策略。最后,讨论了微服务架构实施过程中可能遇到的问题及解决方案。
|
24天前
|
Cloud Native Devops 云计算
云计算的未来:云原生架构与微服务的革命####
【10月更文挑战第21天】 随着企业数字化转型的加速,云原生技术正迅速成为IT行业的新宠。本文深入探讨了云原生架构的核心理念、关键技术如容器化和微服务的优势,以及如何通过这些技术实现高效、灵活且可扩展的现代应用开发。我们将揭示云原生如何重塑软件开发流程,提升业务敏捷性,并探索其对企业IT架构的深远影响。 ####
39 3
|
1月前
|
Cloud Native 安全 数据安全/隐私保护
云原生架构下的微服务治理与挑战####
随着云计算技术的飞速发展,云原生架构以其高效、灵活、可扩展的特性成为现代企业IT架构的首选。本文聚焦于云原生环境下的微服务治理问题,探讨其在促进业务敏捷性的同时所面临的挑战及应对策略。通过分析微服务拆分、服务间通信、故障隔离与恢复等关键环节,本文旨在为读者提供一个关于如何在云原生环境中有效实施微服务治理的全面视角,助力企业在数字化转型的道路上稳健前行。 ####