DDD在网易LOFTER大型列表治理中的运用

3,663 阅读20分钟

1 引言

DDD(领域驱动设计)早在2004年由Eric Evans提出,随着软件系统日趋复杂,近两年在后端、移动端架构中得到广泛的运用。DDD在不同的领域有不同的架构形态,例如后端侧重分布式服务,在微服务架构中DDD会围绕核心服务而建设,移动端更侧重UI交互,于是产生The Clean Architecture、VIPER这样的主流架构。移动端DDD架构虽然已有多年历史,但主要关注页面维度,尽可能让页面渲染、交互、数据请求和存储等逻辑各司其职,很少关注到页面内部的UI元素维度,而列表就是移动端最常见的UI元素,几乎每个页面都有列表,大部分页面剥离列表后只剩下一个空壳页面,页面渲染实际上是列表的渲染。对于Android而言,MVVM最多只描述了让Activity/Fragment级界面订阅UI状态的更新,但不会约定RecyclerView内每个item如何更新。典型的VIPER架构:

截图.png

那么,何谓大型列表?Lofter作为年轻人的兴趣社区,比较注重内容能丰富而高效地展现给用户,很多内容聚合页的RecyclerView包含文字、图片、音乐、视频、长文章、直播、小编推荐等十余种类型,每种类型的视图内部又有很多嵌套深、交互多的元素,不同类型之间还能包含公共的元素,一些元素在不同页面场景甚至有不同的展示样式,这些元素之间的展示状态和排列关系可能还是动态的。如以下仅是关注流中的某个图片类型的Item:

截图2.png

该图片类型的item,在个人主页场景下,头部用户信息区又变为日期+合集:

截图3.png

像喜欢、点赞、收藏、评论发表、右上角菜单操作、用户信息,评论展示、审核状态等元素,对于其它类型的item(如文字、音乐、视频等)都是一样的。

承载这样巨型列表的页面,列表渲染的复杂性已远远大于采用MVC/MVP/MVVM/MVI设计的页面维度的复杂性,下面通过回顾Lofter大型列表架构的演进之路,来看如何用DDD治理大型列表架构。

2 单体阶段

这个阶段列表承载的样式都比较简单,虽然有多个类型,但无论是自己基于RecyclerView.Adapter扩展或者使用BRVAH开源库,渲染代码往往写在一个Adapter里,并不会难以维护。此时的架构示意图:

截图4.png

相当于在BRVAH的convert中写switch-case语句:

public class LofterAdapter<T extends MultiItemEntity, K extends BaseViewHolder> 
        extends BaseMultiItemQuickAdapter<T, K> {

    @Override
    public void convert(helper: K, item: T) {
        switch (item.type) {
            TEXT: 
                ...
                break;        
            PHOTO:
                ...
                break;
            VIDEO:
                ...
                break;
            MUSIC:
                ...
                break;
        }
    }
}

但是,架构的劣化往往都是从单体开始,随着业务日趋复杂,Lofter首页关注流列表DashboardAdapter代码已达8000多行,并且由于关注流样式在较多页面复用,还派生了很多子类。一旦版本迭代包含关注流的需求,研发排期都会成倍增加,于是趁着某个版本产品对关注流重新设计,开始着手对adapter进行拆分。

3 分治阶段

我们当时基于BRVAH开源库开发了一套拆分框架,叫ComMultiItemAdapter。但当时BRVAH开源库还没有支持BaseProviderMultiAdapter这样的拆分方案,当然,为了不影响今后版本升级,我们并没有对BRVAH源码进行修改,所有的改动都是基于扩展实现。列表的构造阶段不再仅关联item类型和item布局,大致写法如下:

addItemHolderController(TEXT, R.layout.text, new TextItemRender(adapterController));
addItemHolderController(PHOTO, R.layout.photo, new PhotoItemRender(adapterController));
addItemHolderController(MUSIC, R.layout.audio, new MusicItemRender(adapterController));
addItemHolderController(VIDEO, R.layout.video, new VideoItemRender(adapterController));

我们看到,列表的每种视图类型还关联了独立的渲染控制器,渲染控制器可以获取到加载的布局和条目数据(构造函数传入的adapterController的作用后面再说),此时的架构的示意图:

截图5.png

以TextItemRender为例,布局创建事件分发到doOnCreate方法,数据绑定事件分发到doOnBind方法:

public class TextItemRender extends BaseRender {
    
    @Override
    public void doOnCreate(TextItemHolder holder) {
        ...    
    }
    
    @Override
    public void doOnBind(TextItemHolder holder) {
        ...    
    }
}

public class TextItemHolder extends BaseItemHolder {
    public TextView title;
    public TextView content;
    ...
}

但不同类型的item之间并非完全相互独立的,例如文字、图片、视频、音乐卡片都有喜欢、推荐、收藏、评论、菜单项等互动操作的元素,随着版本迭代进行,这种不同类型卡片的公共元素越来越多,导致BaseRender越来越庞大,BaseRender类代码也高达2300多行,BaseRender对应的基类ViewHolder也有150个属性,用来存放所有各类型卡片都要展示的公共view引用。

我们知道,BRVAH允许item定义自己的ViewHolder类型,这样可以在item布局创建阶段,也就是doOnCreate方法中可以通过findViewById预先将View元素的引用保存在ViewHolder的属性中,然后在数据绑定阶段可以快速地更新View元素的状态,如上面的TextItemHolder。这种模式早已耳熟能详,看似非常好用,但当item布局复杂,所有的View元素都会排列在ViewHolder中,导致ViewHolder会有非常多的属性,且不同类型之间的公共元素会排列在BaseItemHolder中,最终我们很难找到关心的View元素在哪里。

落地到具体的页面开发中,我们会发现渲染控制器仅仅有以上两个方法,相对其需要满足的业务场景来说还显得过于单薄,例如缺少页面生命周期、滚动事件监听、图片加载、视频播放等基础能力,这就是渲染控制器构造函数中传入adapterController参数的作用。RecyclerView Adapter Controller的职责主要扮演了Activity和RecyclerView之间的中介者的角色,在某些特殊场景下,可能还会与Presenter/ViewModel发生联系,RecyclerView Adapter Controller最终也将变为一个“垃圾堆”。其典型的示意图如下:

截图6.png

该阶段下,看似大型列表得到了治理,但弊端也同样明显。

注:BRVAH最新版本,已支持注册各类型的ItemProvider,在ItemProvider里完成布局的加载和数据绑定,具体使用这里不作表述,可参见官方介绍,本质上与ComMultiItemAdapter的功能相似。

4 DDD阶段

在分治阶段,Lofter关注流卡片样式还没有引言的复杂,在后端接口数据模型重构和关注流按照最新样式改版的背景下,分治阶段的ComMultiItemAdapter框架已无法适应团队业务发展的节奏,此时我们非常迫切需要一个能满足多人协作开发和维护需要的大型列表治理框架。幸运的是,我们还有DDD,DDD从诞生之日起就是为了应对软件系统的复杂性,虽然DDD在移动端已产生像VIPER、The Clean Architecture的标准化架构,但在这种大型列表治理的更细分的领域下却鲜有优秀案例。那么,是否能直接用The Clean Architecture来治理大型列表呢?当你深入思考后会发现各种不兼容,这是因为软件架构的本质是为业务场景服务,同一种架构思想在不同的业务场景下必然会有差异化甚至特殊化的形态,这都意味着任何已有DDD架构都无法生搬硬套到大型列表治理中。

4.1 DDD规划

