如何“好好利用多态”写出又臭又长又难以维护的代码?

多态

多态是编程语言支持的一种特性,这种特性使得静态的代码运行时可能产生动态的行为,这样一来编程时不需要为类型所烦恼,可以编写统一的处理逻辑而不是依赖特定的类型。”

在星巴克消费的时候就会发生多态,多态方便了顾客。不管是现金,支付宝,亦或是微信,顾客不需要关心每种支付背后的细节,只需递交支付工具即可。收银员是产生多态行为的关键,他把同样的递交行为在结账机上动态地和不同的支付方式做了对接。

深入结账机内部,假设它使用如下方式实现支付的多态:

class PayManager {
    fun pay(type: String){
        if (type == 现金){
            payByCash()
        } else if (type == 支付宝) {
            payByAli()
        } else if (type == 微信) {
            payByWechat()
        }
    }
}
复制代码

通过 if-else 实现,其实称不上多态,而且这样实现会遇到不少麻烦。

if-else 之殇:无扩展性

新增支付方式时,得重新生产结账机。

因为if-else是在编译时新增行为,即当代码编译完成后,就生成了一张PayManager.pay()的 PDF 快照,它无法在运行时动态地改变,要新增行为就得重新编译,所以这种新增行为是静态的,无扩展性可言。

与 编译时新增行为 对应的是运行时新增行为,即当运行不同的上层代码,PayManager.pay()会表现出不同的行为(多态)。

策略模式就适用于当前场景,策略模式将具体的行为和行为的使用者隔离,当行为发生变化时,行为的使用者不需要随之而变。

// 用接口定义抽象支付行为
interface Pay {
    fun pay()
}

// PayManager 持有抽象支付行为
class PayManager {
    private var pay:Pay
    
    fun setPay(pay: Pay) {
        this.pay = pay
    }
    
    fun pay(){
        pay.pay()
    }
}
复制代码

经过策略模式的封装,使用 PayManager 的上层类就可以通过注入不同的Pay接口实例,在运行时动态地为 PayManager 新增支付方式。

那 PayManager 的上层类不也要参与编译吗?也就是说能为 PayManager 新增多少支付方式也是编译之前就决定的咯?

没错!动态是有层次的,通过策略模式的封装,至少 PayManager 这一层实现了“运行时动态新增行为”。这样的好处时,新增支付方时 PayManager 类不需要改动,即 “当变化发生时上层代码不需要改动” ,这句话也可以表达成: “在不修改既有代码的情况下扩展功能” ,这就是著名的 “开闭原则”

  • “对修改关闭”的意思是:当需要为类扩展功能时,不要想着去修改类的既有代码,这是不允许的! 为啥不允许?因为既有代码是由数位程序员的努力,历经了多个版本的迭代,好不容易才得到的正确代码。其中蕴含着博大精深的知识,和你不曾了解的细节,修改它一定会出bug的!
  • “对扩展开放”的意思是:类的代码应该具备良好的抽象,使得扩展类的时候,不需要修改类的既有代码。

有时候修改上层类代价很高,比如 PayManager 是另一个团队提供的库,若没有考虑扩展性的话,新增支付方式这个小功能就变成了跨团队协作的大需求。

仔细端详上述代码后,是不是会产生“策略模式”抽象了个寂寞,因为 demo 中的 PayManager 什么都没有做,要它有何用?

其实 PayManager 中还应该包含一些其他的代码,比如支付之前先从自家服务器获取订单信息,或者支付结果的回调,或者支付失败后的重试逻辑,这些逻辑都不会因支付方式的改变而改变,它们就应该固定在 PayManager 类中。而策略模式就好像在 PayManager 中打开一个小孔,上层可以根据需求塞不同的行为进来。

关于策略模式的详解可以点击一句话总结殊途同归的设计模式:工厂模式=?策略模式=?模版方法模式

if-else 之殇:无法复用

假设星巴克为北京门店用 if-else 预设了 8 种支付方式,新开张的上海门店需要其中的 2 种,并且还得新增一种上海独有的支付方式“OK卡”。

用 if-else 的思想解决方案是,重新为上海生产一批结账机,在北京结账机的 if-else 分支中摘取 2 个复制粘贴到新结账机,并通过 else-if 追加“OK卡”支付方式。

