与 编译时新增行为 对应的是运行时新增行为,即当运行不同的上层代码,PayManager.pay会表现出不同的行为(多态)。
策略模式就适用于当前场景,策略模式将具体的行为和行为的使用者隔离,当行为发生变化时,行为的使用者不需要随之而变。
// 用接口定义抽象支付行为
interfacePay{
funpay
}
// PayManager 持有抽象支付行为
classPayManager{
privatevarpay:Pay
funsetPay(pay: Pay) {
this.pay = pay
}
funpay{
pay.pay
}
}
经过策略模式的封装,使用PayManager 的上层类就可以通过注入不同的Pay接口实例,在运行时动态地为PayManager 新增支付方式。
那PayManager 的上层类不也要参与编译吗?也就是说能为PayManager新增多少支付方式也是编译之前就决定的咯?
没错!动态是有层次的,通过策略模式的封装,至少PayManager 这一层实现了“运行时动态新增行为”。这样的好处是,新增支付方时 PayManager类不需要改动,即 “ 当变化发生时上层代码不需要改动” ,这句话也可以表达成: “ 在不修改既有代码的情况下扩展功能” ,这就是著名的 “ 开闭原则” 。
• “对修改关闭”的意思是:当需要为类扩展功能时,不要想着去修改类的既有代码,这是不允许的!为啥不允许?因为既有代码是由数位程序员的努力,历经了多个版本的迭代,好不容易才得到的正确代码。其中蕴含着博大精深的知识,和你不曾了解的细节,修改它一定会出bug的!
• “对扩展开放”的意思是:类的代码应该具备良好的抽象,使得扩展类的时候,不需要修改类的既有代码。
有时候修改上层类代价很高,比如PayManager 是另一个团队提供的库,若没有考虑扩展性的话,新增支付方式这个小功能就变成了跨团队协作的大需求。
仔细端详上述代码后,是不是会产生“策略模式”抽象了个寂寞,因为 demo 中的PayManager什么都没有做,要它有何用?
其实PayManager 中还应该包含一些其他的代码,比如支付之前先从自家服务器获取订单信息,或者支付结果的回调,或者支付失败后的重试逻辑,这些逻辑都不会因支付方式的改变而改变,它们就应该固定在PayManager类中。而策略模式就好像在PayManager 中打开一个小孔,上层可以根据需求塞不同的行为进来。
关于策略模式的详解可以点击一句话总结殊途同归的设计模式:工厂模式=?策略模式=?模版方法模式。
3
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。
但其实小明的复制粘贴工作也不好做:
classPayManager{
varaliPay: AliPay
varwechatPay:WECHATPay
varresultHandler: Handler // 支付结果处理器
varretryRunnable: Runnable // 支付失败后的重试逻辑
funpay(type: String) {
if(type == 现金){
payByCash
} elseif(type == 支付宝) {
payByAli
} elseif(type == 微信) {
payByWechat
}
}
privatepayByAli{...}
}
支付宝的支付逻辑不全是被一个私有方法包裹的,还有散落在 PayManager 内部的各种成员变量。这种场景下,将方法复制到另一类中,通常会有很多报错,然后再一个个来回复制粘贴成员变量。(低耦合高内聚的代码复制粘贴后不会报错)
可想而知,随着分店的变多,现有架构为适应变化的改动会成倍地增加,虽然小明加班时间越来越长,但交付速度和质量却越来越差。慢慢地,小明也进入了彻夜无眠的状态。
4
项目实战中的多态现状
上述故事纯属虚构,但雷同的情节经常发生在日常的开发过程中,下面举一个真实项目中的例子。
这是一个 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方法:
publicint getType {
if(timeline != 0) {
returnTYPE_TIMELINE_YEAR;
}
if(TextUtils.equals(category, CATEGORY_BEHAVIOR)) {
if(type == TYPE_IMAGE || type == TYPE_VOICE) {
returnTYPE_POST_CHECK;
} elseif(type == TYPE_TEXT || type == TYPE_VOICE_COMPLEX || type == TYPE_STREET_NORMAL) {
returnTYPE_BEHAVIOR;
} else{
returnTYPE_NOT_SUP;
}
}
if(TextUtils.equals(category, CATEGORY_POKE)) {
returnTYPE_POINT;
}
if(mPostAdvert != null) {
if(mPostAdvert == PostAdvert.RulesAdvert.INSTANCE) {
returnTYPE_RULE;
}
if(mPostAdvert instanceof PostAdvert.TopicHeaderAd) {
returnTYPE_TOPIC_HEADER;
}
if(mPostAdvert instanceof PostAdvert.PartyAdvert) {
returnTYPE_RECOMMEND_JOIN;
}
if(mPostAdvert instanceof PostAdvert.StreetLaneAdvert) {
returnTYPE_RECOMMEND_CIRCLE;
}
if(mPostAdvert instanceof PostAdvert.VoiceLaneAdvert) {
returnTYPE_RECOMMEND_AU;
}
if(mPostAdvert instanceof PostAdvert.TestAdvert) {
returnTYPE_RECOMMEND_TEST;
}
if(mPostAdvert instanceof PostAdvert.ChannelPromoteAdvert) {
returnTYPE_CHANNEL_RECOMMEND_HEADER;
}
}
if(liveComment != null) {
returnTYPE_LIVE_COMMENT;
}
if(insertParty != null) {
returnTYPE_INSERT_PARTY;
}
if(topicBeans != null) {
returnTYPE_HOT_TOPIC;
}
if(insertTopics != null){
returnTYPE_INSERT_TOPIC;
}
if(type == TYPE_ZHUAN_FA) {
if(sourcePost == null) {
returnTYPE_ZHUAN_FA_DELETE;
} else{
int gender = UserManager.getSex.blockingGet;
if(sourcePost.selfOnly == 1){
returnTYPE_ZHUAN_FA_DELETE;
} elseif(gender == 1&& (sourcePost.publicStatus == 5|| sourcePost.publicStatus == 7)){
returnTYPE_ZHUAN_FA_DELETE;
} elseif(gender == 0&& (sourcePost.publicStatus == 4|| sourcePost.publicStatus == 6) ){
returnTYPE_ZHUAN_FA_DELETE;
} elseif(sourcePost.publicStatus == 0) {
returnTYPE_ZHUAN_FA_DELETE;
} else{
if(sourcePost.type == TYPE_IMAGE || sourcePost.type == TYPE_STREET_INVITE) {
returnTYPE_ZHUAN_FA_TEXT_PHOTO;
} elseif(sourcePost.type == TYPE_VOICE || sourcePost.type == TYPE_DUET || sourcePost.type == TYPE_VOICE_COMPLEX) {
returnTYPE_ZHUAN_FA_AUDIO;
} elseif(sourcePost.type == TYPE_VIDEO || sourcePost.type == TYPE_MOVIE) {
returnTYPE_ZHUAN_FA_VIDEO;
} else{
returnTYPE_ZHUAN_FA_TEXT;
}
}
}
}
if(type == TYPE_TEXT) {
returnTYPE_ITEM_TEXT;
}
if(type == TYPE_IMAGE) {
returnTYPE_ITEM_PHOTO;
}
if(type == TYPE_VOICE) {
returnTYPE_ITEM_VOICE;
}
if(type == TYPE_VOICE_COMPLEX) {
if(ObjectsCompat.nonNull(songDet) &&
!TextUtils.isEmpty(songDet.getFirstParagraph)) {
returnTYPE_ITEM_VOICE_CONTROL_OLD;
} else{
returnTYPE_ITEM_VOICE_CON;
}
}
if(type == TYPE_STREET_INVITE) {
returnTYPE_STREET_SHARE;
}
if(type == TYPE_DUET) {
returnTYPE_CHORUS;
}
if(type == TYPE_MOVIE) {
returnTYPE_VIDEO;
}
returnTYPE_NOT_SUPPORT;
}
为了获得帖子类型,不得不结合 type 字段及其他 N 个字段进行综合比对,复杂度之高,让人彻夜无眠。
每新增一个类型,就不得不为上帝PostBean 新增一个字段,让他更加无所不知,并且还得让已经长的无法看懂的getType 方法更加看不懂。
随着迭代的进行,新的类型不断地再被插入到 Feeds 流中,比如:
对于服务器来说,上图中的推荐话题来自于另一个服务,所以客户端得从一个新的接口中获取数据。这样整个 feeds 流的内容就来自于两个接口。
虽然这次服务器无法继续沿用宽字段的思想,将推荐话题作为新增字段插入到原本的大 json 中,但客户端的思维惯性让PostBean 又新增了一个成员变量。
其实这是不得已为之,因为用于展示 feeds 流的适配器被定义成如下这个样子:
classFeedsAdapter: ListAdapter<PostBean, RecyclerView. ViewHolder> {}
FeedsAdapter 是“单类型列表适配器”,它只能适配一种数据类型,即PostBean。所以即使服务器返回了一种新类型,客户端也不得不将新类型作为PostBean 的一个成员变量,假装它好像是一个PostBean。
这还不是最糟的,下面才是故事的高潮。
由于服务端和客户端实现方案的各种包袱,不同帖子的展示及交互逻辑不得不在 Adapter 中通过一个超级大的if-else 来完成:
classFeedsAdapter:ListAdapter<PostBean, RecyclerView.ViewHolder> {
overridefunonCreateViewHolder(parent: ViewGroup, viewType: Int) : ViewHolder {
if(viewType == 1){
create1ViewHolder
} elseif(viewType == 2) {...}
...
}
overridefunonBindViewHolder(holder: ViewHolder, position: Int) {
if(holder is1ViewHolder){
holder.bind
} elseif(holder is2ViewHolder) {...}
...
}
overridefungetItemViewType(position: Int) : Int{
returndata[position].getType
}
}
客户端的FeedsAdapter 类的长度是 2000+ 行,这已经快彻夜难眠了。
每次新增类型,PostBean 和FeedsAdapter,这两个上帝类都会变得更加上帝一点。(这违反了开闭原则)
更要命的是,帖子不止出现在这一个界面中,整个 app 有 6 个不同的界面需要展示帖子,因为帖子的展示及交互逻辑写在if-else 中,所以它无法被复用,只能通过复制粘贴到另外 5 个 Adapter。每次新增帖子类型,或是改动某个帖子的交互的工作量直接乘以 6。对新人也及其不友好,他不熟悉业务,他不知道代码里还有 5 个坑等着他,他得知这个坏消息的途径很可能是测试提的 bug。
5
一种多态解决方案
为了解决复杂度高、扩展性差、无法复用这三个缺点。我抛砖引玉一套解决方案:
服务端弃用宽字段
服务端不再返回包含所有冗余字段的大 json,而是改用下面的形式:
{
feeds:[
{
type: 1,
data: {
text: ""
}
},
{
type: 2,
data: {
imgUrls: []
}
}
]
...
}
即为每种类型配置一个单独的 json 结构,每一个 json 结构对应于客户端的一个实体类。
classBaseBean{
Stringtype
}
这是实体类的基类,它包含了所有实体类共有的字段 type,解析时就通过该字段实现多态。
// 文本贴实体类
classTextBean: BaseBean{
Stringtext
}
// 图片帖实体类
classImageBean: BaseBean{
List< String> imageUrls
}
客户端在解析这种多类型 json 时,需要通过继承JsonDeserializer自定义一个解析器:
classMyDeserializerimplementsJsonDeserializer< List< BaseBean>> {
@Override
publicList<BaseBean> deserialize(JsonElement element, Type type,JsonDeserializationContext context) throws JsonParseException {
JsonArray array= element.getAsJsonArray;
List<BaseBean> list= newArrayList<>;
for(JsonElement e : array) {
int type = e.getAsJsonObject.get( "type").getAsInt;
if(type == 1) {
list.add( newGson.fromJson(e, TextBean.class));
} elseif(type == 2) {
list.add( newGson.fromJson(e, ImageBean.class));
}
}
returnlist;
}
}
这是整个方案中唯一出现的if-else 代码块。
网络请求后客户端拿到的是List<BaseBean>,但这个列表中的每个元素通过继承实现了多态。
这个方案可以提高客户端内存和 CPU 性能,首先服务端返回的 json 串变小了,而且每个帖子需要解析的字段变少了(原来是不管哪种类型的帖子都要解析 53 个字段)。
多类型列表适配器
下一个问题就是如何设计一个多类型列表适配器,我在策略模式应用 | 每当为 RecyclerView 新增类型时就很抓狂这篇文章中做了详细的分析。
简单总结如下:
1. 把表项展示和数据绑定抽象为策略,策略的声明带有泛型,以表示遇到哪个类型的数据时使用该策略。Adapter 持有一组策略和一组抽象数据List<Any>。新增类型即是注入一个新的策略。(Adapter 不需要修改)
2. Adapter 的主要功能是为不同的数据类型匹配不同的策略。这样一来,每一个表项的展示和交互就被包裹在一个独立的策略类中,它可以随意的注入到任何 Adapter 中,以实现不同界面的复用。
性能优化
最后还有一个出于性能的考虑。
如图帖子就被拆分成了用户区、文本区、视频区、标签区、底边栏区。可以理解为本来一个完整的贴子现在被拆分成 5 个帖子,它们对应 5 个不同的 Bean,在 Adapter 中也对应着 5 个不同的策略。
这样一来,上一个视频贴的头像区滚出屏幕后,下一个音频贴的头像区就可以命中缓存,得以复用。
不过这样做也有缺点:
1. 语义不一致:客户端眼中的帖子和服务端眼中的帖子不是同一个 Bean,客户端得把服务端认为的帖子拆分成 n 个子帖子。如果有服务端的同学看到这里,我有一个问题想请教:如果服务端也为每个帖子子区域返回不同的 json 结构,这样做的有什么不好的地方?
2. 对于帖子整体的操作变复杂,比如删除一个帖子,现在必须根据帖子id,遍历 Adapter 的数据集,删除所有和该 id 相同的 Bean。
6
总结
为了让代码又丑又长又难以维护,必须遵循以下原则:
1. 写代码之前不要做无为的设计,不要去辨别“会发生变化的逻辑”及“不变的逻辑”。迭代的推进是变幻莫测的,这些预先的设计,都将沦为“过度设计”。
2. 在遇到多类型的问题时(且类型的个数是会变化的),忘掉多态,千万不要使用编程语言已经预设多态机制,比如继承、接口、重载。只能使用 if-else 来做分类讨论。
3. 遵循JRY原则(just repeat yourself),ctrl + c 和 ctrl + v 技能捏在手里,时刻准备着复制粘贴,让相同的代码散布在项目的各个角落,这样提桶跑路时才能隐藏更多的彩蛋。
4. 遵循对修改开放,对扩展关闭原则,遇事不决就修改基类,让每一次修改都有更大的影响范围,这样才能和测试小姐姐成为患难之交。
最后推荐一下我做的网站,玩Android: wanandroid.com,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!
:
官方还有这个控件?小红点,so easy~
浅谈2022Android端技术趋势,什么值得学?
难得的App启动优化分析好文!
点击关注我的公众号
如果你想要跟大家分享你的文章,欢迎投稿~
┏(^0^)┛明天见!返回搜狐,查看更多
责任编辑: