美文网首页编程思想Android知识Android开发
聊一聊UI代码要怎么写 | 分治篇

聊一聊UI代码要怎么写 | 分治篇

作者: 猫克杯 | 来源:发表于2017-05-04 09:02 被阅读456次
    • 本文的主要价值:提供一份软件开发中分治UI逻辑的实践样本
    • 关键词:分治UOP
    • 本文约4500字,建议阅读时间30分钟

    引子


    我们知道,分治策略是人们解决问题的一种基本策略。问题规模越大,内部包含的差异化的细节越多,越需要执行分治策略。

    所谓化整为零,化繁为简,逐个击破讲的都是分治。在计算机领域,我们要列举分治的例子,大的可以聊到七层网络模型(本质上分层也是一种分治),小的可以讲起二分算法。

    本文基于Android客户端开发中经常涉及的交互逻辑编程展开,表达我对于UI分治在软件开发中如何实践的理解。

    1. 从MVC说起


    经典的MVC设计模式想必各位程序猿们无人不晓。MVC最早存在于桌面程序中,后来由于其强大的复用性被广泛地发掘和应用于各端的开发中,还衍生出了MVP,MVVM等变体。如果把MVC的变体都算作MVC,可以说现如今任何一个成熟的GUI框架都内化了这种设计模式。放图:

    MVC_MVP.png

    然而文猫君今天不是要来谈MVC设计模式的,因为网络上以各种姿势深入浅出MVC的好文章已经多的不能再多。之所以提及MVC模式只是想借此提醒读者,解耦对于编程的重要性。

    MVC之所以看起来很简单却又如此广泛地被使用就在于它有效地解决了一个已知的,规模庞大的耦合问题:“你看到的”(不妨理解为MVC中的<code>View</code>) 和“它代表的” (不妨理解为MVC中的<code>Model</code>),这两个东西永远不可能完全一致。我们有时候需要用相同的表象去表示不同的真相,有时候则需要用不同的表象去表达同一个真相。而当我们尝试把两者捆绑在一起处理时,一旦映射关系发生变化,这个捆绑体便不再适用,需要重新构建。这样一来,原来的东西就都不能用了。所以我们需要一套机制来避免这种耦合,从而实现<code>Model</code>和<code>View</code>各自的复用。想要简单的理解这个问题,思考一下我们如何造字,为什么只需要造出常用的几千个汉字就能够表达一个人在生活中遇到的绝大部分事物?又为什么同一个字词在不同的场景下可以表达不同的含义?

    MVC正是通过解耦<code>Model</code>和<code>View</code>,使得大量的UI可以被标准化,进而被重复利用。而这个问题之所以规模巨大,是因为只要一个计算机程序是给人类用的,就一定会涉及到人机交互,也就是我们常说的UI。

    在解决了耦合问题并实现复用时,MVC引入了<code>Controller</code>,我们的UI怎么写将围绕<code>Controller</code>展开。

    2. UI写在何处?


    在现今我们用到的主流应用框架中,你很难找到针对GUI编程部分只提供手写原生代码来实现界面的个例—它们无一例外地会引入基于某一种或者某几种标记语言的界面编程机制。其中最常见的是xml及其变体。

    使用标记语言设计UI的最大好处在于它们可以被方便地转换为可视化的编辑界面,这样的话可以允许程序员以所见即所得的方式直观地进行界面设计,即可视化编程。提到可视化编程,桌面端的开发框架中大家最耳熟能详的想必包括微软的<code>.NET</code>。之所以特别提及<code>.NET</code>,是因为我认为微软在可视化编程技术和可视化集成开发环境方面的贡献至今仍是值得称道的。作为编程的初学者,文猫君当年曾一度被VisualStudio的强大惊艳到。

    在GUI编程方面摸爬滚打过几年,我先后使用过<code>MFC</code>,<code>Windows Forms</code>,<code>Qt</code>等框架开发桌面端的用户界面,目前从事的是Android客户端开发。从这些使用过的开发框架的用户界面部分的组件,我发现一个共同点:UI都是一定程度上独立的。首先在设计阶段,通过使用单独的资源文件夹或者<code>.xml</code>,<code>.qml</code>,<code>.ui</code>,又或者是C#分部类,界面部分的生成逻辑是被隔离开的。然后到使用阶段,界面部分的元素通过约定的方式查找或者引用,并且建立响应逻辑。这样的运行方式,使得UI和其他程序逻辑被天然地划分开,能够让程序员把UI的代码编写从整体的编程活动中独立出来,从而便于维护和协作。不妨把UI设计和UI使用的这两个阶段称为UI编程的两个阶段。我在后文会介绍到UOP和从UI快速切入别人的代码结构进行修改的策略,都是基于UI实现所具备的这种独立性。从这里开始,相关话题笔者会以Android平台的客户端界面设计为例展开。

    Android的UI可以通过xml设计,运行时由系统加载和创建出界面,也可以通过代码直接创建。相对来说,前者的使用方式比较普遍,以下描述会基于采用前者方式的前提。在UI编程的第二个阶段,Android通过<code>findViewById</code>的方式将UI元素从被隔离的区域(xml)中找出来,准确的说,是通过预先定义好的id将第二阶段所关心的UI元素从被隔离的区域(xml)中找出来,为它在控制器逻辑中建立一个引用,然后围绕这些UI元素编写交互逻辑。

    到目前为止,文猫君所说的是大家都已经知道的事实,而这一节要聊的关于UI写在何处——跟我们已知的这些事实有什么关联呢?一些有过Android开发经验的同学想一想RoboGuiceButterKnife以及官方的Android Data Binding为何会存在?也许会对这个问题有自己的答案。

    在笔者看来,在编程框架内置的MVC模型中,单一的<code>Controller</code>一直负荷了过重的工作,因为通常的情况是许多的Model和View都仅仅通过某个唯一的<code>Controller</code>建立关联,与此同时我们忽略了一个重要的事实:单一的<code>Controller</code>通常不仅要服务于UI,往往还需要承载多个不同角度不同层级的业务逻辑。面对这个问题,我们能做些什么呢?有的读者可能已经想到了MVVM模式。首先,要肯定一下MVVM是一个可以考虑的选项。在我看来,<code>ViewModel</code>本质上就是将<code>Controller</code>中与特定UI密切相关的逻辑集中在一起。不过呢,笔者会倾向于认为MVVM是MVC的一个变体,<code>ViewModel</code>是我们在实现<code>Controller</code>时所采取的一种策略:这种策略叫做分治。那么为什么MVVM只是可以考虑的选项而不是根本的解决方案呢?笔者的解释是:因为分治这件事,太依赖于具体情况了。没有一种框架可以告诉我们应当如何拆分一整个复杂的控制器逻辑,如你所见,它们顶多帮你把UI隔离到另外的空间,让你的代码不至于一上来就看着一团乱麻。而我们在编程的时候,还可能会不断地把UI找出来,重新加入我们的控制器,并为它们书写冗长的交互代码。有的时候这些交互的复杂程度是已经具备一定约束规则的ViewModel所无法预见和适应的。我的想法是,无论MVC还是MVVM,其实都是可行的方案,但是我们不能太拘泥于形式,不能规定<code>View</code>和<code>Model</code>必须以这样或者那样的方式建立关联。在具体的场景中,需要视逻辑的复杂度对控制器中的UI部分进行拆分。(控制器中可以拆分的逻辑当然并不局限于UI部分,不过它与本文无关这里不特别说明。)所有分治的UI控制器中的UI虽然是由原来的那个控制器统一创建的,但是在使用时却可以由各分治控制器自行把握。

    这里提供一个参考思路:在实际的开发实践中,我做过的控制器拆分通常是以可复用的组件作为目标,根据模块分工,代码规模等情况综合考虑的。

    3. UOP——分治UI控制逻辑的利器


    接着第2节文末提出的思路,笔者在本文的最后一节要来重点分享一下在实践UI控制逻辑分治这件事时的一种十分有效的编程方式:UOP

    User-Interface-oriented Programming, 面向UI编程。你无法在wikipedia上找到这个词条。因为它是杜撰的。实际上,我将要说的“面向UI编程”应该是面向切面编程(****Aspect-oriented* *programming****)的一种,只不过切面聚焦在User Interface上。

    在展开UOP之前,让我们先回到<code>Controller</code>这个概念,在我们熟知的框架内化的MVC模式中,原先的那个<code>Controller</code>角色必定是已经提供UI的访问途径了,比如Android框架的控制器<code>Activity</code>,它提供了<code>findViewById</code>。而分治出去的<code>Controller</code>(同时也是我们自己创建的<code>Controller</code>)要如何取得UI呢?一些同学可能会想到,使用setter。是的,我们完全可以这么实现。不过这里要提供另外一种思路——“UI包装器”,文猫君还给它取了一个代码名,叫<code>UIWrapper</code>。我们将通过这个“UI包装器”来说明UOP是什么,以及我们为什么使用UOP。

    一个Android上的UI包装器可以是如下的实现:

    public class UIWrapper {
        /** 布局资源id到根视图的索引 */
        protected SparseArray<View> mLayoutIdToRootViewIndex = new SparseArray<>();
        /** 视图ID到子视图的索引 */
        protected SparseArray<View> mIdToSubViewIndex = new SparseArray<>();
    
        public UIWrapper() {}
    
        /**
         * 设置UI元素
         * 
         * @param otherWrapper 给定的UI元素包装器
         */
        public UIWrapper setUi(UIWrapper otherWrapper) {
            mLayoutIdToRootViewIndex = otherWrapper.mLayoutIdToRootViewIndex.clone();
            mIdToSubViewIndex = otherWrapper.mIdToSubViewIndex.clone();
    
            return this;
        }
    
        /**
         * 包装UI元素
         *
         * @param viewId 指定的视图资源id
         * @param view 指定的视图资源ID对应的视图
         */
        public UIWrapper wrapUi(int viewId, View view) {
            return wrapUi(viewId, view, false);
        }
    
        /**
         * 包装UI元素
         *
         * @param viewId 指定的视图资源id
         * @param view 指定的视图资源ID对应的视图
         * @param treatAsViewGroup 以ViewGroup方式处理包装的View
         */
        public UIWrapper wrapUi(int viewId, View view, boolean treatAsViewGroup) {
            if (viewId >= 0 && view != null) {
                mIdToSubViewIndex.put(viewId, view);
                if (treatAsViewGroup) {
                    mLayoutIdToRootViewIndex.put(viewId, view);
                }
            }
    
            return this;
        }
    
        /**
         * 根据view id查找UI元素
         *
         * @param viewId 目标视图的id
         * @return view
         */
        public View findViewById(int viewId) {
            View view = mIdToSubViewIndex.get(viewId);
    
            if (view == null) {
                for (int index = 0; index < mLayoutIdToRootViewIndex.size(); index++) {
                    View layout = mLayoutIdToRootViewIndex.valueAt(index);
                    if (layout != null) {
                        view = layout.findViewById(viewId);
                    }
    
                    if (view != null) {
                        mIdToSubViewIndex.put(viewId, view);
                        break;
                    }
                }
            }
    
            return view;
        }
    }
    

    <code>UIWrapper</code>这个类很简单,它的功能概括成一句话就是动态扩容地持有一组<code>View</code>的匿名引用,并且具备通过<code>viewId</code>来检索View的能力。(以动态扩容地持有匿名引用的方式来实现代码复用,是一个简单却极其有效的实用技巧,在文猫君的另外一篇文章 用“管道”设计拆分复杂处理流程 中也有提到,感兴趣的读者可以移步一阅。)

    <code>UIWrapper</code>在具体场景中是以继承或者依赖的方式被使用的,如果我们的分治UI控制器本身没有基类,可以直接继承自<code>UIWrapper</code>; 如果已经有了继承结构,可以引入<code>UIWrapper</code>作为成员,进而间接引入目标UI,比如<code>Fragment</code>就适用这种情况。题外话,说到<code>Fragment</code>,读者会不会意识到它其实就是框架本身提供的一种分治策略的具体实现呢?

    对于以继承方式使用UI包装器的方式,我们不妨设计下面这样一个基类<code>AbsUIController</code>,也就是前文我们说的要分治的UI控制器。

    /** 
     * 分治的UI控制器基类
     */
    public abstract class AbsUIController extends UIWrapper {
        // 生产环境中这个抽象层级中除了构造方法外还有一些实际的基础功能,
        // 但是与本文重点无关,此处略过
    
        public AbsUIController(@NonNull Activity activity) {
            super();
            wrapUi(0, activity.getWindow().getDecorView(), true);
        }
    
        public AbsUIController(@NonNull Activity activity, UIWrapper ui) {
            super();
            setUi(ui);
        }
    }
    

    再基于基类的UI控制器,我举一个在生产环境中实现的UI控制器为例,它的功能是为所有由它管理的UI元素提供独占可见状态的显示和隐藏过程处理的可复用的工具类。通俗的说,就是处理几个</code>View</code>同一时间只显示其中一个的情况。以下是简化的代码:

    public class MonoDisplayController extends AbsUIController {
        ...
    
        /**
         * 独占展示空间显示给定视图
         * @param view 要显示的视图
         */
        public void monoDisplay(final View view) { ... }
        
        /**
         * 隐藏由当前包装器管理的全部视图
         * @param delayBeforeStart 开始隐藏前的延时
         * @return 整个隐藏过程完成的总耗时,以毫秒计
         */
        public long hideAll(long delayBeforeStart) { ... }
    }
    

    读者是否可以想到这样的一个简单的UI控制器可以用在哪里呢?不妨先看一下<code>MonoDisplayController</code>这个类的用例的代码片段:

        // 将可见性互斥的UI元素包在同一个MonoDisplayController里, 包含可下载图标,下载进度等
        // 注意,生产环境中的UI元素数量可能很多,都要求互斥显示,这时复用代码更能体现优势
        viewHolder.monoDisplaysOnDownloadStatus = new MonoDisplayController(getActivity());
        viewHolder.monoDisplaysOnDownloadStatus
                .wrapUi(R.id.iv_download_available, viewHolder.ivDownloadAvailable)
                .wrapUi(R.id.download_progress_view, viewHolder.downloadProgressView)
                .wrapUi(...)
        //....
        // 根据下载状态互斥展示不同的UI元素
        switch (entity.getDownloadStatus()) {
        // 正在下载
        case DOWNLOAD_STATUS_DOWNLOADING:
            viewHolder.monoDisplaysOnDownloadStatus.monoDisplay(viewHolder.progressView);
            ...
            break;
        // 下载暂停,删除,未下载
        case DOWNLOAD_STATUS_PAUSE:
        case DOWNLOAD_STATUS_DELETED:
        case DOWNLOAD_STATUS_UN_DOWNLOAD:
            viewHolder.monoDisplaysOnDownloadStatus.monoDisplay(viewHolder.downloadAvailableView);
            ...
            break;
        }
    

    限于篇幅没有奉上无关的代码。我相信如果读者有过用许多if, else分支来协调多个<code>View</code>的可见性这种编码经验的话,应该会认同这样一个控制器是能够有效减少代码量和避免错误的。

    讲到这里,UOP的实例已经在代码中完整给出。读者请重点留意一下<code>UIWrapper#wrapUi(int viewId, View view)</code>这个方法,它体现了UOP最核心的思维方式:UI是第一度的逻辑出发点。首先搞清楚我们要跟哪些UI打交道,然后才是要在这些UI上做些什么事情。

    .wrapUi(R.id.iv_download_available, viewHolder.ivDownloadAvailable)
    .wrapUi(R.id.download_progress_view, viewHolder.downloadProgressView)
    

    解释完UOP是什么,最后再来解释一下为什么UOP是分治UI的利器。前面说过UOP其实是AOP中的一种,而我认为AOP其实是贯彻第一性原理的一种思维方式:聚焦任务在同一维度的事物构成的某一条线索上。UI是程序代码中被最直观呈现的东西,以软件用户的角色观察,UI可能就是他可以感知的全部,这是一条最自然的线索。而对于程序员来说,这个认知其实也是完全适用的。如果这样描述不好理解,我再举一个维护代码的例子,程序员朋友可能就会有共鸣了。

    假定有程序员甲开发了一款软件,他很熟悉这款软件的代码。还有程序员乙从未接触过这款软件的代码。有一天乙接受任务在甲不能提供帮助的情况下(比方说甲请假了)要在短时间内调整一处该软件的UI。对于程序员乙来说,最快的做法是先去阅读代码并了解代码结构吗?恐怕不是,现实场景中在对代码一无所知的情况下基于别人的代码增改功能并不是罕见的情况。倘若时间有限无法阅读更多的代码,抓住目标的特点从代码中找到一条线索并切入则经常能够快速搞定任务。这其实只是专注于任务本身的结果,而并非什么独特的解题技巧。对于UI来说,不论是框架内化的设计模式还是它直观的呈现方式,往往都天然地提供了一条独立于常规代码之外的线索。

    使用UOP本质上是在分治原本被集中在一处书写的UI控制逻辑时能够保持对原始逻辑的聚焦。虽然分出来了,但还是那些事,不会因为设立了一个分管的去处就设计出了额外的东西。但是又因为分出来了,被分出来的这些事作为一个可单独运行的整个的逻辑变得更加纯粹了,可以被复用,当然它带来的最重要的好处是:更方便维护了。

    相关文章

      网友评论

        本文标题:聊一聊UI代码要怎么写 | 分治篇

        本文链接:https://www.haomeiwen.com/subject/iwzpzttx.html