限界上下文的划分是DDD建模阶段最关键的一步,我们回过头来看RecyclerView图片类型的卡片,我们将卡片切分为很多bar,有的bar是多个类型卡片公共的,bar和bar之间在UI及交互上天然分割,虽然bar和bar之间会有访问,例如点操作栏的收藏按钮会在内容底部出现已收藏的提示条,但bar的业务边界已非常明确,故bar作为DDD的限界上下文再合适不过。当定义了bar后,我们不再惧怕不同类型卡片之间有太多公共元素而导致BaseRender变得臃肿不堪,我们只需为各类型卡片编排所需的bar即可。以下是bar的部分划分方式:

截图7.png

对于文章卡片领域,我们枚举所有视图类型的bar,用DDD划分为各个限界上下文,注意bar和限界上下文之间是充分非必要关系。图片加载器和视频播放器(包含列表自动播放)作为某些bar需要使用的通用能力,同时解耦领域逻辑对图片加载和视频播放底层库的依赖,并对底层基础能力进行封装,可成为单独的限界上下文,这也符合关注点分离原则。注意,bar的划分是非常灵活的,并非要界面上连续的矩形方块,亦或要满足一定条件才可见,甚至并不一定要满足UI可见,例如以下事件消费bar,仅用来拦截touch事件,处理整个卡片区域的双击喜欢动效、长按弹出菜单、点击空白背景跳转文章详情等手势行为。

截图8.png

部分bar之间存在一定程度的合作,例如评论输入bar发布评论后需要在评论展示bar展示刚发布的评论,操作栏bar点收藏操作后会展示收藏成功反馈bar,仅收藏操作成功才展示。bar可作为每个子域内的聚合根,由于bar内的逻辑比较相似,各个子域可以基于聚合根协议ItemPartView扩展自己的子域逻辑。我们以最简单的调试信息展示DebugInfoBar(方便查看item在当前列表中的位置)为例,省略掉属性注入方法后,主要方法如下:

class DebugInfoBar : FrameLayout, ItemPartView {

    // bar的充血模型,后面叙述
    private var mItemPartLayoutMan: ItemPartLayoutMan<*, *, *>? = null
    // 工具类
    private var adapterContext: UnityAdapter.IAdapterContext? = null
    // ViewHolder
    private var mItemViewHolder: ItemViewHolder? = null
    // item对应的列表中的数据
    private var itemModel: PostCardModel? = null

    // findViewById保存在bar内,而不是ViewHolder中
    private var hintTv: TextView? = null

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    init {
        View.inflate(context, R.layout.item_part_debug_info_bar, this)
    }

    // 对应RecyclerView onCreateViewHolder
    override fun onCreate() {
    }

    // 对应RecyclerView onBindViewHolder
    override fun onUpdate(itemModel: Any?) {
        itemModel ?: return
        visibility = View.GONE
        val position = mItemViewHolder?.itemPosition ?: -1
        hintTv?.text = "我在第${position}位置 ${TrackerHelper.getPostCard2StatisType(mItemViewHolder?.itemViewType!!)}"
        if (position == -1) {
            setBackgroundColor(Color.BLUE)
        } else {
            setBackgroundColor(if (position % 2 == 0) {
                Color.GREEN
            } else {
                Color.RED
            })
        }
    }

    // 对应RecyclerView onBindViewHolder局部更新
    override fun onUpdate(itemModel: Any?, payloads: List<Any>?) {
    }

    // 图片加载失败时恢复加载,为扩展方法,由DDD框架自动调用
    override fun reloadImage() {
    }

    override fun onFinishInflate() {
        super.onFinishInflate()
        hintTv = findViewById(R.id.hint_tv)
    }

}

乍一看onCreate、onUpdate方法跟分治阶段的实现类似,不过就是接收了RecyclerView的onCreateViewHolder和onBindViewHolder通知,但思维方式已发生了微妙的转变,即我们将通知的接收放到了RecyclerView Item的每个bar中,这样传统View对象不再是分治阶段中的贫血模型的对象,仅仅是一个裸布局,必须得在View的外部完成id查找、点击事件监听、数据绑定、渲染等基本操作,这些切片View可自己完成这一系列过程。