虽然老板对于重新生产结账机的成本增加有隐隐地不满,但上海星巴克也“成功地”开张了。

运营部门为了提高流水,决定抓住“1024程序员节”这个好日子搞一个大促,当天程序员通过支付宝买 10 杯咖啡打八折,好让他们彻夜无眠。

对于技术部门来说,这次运营活动就是对既有支付方式的一次迭代,技术 leader 拍着胸脯说:“小需求,10 分钟搞定”。程序员小明“快速”地做出了实现,但提测后,他有一些后怕:“因为之前是将北京结账机中支付宝的支付逻辑复制粘贴到了上海结账机中,这样同一份逻辑就出现在了两个地方,如果以后还有广州店,深圳店,南京店。。。。怎么办?而且不仅仅是频繁的运营活动,支付宝 SDK 更新导致 API 变动的适配也散落在不同的地方。”

其实也不能怪罪小明,谁叫前辈用 if-else 的方式来实现支付方式的多态?

if-else 中的逻辑是无法抽离出来供其他类复用的!

这也是为啥要提倡DRY原则的一个原因,即don't repeat yourself

但其实小明的复制粘贴工作也不好做:

class PayManager {
    var aliPay: AliPay
    var wechatPay:WECHATPay
    var resultHandler: Handler // 支付结果处理器
    var retryRunnable: Runnable // 支付失败后的重试逻辑
    
    fun pay(type: String){
        if (type == 现金){
            payByCash()
        } else if (type == 支付宝) {
            payByAli()
        } else if (type == 微信) {
            payByWechat()
        }
    }
    
    private payByAli(){...}
}
复制代码

支付宝的支付逻辑不全是被一个私有方法包裹的,还有散落在 PayManager 内部的各种成员变量。这种场景下,将方法复制到另一类中,通常会有很多报错,然后再一个个来回复制粘贴成员变量。(低耦合高内聚的代码复制粘贴后不会报错)

可想而知,随着分店的变多,现有架构为适应变化的改动会成倍地增加,虽然小明加班时间越来越长,但交付速度和质量缺越来越差。慢慢地,小明也进入了彻夜无眠的状态。

项目实战中的多态现状

上述故事纯属虚构,但雷同的情节经常发生在日常的开发过程中,下面举一个真实项目中的例子。

微信图片_20220109134606.jpg

这是一个 feeds 流,它由一个个帖子组成,一开始帖子类型只有文字一种。所以服务端返回的 json 结构也很简单清晰:

{
    feeds:[
        {
            text: "UU环游记...",
            user: {},// 用户信息
            commentCount: 15,
            likeCount: 102
        },
        ...
    ]
}
复制代码

随着持续地迭代,帖子类型不断增多,比如出现了图片、视频、语音。服务端使用如下方式扩展 json:

{
    feeds:[
        {
            type: 1, // 帖子类型
            text: "UU环游记...",
            user: {},// 用户信息
            commentCount: 15,
            likeCount: 102
            images: [], //图片 url 数组
            video: {}, // 视频字段
            voice: {} // 语音字段
        },
        ...
    ]
}
复制代码

客户端得通过读取每个帖子的 type,然后解析不同的字段。当 type 为语音时,则读取 voice 字段,当 type 为视频时,则读取 video 字段。

也就是说,服务端用一个“大json”来表示帖子,每种帖子只会使用到大json中的某些字段,随着帖子类型增多,json结构会越来越大。这种方式叫宽字段

对于服务端来说宽字段的缺点是类型冗余,需要额外做空值处理,比如当前是音频贴,就不得不给将所有和 voice 字段互斥的其他字段都置空。(这对于新人来说不是很容易出 bug 吗?)

由于服务端是宽字段,所以客户端很容易惯性地给大 json 配上对应的上帝类,客户端代码中表示帖子的 PostBean 类有 100+ 个字段。对于新人来说这是一个巨大的理解负担,因为要彻底理解这个类,你就必须知道哪个场景下,PostBean 中的哪些字段会有用。

情况其实比想象的还要糟糕,因为 type 只包含一些基础类型,就是上面提到的文字、图片、视频,语音。还有 N 多扩展类型,并不能通过 type 的不同来做区分。这就导致了帖子类型判断是一个及其复杂 if-else 逻辑,秀一下 PostBean 中的getType()方法:

public int getType() {
    if (timeline != 0) {
        return TYPE_TIMELINE_YEAR;
    }
    if (TextUtils.equals(category, CATEGORY_BEHAVIOR)) {
        if (type == TYPE_IMAGE || type == TYPE_VOICE) {
            return TYPE_POST_CHECK;
        } else if (type == TYPE_TEXT || type == TYPE_VOICE_COMPLEX || type == TYPE_STREET_NORMAL) {
            return TYPE_BEHAVIOR;
        } else {
            return TYPE_NOT_SUP;
        }
    }
    if (TextUtils.equals(category, CATEGORY_POKE)) {
        return TYPE_POINT;
    }
    if (mPostAdvert != null) {
        if (mPostAdvert == PostAdvert.RulesAdvert.INSTANCE) {
            return TYPE_RULE;
        }
        if (mPostAdvert instanceof PostAdvert.TopicHeaderAd) {
            return TYPE_TOPIC_HEADER;
        }
        if (mPostAdvert instanceof PostAdvert.PartyAdvert) {
            return TYPE_RECOMMEND_JOIN;
        }
        if (mPostAdvert instanceof PostAdvert.StreetLaneAdvert) {
            return TYPE_RECOMMEND_CIRCLE;
        }
        if (mPostAdvert instanceof PostAdvert.VoiceLaneAdvert) {
            return TYPE_RECOMMEND_AU;
        }
        if (mPostAdvert instanceof PostAdvert.TestAdvert) {
            return TYPE_RECOMMEND_TEST;
        }
        if (mPostAdvert instanceof PostAdvert.ChannelPromoteAdvert) {
            return TYPE_CHANNEL_RECOMMEND_HEADER;
        }
    }

    if (liveComment != null) {
        return TYPE_LIVE_COMMENT;
    }


    if (insertParty != null) {
        return TYPE_INSERT_PARTY;
    }

    if (topicBeans != null) {
        return TYPE_HOT_TOPIC;
    }
    if(insertTopics != null){
        return TYPE_INSERT_TOPIC;
    }
    if (type == TYPE_ZHUAN_FA) {
        if (sourcePost == null) {
            return TYPE_ZHUAN_FA_DELETE;
        } else {
            int gender = UserManager.getSex().blockingGet();
            if(sourcePost.selfOnly == 1){
               return TYPE_ZHUAN_FA_DELETE;
            }else if(gender == 1 && (sourcePost.publicStatus == 5 || sourcePost.publicStatus == 7)){
                return TYPE_ZHUAN_FA_DELETE;
            }else if(gender ==0  && (sourcePost.publicStatus == 4 || sourcePost.publicStatus == 6) ){
                return  TYPE_ZHUAN_FA_DELETE;
            }else if (sourcePost.publicStatus == 0 ) {
                return TYPE_ZHUAN_FA_DELETE;
            } else {
                if (sourcePost.type == TYPE_IMAGE || sourcePost.type == TYPE_STREET_INVITE) {
                    return TYPE_ZHUAN_FA_TEXT_PHOTO;
                } else if (sourcePost.type == TYPE_VOICE || sourcePost.type == TYPE_DUET || sourcePost.type == TYPE_VOICE_COMPLEX) {
                    return TYPE_ZHUAN_FA_AUDIO;
                } else if (sourcePost.type == TYPE_VIDEO || sourcePost.type == TYPE_MOVIE) {
                    return TYPE_ZHUAN_FA_VIDEO;
                } else {
                    return TYPE_ZHUAN_FA_TEXT;
                }
            }
        }
    }
    if (type == TYPE_TEXT) {
        return TYPE_ITEM_TEXT;
    }
    if (type == TYPE_IMAGE) {
        return TYPE_ITEM_PHOTO;
    }
    if (type == TYPE_VOICE) {
        return TYPE_ITEM_VOICE;
    }
    if (type == TYPE_VOICE_COMPLEX) {
        if (ObjectsCompat.nonNull(songDet) &&
                !TextUtils.isEmpty(songDet.getFirstParagraph())) {
            return TYPE_ITEM_VOICE_CONTROL_OLD;
        } else {
            return TYPE_ITEM_VOICE_CON;
        }
    }
    if (type == TYPE_STREET_INVITE) {
        return TYPE_STREET_SHARE;
    }
    if (type == TYPE_DUET) {     
        return TYPE_CHORUS;
    }
    if (type == TYPE_MOVIE) {     
        return TYPE_VIDEO;
    }
    return TYPE_NOT_SUPPORT;
}
复制代码

为了获得帖子类型,不得不结合 type 字段及其他 N 个字段进行综合比对,复杂度之高,让人彻夜无眠。

每新增一个类型,就不得不为上帝 PostBean 新增一个字段,让他更加无所不知,并且还得让已经长的无法看懂的 getType() 方法更加看不懂。

随着迭代的进行,新的类型不断地再被插入到 Feeds 流中,比如:

微信图片_20220109143037.jpg 对于服务器来说,上图中的推荐话题来自于另一个服务,所以客户端得从一个新得接口中获取数据。这样整个 feeds 流的内容就来自于两个接口。

虽然这次服务器无法继续沿用宽字段的思想,将推荐话题作为新增字段插入到原本的大 json 中,但客户端的思维惯性让 PostBean 又新增了一个成员变量。

其实这是不得已为之,因为用于展示 feeds 流的适配器被定义成如下这个样子:

class FeedsAdapter() :ListAdapter<PostBean, RecyclerView.ViewHolder>() {}
复制代码

FeedsAdapter 是“单类型列表适配器”,它只能适配一种数据类型,即 PostBean。所以即使服务器返回了一种新类型,客户端也不得不将新类型作为 PostBean 的一个成员变量,假装它好像是一个 PostBean。

这还不是最糟的,下面才是故事的高潮。

由于服务端和客户端实现方案的各种包袱,不同帖子的展示及交互逻辑不得不在 Adapter 中通过一个超级大的 if-else 来完成:

class FeedsAdapter() :ListAdapter<PostBean, RecyclerView.ViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        if(viewType == 1){
            create1ViewHolder()
        } else if (viewType == 2) {...}
        ...
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        if(holder is 1ViewHolder){
            holder.bind()
        } else if (holder is 2ViewHolder) {...}
        ...
    }
    
    override fun getItemViewType(position: Int): Int {
        return data[position].getType()
    }
}
复制代码

客户端的 FeedsAdapter 类的长度是 2000+ 行,这已经快彻夜难眠了。

每次新增类型,PostBean 和 FeedsAdapter,这两个上帝类都会变得更加上帝一点。(这违反了开闭原则)

更要命的是,帖子不止出现在这一个界面中,整个 app 有 6 个不同的界面需要展示帖子,因为帖子的展示及交互逻辑写在 if-else 中,所以它无法被复用,只能通过复制粘贴到另外 5 个 Adapter。每次新增帖子类型,或是改动某个帖子的交互的工作量直接乘以 6。对新人也及其不友好,他不熟悉业务,他不知道代码里还有 5 个坑等着他,他得知这个坏消息的途径很可能是测试提的 bug。

一种多态解决方案

为了解决复杂度高、扩展性差、无法复用这三个缺点。我抛砖引玉一套解决方案:

服务端弃用宽字段

服务端不再返回包含所有冗余字段的大 json,而是改用下面的形式:

{
    feeds:[
        {
            type: 1,
            data: {
                text:""
            }
        },
        {
            type: 2,
            data: {
                imgUrls: []
            }
        }
    ]
    ...
}
复制代码

即为每种类型配置一个单独的 json 结构,每一个 json 结构对应于客户端的一个实体类。

class BaseBean {
    String type
}
复制代码

这是实体类的基类,它包含了所有实体类共有的字段 type,解析时就通过该字段实现多态。

// 文本贴实体类
class TextBean : BaseBean {
    String text
}

// 图片帖实体类
class ImageBean: BaseBean {
    List<String> imageUrls
}
复制代码

客户端在解析这种多类型 json 时,需要通过继承JsonDeserializer自定义一个解析器:

class MyDeserializer implements JsonDeserializer<List<BaseBean>> {
    @Override
    public List<BaseBean> deserialize(JsonElement element, Type type,JsonDeserializationContext context) throws JsonParseException {
        JsonArray array = element.getAsJsonArray();
        List<BaseBean> list = new ArrayList<>();
        for (JsonElement e : array) {
            int type = e.getAsJsonObject().get("type").getAsInt();
            if(type == 1) {
                list.add(new Gson().fromJson(e, TextBean.class));
            } else if(type == 2) {
                list.add(new Gson().fromJson(e, ImageBean.class));
            }
        }
        return list;
    }
}
复制代码

这是整个方案中唯一出现的 if-else 代码块。

网络请求后客户端拿到的是List<BaseBean>,但这个列表中的每个元素通过继承实现了多态。

这个方案可以提高客户端内存和 CPU 性能,首先服务端返回的 json 串变小了,而且每个帖子需要解析的字段变少了(原来是不管那种类型的帖子都要解析 53 个字段)。

多类型列表适配器

下一个问题就是如何设计一个多类型列表适配器,我在策略模式应用 | 每当为 RecyclerView 新增类型时就很抓狂这篇文章中做了详细的分析。

简单总结如下:

  1. 把表项展示和数据绑定抽象为策略,策略的声明带有泛型,以表示遇到哪个类型的数据时使用该策略。Adapter 持有一组策略和一组抽象数据List<Any>。新增类型即是注入一个新的策略。(Adapter 不需要修改)
  2. Adapter 的主要功能是为不同的数据类型匹配不同的策略。这样一来,每一个表项的展示和交互就被包裹在一个独立的策略类中,它可以随意的注入到任何 Adapter 中,以实现不同界面的复用。

性能优化

最后还有一个出于性能的考虑。

假设 feeds 流包含 20 种类型,在一次拉取数据中,服务器返回了 10 个帖子,它们的类型都不同。在滑动这 10 个帖子的时候,没有一个表项可以被复用,因为 RecyclerView 的复用是基于类型的,即滚出屏幕和滚入屏幕的表项必须是同一类型时才能复用(关于 RecyclerView 复用逻辑的分析可以点击RecyclerView 缓存机制 | 如何复用表项?),所以更好的做法是将一个帖子拆分成若干子项

微信截图_20220109162800.png 如图帖子就被拆分成了用户区、文本区、视频区、标签区、底边栏区。可以理解为本来一个完整的贴子现在被拆分成 5 个帖子,它们对应 5 个不同的 Bean,在 Adapter 中也对应着 5 个不同的策略。

这样一来,上一个视频贴的头像区滚出屏幕后,下一个音频贴的头像区就可以命中缓存,得以复用。

不过这样做也有缺点:

  1. 语义不一致:客户端眼中的帖子和服务端眼中的帖子不是同一个 Bean,客户端得把服务端认为的帖子拆分成 n 个子帖子。如果有服务端的同学看到这里,我有一个问题想请教:如果服务端也为每个帖子子区域返回不同的 json 结构,这样做的有什么不好的地方?
  2. 对于帖子整体的操作变复杂,比如删除一个帖子,现在必须根据帖子id,遍历 Adapter 的数据集,删除所有和该 id 相同的 Bean。

总结

为了让代码又丑又长又难以维护,必须遵循以下原则:

  1. 写代码之前不要做无为的设计,不要去辨别“会发生变化的逻辑”及“不变的逻辑”。迭代的推进是变幻莫测的,这些预先的设计,都将沦为“过度设计”。
  2. 在遇到多类型的问题时(且类型的个数是会变化的),忘掉多态,千万不要使用编程语言已经预设多态机制,比如继承、接口、重载。只能使用 if-else 来做分类讨论。
  3. 遵循JRY原则(just repeat yourself),ctrl + c 和 ctrl + v 技能捏在手里,时刻准备着复制粘贴,让相同的代码散布在项目的各个角落,这样提桶跑路时才能隐藏更多的彩蛋。
  4. 遵循对修改开放,对扩展关闭原则,遇事不决就修改基类,让每一次修改都有更大的影响范围,这样才能和测试小姐姐成为患难之交。

猜你喜欢

转载自juejin.im/post/7051359738134544415
今日推荐