最近在做重构,将一个 2000+行代码的详情页重构到不足 200 行。200 行的代码实现 2000+行代码的的业务逻辑?!当然不是了。我只是把原本耦合在一个页面的业务逻辑合理有效的拆分到多个模块中了。
1 背景
传统的 MVC 模式,业务代码都堆积在 Activity 中。随着业务需求的复杂,我们的页面文件代码量越来越多,类似房源详情这种可以达到 2K+行。
例如 Link 的新房详情页面,2182 行(单 onClick 回调方法占据了 265 行)。这样的代码存在着诸多问题,耦合性高,可读性差,维护成本非常大。本次采用新的架构方法(MVP+模块化),可以有效的解决以上问题。MVP 模式解耦合。模块化职责分明,大大减少单文件代码量,有效提升可读性,降低维护成本。
2 架构方案
基于 MVP 的设计模式,组合模块化(Part&ViewPart)。
3 方案概述
3.1 MVP
MVP 模式这里不多说,与传统的 MVP 模式不同的是,我们允许一个页面可以存在多个 P,多个 V,一个 P 可以对应多个 V。这样是为了我们每个模块可以完成自己的职责。P 层由 Activity 统一管理。当然如果一个 PV 独立于页面(且有复用性),则可以独立出这个 PV。
3.2 模块化
模块化:简单的讲就是讲业务拆分为多个模块,每个模块尽可能独立的负责自己的逻辑处理与 UI 展示。这样以来就实现了职责分明,并且大大减少了主 Activity 或 Fragment 的代码量,而且维护起来也比较方便。
3.2.1 模块化的方式
我们使用 Part&ViewPart 来实现模块化。
3.2.2 Part
Part 是我们一个页面的大容器中的每个模块,我们称之为 Part.这里的大容器就是我们页面的流式容器,Part 要求对应的大容器必须是 LinearLayout。
我们提供一个 BasePart 抽象类,所有的 Part 都继承该类。我们先看一下 BasePart 的代码:
1/** 2 * 模块化基类<br/> 3 * 必须满足以下条件:<br/> 4 * 1.父容器必须是LinearLayout<br/> 5 * 2.父容器的所有view都必须是通过Part添加的<br/> 6 * 3.必须通过@#query方法创建part<br/> 7 * 4.View添加位置必须固定且已知,暂不支持动态权重<br/> 8 */ 9public abstract class BasePart<T> { 10 private Context mContext; 11 private LayoutInflater mInflate; 12 private View mView; 13 private ViewGroup mParent; 14 private T mData; 15 private String tag; 16 17 public static <D extends BasePart> D query(LinearLayout parent, Class<D> clazz) { 18 return query(parent, clazz, null); 19 } 20 21 public static <D extends BasePart> D query(LinearLayout parent, Class<D> clazz, String tag) { 22 BasePart target; 23 if (TextUtils.isEmpty(tag)) { 24 tag = clazz.getSimpleName(); 25 } 26 // 先根据tag从parent中取,如果没找到就创建。 27 // 这里不check数据,调用bindData方法后再处理数据,回调onBindData 28 target = findPartByTag(parent, tag); 29 if (target == null) { 30 target = createPart(clazz); 31 target.mParent = parent; 32 target.mContext = parent.getContext(); 33 target.mInflate = LayoutInflater.from(target.mContext); 34 target.tag = tag; 35 } 36 return (D) target; 37 } 38 39 public void bindData(T data) { 40 mData = data; 41 42 LinkedHashMap<String, Boolean> mStateMap = (LinkedHashMap) mParent.getTag(R.id.part_state); 43 if (mStateMap == null) { 44 mStateMap = new LinkedHashMap<>(); 45 mParent.setTag(R.id.part_state, mStateMap); 46 } 47 48 if (isValid(data)) { 49 if (mView == null) { 50 mView = onCreateView(); 51 ButterKnife.bind(this, mView); 52 mView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { 53 @Override 54 public void onViewAttachedToWindow(View v) { 55 56 } 57 58 @Override 59 public void onViewDetachedFromWindow(View v) { 60 onDestroyView(); 61 } 62 }); 63 // fixme 只有数据有效的part才会存储到view中 64 mView.setTag(R.id.part_cache, this); 65 // 把这个对象和view绑定 66 init(mView); 67 // 如果当前存储的数据中不包含这个tag,那么就直接add 68 if (!mStateMap.containsKey(tag)) { 69 mParent.addView(mView); 70 } else { 71 mParent.addView(mView, findAddPosition(mStateMap, tag)); 72 } 73 mStateMap.put(tag, true); 74 } 75 onBindData(data); 76 } else { 77 if (mView != null) { 78 mParent.removeView(mView); 79 } 80 mStateMap.put(tag, false); 81 } 82 } 83 84 protected final View inflate(@LayoutRes int resource) { 85 return mInflate.inflate(resource, mParent, false); 86 } 87 88 /** 89 * 数据是否有效 90 * 91 * @param data data 92 * @return false will remove relative view 93 */ 94 protected abstract boolean isValid(T data); 95 96 protected abstract View onCreateView(); 97 98 protected void onDestroyView() { 99 }100101 /**102 * 执行在{@link #bindData(Object)}之后,{@link #onBindData(Object)}之前,{@link #isValid(Object)==true}且view没有初始化时才会回调103 */104 protected abstract void init(View view);105106 /**107 * 只用于子类实现,外界调用{@link #bindData(Object)}。108 * 该方法在{@link #isValid(Object) == true }时才回调109 *110 * @param data data111 */112 protected abstract void onBindData(T data);113114 private static <D extends BasePart> D createPart(Class<D> clazz) {115 try {116 return clazz.getConstructor().newInstance();117 } catch (Exception e) {118 throw new RuntimeException(e);119 }120 }121122 private int findAddPosition(LinkedHashMap<String, Boolean> hashMap, String tag) {123 // 查找指定tag之前的所有被添加的view的个数,作为add时候的index,必须保证该容器的所有view都是通过part添加的124 int count = 0;125 for (Map.Entry<String, Boolean> entry : hashMap.entrySet()) {126 if (entry.getKey().equals(tag)) return count;127 if (entry.getValue()) count++;128 }129 return count;130 }131132 /**133 * find target by tag,null if not exist134 *135 * @param tag tag136 */137 private static BasePart findPartByTag(ViewGroup parent, String tag) {138 for (int i = 0; i < parent.getChildCount(); i++) {139 View view = parent.getChildAt(i);140 BasePart part = (BasePart) view.getTag(R.id.part_cache);141 if (part.tag.equals(tag)) {142 return part;143 }144 }145 return null;146 }147148 public T getData() {149 return mData;150 }151152 protected Context getContext() {153 return mContext;154 }155156 public LayoutInflater getInflate() {157 return mInflate;158 }159160 protected View getView() {161 return mView;162 }163164 protected ViewGroup getParent() {165 return mParent;166 }167}
复制代码
简单描述下,BasePart 所做的事情:根据子类判断的数据是否有效,当数据有效时才会将子模块 view 添加到父容器中。这样无效的数据模块也就不会被添加进来,避免不必要的渲染浪费。添加进来的 view 会和对应的 part 做一个映射,并将 part 缓存下来,支持单独刷新某个 Part。
下面来看下 Part 的使用方式。
首先我们要实现一个 Part,BasePart 是个抽象类,必须实现 4 个抽象方法。
1public class XxxPart extends BasePart<T>{ 2 @Override 3 protected boolean isValid(T data) { 4 return false; 5 } 6 7 @Override 8 protected View onCreateView() { 9 return null;10 }1112 @Override13 protected void init(View view) {1415 }1617 @Override18 protected void onBindData(T data) {1920 }21}
复制代码
除了上面四个必须实现的方法,BasePart 还提供一个非抽象的方法,==onDestroyView()==,用于处理销毁逻辑,如 Handler,请求等。
接下来看一下,Part 是怎么加载、调用的。Part 的使用很简单,只需要调用 query 方法,并 bindData 就够了。query 方法返回 Part,因此支持模块间的相互调用及跨模块更新。
1public static <D extends BasePart> D query(LinearLayout parent, Class<D> clazz, String tag){...}
复制代码
简单分析一下。query 需要 3 个参数。
parent 即该模块所在的父容器,也就是我们前面说的页面大容器。
clazz 模块 Part 的 class 对象(是的,这里用的反射构建的 Part 的实例)
tag 每个 Part 都会有一个 tag 与之对应,为了我们缓存 part。如果没有设置,会默认取 class.simpleName
3.2.3 ViewPart
前面有说到我们这里的模块化分为 Part 和 ViewPart。这里的 ViewPart 其实就是一个 View。在非大容器中的模块,我们可以使用 ViewPart 的方式将其模块化。我们模块化的原则是尽量将各个模块的业务交由各个模块自己完成,职责分明,逻辑清晰。
简单介绍完这些概念之后,下面来具体看一下重构的过程。
4 重构过程
4.1 梳理逻辑模块
新房房源详情页大大小小加一起,一共 14 个业务逻辑。其中除引导图外,其他我们均采用模块化的方式处理。分享逻辑和底部 Bar 采用 ViewPart 的方式,其他采用 Part 的方式。HouseDetailActivity 采用 MVP 的模式。
分享引导图
分享逻辑
楼盘信息模块
一房一价模块
其他信息模块
竞对盘推荐模块
激励政策 模块
动态模块
特价房模块
客户规则模块
佣金*规则模块
楼盘销售特点模块
户型模块
底部 bar
4.2 主页面 MVP 架构
首先看一下,详情页整体的 MVP 契约类。
1public interface HouseDetailContract { 2 interface Present extends BasePresenter<View> { 3 void getHouseResponse(String projectId); 4 5 void getShareResponse(String projectId); 6 7 void follow(String projectId, boolean follow); 8 } 910 interface View extends BaseView {11 void onGetHouseResult(@NonNull NewHouseResBlockDetailBean data);1213 void onUpdateStateLayout(boolean isSuccess, boolean netError);14 }1516 interface IFollowView extends IBaseView {17 /**18 * @param followed 是否已关注19 * @param error null表示成功,false表示失败20 */21 void updateFollowState(boolean followed, String error);22 }2324 interface IShareView extends IBaseView {25 void onShare(ShareDialog.ShareToThirdAppBean shareBean, ShareDialog.ShareToSmsBean smsBean);2627 void onGetShareResultFail(String error);28 }29}
复制代码
可以看到,这里一个 P,有多个 V。且逻辑中还有两个比较独立且被复用的 PV,抽离出来了,这里不再列举。简单看下主 Present 的实现。
1public class HouseDetailPresent extends HttpPresenter<HouseDetailContract.View> 2 implements HouseDetailContract.Present { 3 4 private HouseDetailContract.IFollowView iFollowView; 5 private HouseDetailContract.IShareView iShareView; 6 7 public void attachFollowView(HouseDetailContract.IFollowView iFollowView) { 8 this.iFollowView = iFollowView; 9 }1011 public void attachShareView(HouseDetailContract.IShareView iShareView) {12 this.iShareView = iShareView;13 }1415 @Override16 public void getHouseResponse(String projectId) {17 enqueue(ApiClient.create(HouseApi.class).getHousesDetailResult(projectId),18 new SimpleCallback<Result<NewHouseResBlockDetailBean>>() {1920 @Override21 protected void onNetworkError(HttpCall<Result<NewHouseResBlockDetailBean>> call, Throwable t) {22 mView.onUpdateStateLayout(false, true);23 }2425 @Override26 public void onResponse(HttpCall<Result<NewHouseResBlockDetailBean>> call,27 Result<NewHouseResBlockDetailBean> entity) {28 if (hasData()) {29 mView.onGetHouseResult(entity.data);30 mView.onUpdateStateLayout(true, false);31 } else {32 mView.onUpdateStateLayout(false, false);33 }34 }35 });36 }3738 @Override39 public void follow(String projectId, final boolean follow) {40 enqueue(ApiClient.create(HouseApi.class).followResblock(projectId, follow ? 1 : 0),41 new SimpleCallback<Result>() {42 @Override43 public void onResponse(HttpCall<Result> call, Result entity) {44 if (isSuccess()) {45 iFollowView.updateFollowState(follow, null);46 } else {47 iFollowView.updateFollowState(follow,48 Result.getErrorMsg(entity, follow ? "关注失败" : "取消关注失败"));49 }50 }51 }, true); // 是否需要loading52 }5354 @Override55 public void getShareResponse(String projectId) {56 ...57 }58}
复制代码
主 Present 会绑定其他两个模块的 V,并完成对应的 P 层逻辑和 V 的回调。
再来看下我们的详情页,这里重点看下,获取到数据之后加载 part 的处理逻辑。
1@Override 2 public void onGetHouseResult(@NonNull NewHouseResBlockDetailBean data) { 3 this.bean = data; 4 setTitle(data.name); 5 bottomPart.bindData(data); 6 sharePart.bindData(data.projectName); 7 initDMShare(); 8 BasePart.query(container, HouseDetailInfoPart.class).bindData(data); 9 BasePart.query(container, OnePricePart.class).bindData(data);10 BasePart.query(container, HouseDetailOtherInfoPart.class).bindData(data);11 BasePart.query(container, RecommendPart.class).bindData(data);12 BasePart.query(container, IncentivePolicyPart.class).bindData(data.incentivePolicy);13 BasePart.query(container, DynamicPart.class).bindData(data.newDynamic);14 BasePart.query(container, DiscountHousePart.class).bindData(data);15 BasePart.query(container, CustomerRulePart.class).bindData(data.customerRule);16 BasePart.query(container, CommissionRulePart.class).bindData(data.commissionRule);17 BasePart.query(container, SaleFeaturePart.class).bindData(data.saleFeature);18 BasePart.query(container, FramePart.class).bindData(data);19 }
复制代码
所有根据返回数据操作 UI 及逻辑都在这个方法里。其中 bottomPart 和 sharePart 就是两个普通的 View。
下面看一个简单的 Part。
1public class IncentivePolicyPart extends BasePart<NewHouseResBlockDetailBean.IncentivePolicy> { 2 3 @BindView(R2.id.tv_encourage_policy_detail) 4 CommonTextView tvEncouragePolicyDetail; 5 6 @Override 7 protected boolean isValid(NewHouseResBlockDetailBean.IncentivePolicy data) { 8 return data != null && !TextUtils.isEmpty(data.excitationPolicy); 9 }1011 @Override12 protected View onCreateView() {13 return inflate(R.layout.lib_newhouse_detail_encourages_policy);14 }1516 @Override17 protected void init(View view) {18 view.setOnClickListener(new View.OnClickListener() {19 @Override20 public void onClick(View v) {21 if (!TextUtils.isEmpty(getData().excitationPolicyUrl)) {22 CommonWebActivity.startActivity(getContext(), getData().excitationPolicyUrl,23 getContext().getString(R.string.newhouse_jili_zhengce));24 }25 }26 });27 }2829 @Override30 protected void onBindData(NewHouseResBlockDetailBean.IncentivePolicy bean) {31 tvEncouragePolicyDetail.setText(bean.excitationPolicy);32 }33}
复制代码
View 创建和初始化,数据源的有效性判断和数据绑定,层次分明,方便维护。
ViewPart 的代码稍多一些,这里简单取一部分代码。
1public class HouseDetailBottomPart extends LinearLayout implements HouseDetailContract.IFollowView { 2 ... // ButterKnife 初始化view 3 4 // 绑定present 和V 5 public void setPresent(HouseDetailPresent present) { 6 this.present = present; 7 present.attachFollowView(this); 8 } 910 // 绑定数据,更新UI11 public void bindData(NewHouseResBlockDetailBean bean) {12 ...13 }1415 @OnClick(R2.id.ll_attention)16 void onFollow() { // 关注/取消关注17 present.follow(bean.projectId, bean.isFollow == 0);18 }1920 ...2122}
复制代码
可以看到这里的 PartView 也用了 MVP,自身便是个 V。
到这里,我们已经大致看完了,Part 模式相关的全部内容。下面来总结下 Part 的优点和存在的不足。
5 优点
6 不足
Part 的方式并不适用所有的页面,part 主要解决的是线性容器下多模块的场景
作者介绍:
公台(企业代号名),目前负责贝壳找房新房 B 端 Android。
本文转载自公众号贝壳产品技术(ID:gh_9afeb423f390)。
原文链接:
https://mp.weixin.qq.com/s/LLt8u5WVLxLrg_acMXLvEA
评论