这对工程维护来说会有怎样的变化呢?如果将ItemPartView的各种操作逻辑放到外部,最终会变得散乱不堪,查找和迭代都会极其不便,我们以点击事件监听为例,在分治阶段,使用BRVAH框架一般会这样写:

// 创建Adapter时(adapter为BaseQuickAdapter类型)
adapter.setOnItemChildClickListener(object : OnItemChildClickListener {
    
    override fun onItemChildClick(adapter: BaseQuickAdapter, view: View, position: Int) {
        when (view.id) {
            R.id.item_view_1 -> {
                // firstly, get item data from view or view holder                            
            }
            R.id.item_view_2 -> {
               // firstly, get item data from view or view holder             
            }     
        }    
    }
})

// 创建item view时(viewHolder为BaseViewHolder类型)
viewHolder.addOnClickListener(R.id.item_view_1, R.id.item_view_2);

但创建Adapter时一般是在Activity/Fragment中,创建item view时是在Item Render中,将点击事件的注册和回调拆成了两个步骤,大型列表还有很多类型的item,item内布局又极其复杂,view id往往有数十上百个,它们的注册和回调没有统一的地方管理,对维护造成不变。此外,点击事件的回调逻辑onItemChildClick并不在开发者所关注的item view的代码内,当工程庞大,容易导致开发者产生遗漏,我们线上有发生过修改item内view曝光埋点参数但忘记修改点击事件埋点的案例。

4.2 DDD治理

在分治阶段,往往会为各个类型定义特定的ViewHolder,其中的属性专门用来保存findViewById的引用,当不同类型卡片的公共元素越来越多,会导致基类ViewHolder的属性个数越来越多,如果没有详尽的注释,开发者会分不清哪个属性对应哪个公共元素,甚至还有ViewHolder中定义属性用来保存数据的情况,这也是我们在第二阶段遇到的痛点,如下图所示(虽然也可以使用kotlinx的synthetic工具、BRVAH的BaseViewHolder.getView方法来避免ViewHolder问题,但写法上会繁琐一些,更重要的是当某个view使用场景比较多、view id复用其它命名、view id命名不直观、IDE布局工具自动生成的view id,也无法利用ViewHolder属性更好命名所操作的元素,代码可读性会大大降低)。

截图9.png

而在bar中自行设置点击事件,可以将点击事件的注册和回调合为一步,逻辑统一内聚在开发者所关注的bar中,可以直接在bar中开辟属性保存findViewById的结果,不再需要定义任何特定类型的ViewHolder,彻底消灭了在ViewHolder中添加属性的方式,对多人维护的模块更友好。

class PostOperationBar : RelativeLayout, ItemPartView, View.OnClickListener, View.OnLongClickListener {
    
    private var mItemPartLayoutMan: ItemPartLayoutMan<*, *, *>? = null
    private var adapterContext: UnityAdapter.IAdapterContext? = null
    private var mItemViewHolder: ItemViewHolder? = null
    private var mItemModel: PostCardModel? = null
    
    private var likeBtn: View? = null //喜欢按钮
    private var recommendBtn: View? = null //推荐按钮
    private var subscribeBtn: View? = null //收藏按钮
    private var commentBtn: Viw? = null //评论按钮
    
    init {
        View.inflate(context, R.layout.item_part_post_operation_bar, this)    
    }    
    
    override fun onCreate() {
        // 优化view元素命名,onUpdate等其它方法中使用这些view元素可读性更强
        likeBtn = findViewById(R.id.btn_1);
        recommendBtn = findViewById(R.id.btn_2);
        subscribeBtn = findViewById(R.id.btn_3);
        commentBtn = findViewById(R.id.btn_4);
        subscribeBtn = findViewById(R.i.btn_5);
        
        // bar内设置点击
        likeBtn.setOnClickListener(this)
        recommendBtn.setOnClickListener(this)
        subscribeBtn.setOnClickListener(this)
        commentBtn.setOnClickListener(this)
        // bar内设置长按
        subscribeBtn.setOnLongClickListener(this)
    }
    
    override fun onUpdate(itemModel: Any?) {
        // 使用可读性更强的命名
        likeBtn?.setSelected(mItemModel?.liked)
        recommendBtn?.setSelected(mItemModel?.recommended)
        subscribeBtn?.setSelected(mItemModel?.subscribed)
        
        ...
    }
    
    override fun onUpdate(itemModel: Any?, payloads: List<Any>?) {
        ...
    }
    
    override fun reloadImage() {
        ...
    }
    
    // bar内处理点击事件
    override fun onClick(view: View) {
        // 这里用when,bar内的可点击元素一般不会很多
        when (view.id) {
            R.id.btn_1 -> {
                ...            
            }
            R.id.btn_2 -> {
                ...            
            }
            R.id.btn_3 -> {
                ...            
            }
            R.id.btn_4 -> {
                ...            
            }
            R.id.btn_5 -> {
                ...            
            }
        }
    }
    
    // bar内处理长按
    override fun onLongClick(view: View): Boolean {
        when (view.id) {
            R.id.btn_5 -> {
                ...            
            }                    
        }
    }  
}

通过限界上下文的划分,我们虽然迈开了DDD治理的第一步,我们让bar能完成id查找、点击事件监听、数据绑定、渲染这一系列过程,ItemPartView成为了充血模型的对象,但是在大型列表的复杂应用场景下,仅仅成为这样的充血模型对象还是远远不够的,想象列表内视频滚出屏幕停止播放的场景、滚动停止自动选择屏幕内露出的视频播放的场景、页面进入后台停止播放视频的场景、监听用户在其它页面触发这篇文章的喜欢并同步更新当前列表中的喜欢状态的场景、页面销毁时释放bar的额外资源,这些常见的应用场景却超出了ItemPartView现有能力,打破DDD治理的既定规约,为了不让工程走向劣化,这就需要引入bar的另一个聚合根ItemPartLayoutMan,用来监听页面生命周期、onActivityResult、滚动状态等通知,为ItemPartView赋能。

我们以用户信息bar为例,需要处理列表内该bar上的用户关注状态、合集信息的动态更新,可以在ItemPartLayoutMan的生命周期注册和销毁广播,例如在接收到关注用户广播时,需获取列表中包含该用户头像的item,最终调用RecyclerView Adapter的notifyItemChanged向bar发送局部更新:

截图10.png

截图11.png

class PostOwnerItemPartLayoutMan() : ItemPartLayoutMan<PostOwnerBar, PostCardModel, PostCardModel>() {

    // 关注用户通知
    private val followUserReceiver = object : BroadcastReceiver() {
        val actionUserId: Long = ...
        val isFollowAction: Boolean = ...
        
        adapterContext?.adapter?.data?.forEachIndexed { position, item ->
            item?.model?.let { model ->
                if (model.userId == actionUserId) {
                    adapterContext.adapter.notifyItemChanged(position, if (isFollowAction) {
                        PayLoadType.PAYLOAD_FOLLOW_ACTION
                    } else {
                        PayLoadType.PAYLOAD_CANCEL_FOLLOW_ACTION
                    })                   
                }                            
            }
        }
    }
    
    // 更新文章被加入合集通知
    private val updateCollectionInfoReceiver = object : BroadcastReceiver() {
        ...
    }
    
    override fun onContextAttached() {
        adapterContext.registerLocalBroadcastReceiver(BroadCastHelper.FOLLOW_FILTER, followUserReceiver)
        adapterContext.registerLocalBroadcastReceiver(CollectionConstant.ActionKey.ACTION_COLLECT_FILTER, updateCollectionInfoReceiver)
    }

    override fun getItemPartModel(itemModel: PostCardModel?): PostCardModel? {
        return itemModel
    }

    override fun onDestroy(owner: LifecycleOwner) {
        adapterContext.unregisterLocalBroadcastReceiver(followUserReceiver)
        adapterContext.unregisterLocalBroadcastReceiver(updateCollectionInfoReceiver)
    } 
    
    //通知该区域更新UI的行为,更新时会先检查model数据,model数据由更新发起者设置
    enum class PayLoadType {
        //关注行为
        PAYLOAD_FOLLOW_ACTION,
        //取消关注行为
        PAYLOAD_CANCEL_FOLLOW_ACTION,
        // 更新合集信息
        PAYLOAD_UPDATE_COLLECTION_INFO_ACTION
    }       
}

在用户信息bar在onUpdate方法收到局部更新通知,并更新界面:

class PostOwnerBar : FrameLayout, ItemPartView, View.OnClickListener {

    ...
    
    override fun onUpdate(itemModel: Any?, payloads: List<Any>?) {
        payloads?.let { list ->
            list.forEach { one ->
                when (one) {
                    PostOwnerItemPartLayoutMan.PayLoadType.PAYLOAD_FOLLOW_ACTION -> {
                        // 按钮设为已关注                                                
                    }
                    PostOwnerItemPartLayoutMan.PayLoadType.PAYLOAD_CANCEL_FOLLOW_ACTION -> {
                        // 按钮恢复未关注                                                
                    }
                }
            }
    }    
}

再以列表内视频播放场景为例,PostVideoPlayBar是视频播放bar,我们可以在它对应的ItemPartLayoutMan中处理滚动播放、停止播放等交互逻辑:

class PostVideoPlayItemPartLayoutMan() : ItemPartLayoutMan<PostVideoPlayBar, PostCardModel, PostCardModel>() {
    
    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        // 处理滚出屏幕停止播放
    }    
    
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
       // 处理滚动停止自动播放
    }  
    
    override fun onStop(LifecycleOwner owner) {
        // 处理视频停止播放
    }
        
}

领域内不可避免会涉及到一些中间数据的缓存,例如解析后的富文本数据、已曝光过的广告id、默认头像drawable等。分治阶段,会将数据都缓存在RecyclerView Adapter Controller中。DDD阶段,每个限界上下文都可以拥有自己的数据缓存管理类,当然实际未必需要对每个限界上下文都定义不同的类型:

// 上下文缓存管理类
class PostCardCache : IAdapterCache {
    
    // 已曝光的广告id
    val exposedAdIds = HashSet<String>()
    
    override fun clear() {
        exposedAdIds.clear()
    }
}

每个限界上下文都能通过领域工具类adapterContext访问到所需的数据缓存:

// 列表内广告曝光方法
fun trackAdExpose() {
    val adInfo = mItemModel?.adInfo ?: return
    adapterContext?.cache?.getItemCache(PostCardCache::class.java)?.let { cache ->
        if (!cache.exposedAdIds.contains(adInfo.id)) {
            adInfo.addShow()
            cache.exposedAdIds.add(adInfo.id)
        }
    }
}

领域工具类还提供了调用其它上下文服务的能力,bar只需声明自己的服务接口,其它bar就能很方便地调用到它。我们以收藏提示条bar的展示为例(点收藏按钮时内容底部出现收藏提示条bar),收藏提示条bar定义展示自己的服务接口:

interface ISubscribeToastItemService : ItemService {
    fun showWithAlphaAnim(folderName: String?)
}

收藏提示条bar实现服务接口:

class SubscribeToastBar : FrameLayout, ItemPartView, ISubscribeToastItemService  {
    
    override fun showWithAlphaAnim(folderName: String?) {
        // 根据不同item类型找到内容view锚点的位置,并调整自身位置
    }
}

我们只需在操作栏bar的收藏按钮点击事件中,调用adapterContext.itemServiceProvider.getItemService方法,传入ISubscribeToastItemService服务接口类,就能很方便地调用到收藏提示条bar的服务:

class PostOperationBar : RelativeLayout, ItemPartView, View.OnClickListener, View.OnLongClickListener {
    
    ...
    
    // bar内处理点击事件
    override fun onClick(view: View) {
        // 这里用when,bar内的可点击元素一般不会很多
        when (view.id) {
            ...
            R.id.btn_5 -> { // 收藏按钮
               doRequest { success ->
                   if (success) {
                      adapterContext?.itemServiceProvider!!.getItemService(ISubscribeToastItemService::class.java, itemViewHolder)?
                       .showWithAlphaAnim(folderName)                      
                   }               
               }            
            }
        }
    } 
}

4.3 DDD架构

经过DDD治理后,列表架构也变得更清晰,我们从页面的视角来看,完整的DDD架构如下:

截图12.png

由于团队成员对BRVAH较熟悉,为了更快落地,我们在BRVAH之上,设计了一套DDD Addapter Framework,用于支撑最基本的领域逻辑,每个item类型真正要展示bar,交给ItemLayoutMan来编排,每个ItemLayoutMan对应一个XML文件,可以采用XML里编排bar的方式完成item布局的定义,bar本身是一个ViewGroup,同时也是领域服务。页面初始化时,我们只需注册页面关心的item类型和ItemLayoutMan集合即可,页面关心的类型一般跟接口回吐的数据有关。我们以代表视频类型卡片的PostVideoItemLayoutMan为例,看下它是如何编排bar的。注册所需编排的bar:

class PostVideoItemLayoutMan() : ItemLayoutMan<ItemViewHolder, PostCardModel>() {

    override fun onContextAttached() {
        registerSimpleItemPartView(TopStubBar::class.java, BottomStubBar::class.java, DebugInfoBar::class.java, RecommendWordsBar::class.java, 
                                   PostAuditBar::class.java, PostReadNumBar::class.java, PostTextBodyBar::class.java, PostTagsBar::class.java,
                                   DoubleClickBar::class.java, SubscribeToastBar::class.java, PostTopHintBar::class.java)
        registerItemPartLayoutMan(PostOwnerItemPartLayoutMan::class.java, PostVideoPlayItemPartLayoutMan::class.java, 
                                  PostOperationItemPartLayoutMan::class.java, PostCommentListItemPartLayoutMan::class.java,
                                  PostCommentInputItemPartLayoutMan::class.java)
    }

}

然后在XML里编排和布局。在DDD治理之前,列表的item布局非常庞大,相当于每个bar的布局都在item根布局下面平铺,嵌套层级非常深,虽然有的布局有include抽取,但只是规范并非强制,导致include和平铺混用,而经过DDD治理之后,每个item的XML文件很容易维护,层级只有一级:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/lofter_background_primary">

    <lofter.component.middle.business.postCard2.common.viewstub.TopStubBar
        .../>

    <lofter.component.middle.business.postCard2.common.debug.DebugInfoBar
        .../>

    <lofter.component.middle.business.postCard2.common.audit.PostAuditBar
        .../>

    <lofter.component.middle.business.postCard2.common.owner.user.PostOwnerBar
        .../>

    <lofter.component.middle.business.postCard2.text.PostTextBodyBar
        .../>

    <lofter.component.middle.business.postCard2.video.PostVideoPlayBar
        .../>

    <lofter.component.middle.business.postCard2.common.side.PostReadNumBar
        .../>

    <lofter.component.middle.business.postCard2.common.operation.PostOperationBar
        .../>

    <lofter.component.middle.business.postCard2.common.viewstub.BottomStubBar
        .../>

    <lofter.component.middle.business.postCard2.common.toast.subscribe.SubscribeToastBar
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:layout_marginLeft="@dimen/post_card_content_left_margin"
        android:layout_marginRight="@dimen/post_card_content_right_margin"
        android:visibility="gone"/>
        
        
    ...
    

    <lofter.component.middle.business.postCard2.common.doubleClickLike.DoubleClickBar
        .../>

</androidx.constraintlayout.widget.ConstraintLayout>

页面初始化时,Activity/Fragment里构造UnityAdapter,注册所关心的ItemLayoutMan即可:

adapter = UnityAdapter(this, ArrayList<UnityItemEntity>()).apply {

    registerItemLayoutMan(UnityViewType.POST_CARD_TEXT, R.layout.post_card_item_text, PostTextItemLayoutMan::class.java) //文字类型item
    registerItemLayoutMan(UnityViewType.POST_CARD_PHOTO, R.layout.post_card_item_photo, PostPhotoItemLayoutMan::class.java) //图片类型item
    registerItemLayoutMan(UnityViewType.POST_CARD_MUSIC, R.layout.post_card_item_music, PostMusicItemLayoutMan::class.java) //音乐类型item
    registerItemLayoutMan(UnityViewType.POST_CARD_VIDEO, R.layout.post_card_item_video, PostVideoItemLayoutMan::class.java) //视频类型item
    ...
}

我们再看下DDD治理后的工程结构是怎样的,由于bar的数量非常多,我们将限界上下文进行聚类,首先将各item类型特有的bar存放到对应类型目录:

截图13.png

而像操作栏、用户信息等公共元素bar存放common目录。例如common目录里存放事件消费bar、操作栏bar:

截图14.png

bar目录里面,只约定聚合根、上下文服务放必须放在一级目录下,其它可自行存放。例如操作栏bar内点击事件逻辑比较复杂,涉及的类比较多,可在operation目录内创建一个click目录存放。

4.4 防腐

DDD中,为了避免上下文被其它上下文侵蚀,会在上下文之间引入防腐层。而在客户端大型列表领域,列表渲染数据是从后端数据转换过来的,列表样式往往会在多个页面复用,每个页面又请求不同的后端服务,另外,不同上下文只需要整个item数据的其中一部分,或经中间处理转为bar视图渲染的数据,于是我们需要给领域引入防腐层:

截图15.png

4.5 DDD框架

大型列表治理是一个艰辛的过程,我们在文章卡片改版的版本中完成了文章卡片列表治理,为了确保治理经验可复制,我们设计了与业务无关的DDD Adapter Framework,新的大型列表均可直接基于DDD框架开发。除了上面介绍的要点外,DDD框架支持更多场景的使用,例如将单体Adapter作为一个特殊的item类型,这样老的单体Adapter的卡片样式也能迅速迁移到DDD Adapter中分发。除了治理大型列表,DDD框架的目标是统一整个应用所有列表,避免某些item类型不能在其它列表展示的情况,统一所有item类型卡片池,当任何页面需要列表渲染,像叮当猫一样提供随取随用的服务。例如除了文章卡片,我们已在视频大卡片、视频剧集弹窗的复杂列表领域都使用了DDD框架:

截图17.png

截图16.png

5 结束

由于大型列表往往不是一两个人维护,DDD模式要在团队中真正推广,需要一个接纳的过程,推广者除了在组内分享,还要充分吸纳其它成员提出的建议。例如有人提出,要为每个bar都定义一个ItemPartLayoutMan有点繁琐,有的bar比较简单,不需要监听页面生命周期和滚动事件,也不需要监听其它页面的通知进行动态更新,那么就需要改进这些问题,针对这个提议,提供了registerSimpleItemPartView方法,这样可直接添加bar,不再需要ItemPartLayoutMan,这些渐进的改进也是对框架的打磨。治理后,文章卡片已有较多人维护且能协同开发,版本迭代中有交互和UI调整时开发效率更高,开发修改的测试影响范围更小,再通过code review保障,暂未发生过线上bug。

作者:LOFTER技术组 范晨灿

本文发布自网易元气事业部前端团队,文章未经授权禁止任何形式的转载。欢迎与我们交流前端相关的技术问题和经验,同时,团队以及部门正在招聘前端、服务端以及客户端各岗位的开发人员,以上都可以联系LofterFrontendTeam@corp.netease.com进行交流。