抖音交互区重构:问题梳理与架构优化

2024-09-20
来源:网络整理

背景

本文以抖音中最复杂、最重要的功能之一——交互区域为例,与大家分享重构过程中的思考和方法,主要侧重于架构和结构。

互动区介绍

交互区域是指播放页面上可以操作的区域。简单来说,就是指视频播放器附带的功能,比如下图红色区域中的作者姓名、描述、头像、点赞、评论、分享按钮、面具、弹窗面板等。这些几乎是用户看到最多、使用最多的功能,也是主要的流量入口。

找到问题

不要急着改代码,先理清功能、问题、代码,建立全局视角,找到问题的根源。

现状

上图是代码量排名,交互区域排名第一,遥遥领先于其他类别。数据来自自研的代码量化系统,这是一款辅助业务发现架构、设计、代码问题的工具。

您可以进一步查看版本变更:

每周一个版本,不到一年时间代码量就增长了一倍,部分版本由于局部优化导致代码量有所减少,但总体趋势还是快速增长。

除了:

整理业务

作者来自基础技术组,负责业务架构,对互动区业务没有理解,需要重新审视。

其实没有人知道所有的业务,包括产品经理,也没有完整的需求文档可以参考,需要按照代码、功能页面、操作来梳理业务逻辑,如果不确定,就找相关的开发人员、产品同事,跳过中间环节。总共梳理了10多条业务线,100多个子功能,梳理这些功能的目的是:

清理代码

所有业务功能和问题最终都落在代码上,只有明确了代码才能真正明确问题,解决方案也体现在代码中,总结如下:

简单来说就是把代码看完整个看,重点是类之间的依赖关系,可以画个类图来理解。

每行代码的存在都是有原因的,即使感觉没用,删除一行可能会引发线上事故。

趋势

由于抖音的产品特性,视频播放页占据了绝大部分流量,各业务线都想把流量引流到播放页,随着业务的发展,不断向多样性、复杂性演进。

从播放页业态来看,经过多次探索和尝试,目前播放页模式已经比较稳定,主要通过导流形式的入口进行业务拓展。

按之前尝试过的方法进行划分

拆分成多个部分,将代码拆分成View的构造、布局、更新、业务线逻辑。这种方法可以解决一部分问题,但是有局限性,当功能很复杂的时候,无法很好的支持,主要问题有:

左侧和底部的子功能位于

这个想法的方向总体上是正确的,但是尝试了半年多之后失败了,代码也被删除了。

正确观点是:抽象出子功能之间的关系,并用它来进行布局。

失败点是:

其他

还有一些MVP、MVVM等结构的提案,有的只是短暂尝试,有的没能通过技术评审,还有的半途而废。

关键问题

以上只是列举了一部分问题,如果按人来收集,数量更是数不胜数,但这些基本都是表面问题,只有找到问题的本质和原因,解决关键问题,才能彻底解决问题,很多表面问题也会顺带得到解决。

经常提到的内聚、耦合、封装、分层这些思想,感觉很好,但是用起来却并不能真正解决问题。下面就两点进行拓展,以辅助分析和解决问题:

复杂

复杂特性难以维护的原因就在于它们很复杂。

是的,很直接。相比之下,设计、重构等手法都是为了把事情简单化,但把事情简单的过程却并不简单。我们从两个角度来分解一下:

数量:数量是显性的,随着功能的不断增加,需要更多的人来开发和维护系统,需要写的代码也越来越多,维护起来也越来越困难,这些都是显而易见的。

关系:关系是隐性的,当功能之间发生耦合,就会产生关系。假设两个功能之间存在依赖关系,关系数为1,那么三个之间的关系数为3,四个之间的关系数为6,这是一个指数级的增长,当数量足够大时,复杂度就会被夸大,关系不容易看出来,因此很容易产生意想不到的变化。

功能数量可以粗略认为是随着产品开发人数线性增长,也就是复杂度也是线性增长,随着开发人员人数同步增长还能维持。如果关系数量呈指数级增长,那么很快就会变得无法维持。

“变量”和“常量”

“变量”指的是与之前的版本相比,哪些代码发生了变化,对应的“常量”指的是哪些代码没有变化。目的是:

从过去的变化中寻找模式以适应未来的变化。

平常提到的封装、内聚、解耦等概念都是静态的,也就是在某个时间点合理,不代表以后也是合理的。我们期望改进能在更长的一段时间内合理,这就叫动态。在代码中寻找“变量”和“常量”是一种相对有效的手段,对应的代码也有不同的优化趋势:

回到交互区重构场景,我们发现新增的子功能基本都是在三个固定的区域添加,布局上上下下拉伸。这里的变化指的是新增的子功能,不变指的是添加的位置与其他子功能的位置关系和逻辑关系。因此可以提供灵活的扩展机制来支撑变化的部分。在不变的部分,将业务无关的部分下沉到底层框架,将业务相关的部分封装为独立的模块,这样就形成了整体架构。

“变量”和“常量”也可以用来测试重构的效果,比如模块之间往往通过抽象协议进行通信,如果通信方式都是业务相关的,每个同学都可能添加自己的方法,这时“变量”就会不受控制,难以维护。

设计

在梳理问题的过程中,我们一直在不断思考用什么方法可以解决问题,目前已经形成了一个大概的轮廓,这部分更多的是将设计方案系统化。

想法

为了概念统一,将三个区域定义为容器,将放置在容器中的子功能定义为元素。可以放宽容器边界和能力,支持弱类型实例化,可以支持元素代码的物理隔离,形成可插拔的机制。

元素将视图、布局和业务逻辑代码整合在一起。元素与交互区域之间、元素与元素之间没有直接依赖关系。职责具有内聚性,维护起来更加容易。

众多接口可以抽象分类,大致分为UI生命周期调用和播放器生命周期调用。业务接口被抽象出来,分发到具体的元素上去处理逻辑。

建筑设计

下图是期望的最终形态,实施过程会分为多个步骤来确定最终形态,避免实施过程中偏离目标。

总体指导原则:简单、适用、可进化。

SDK 层

所有管理均通过 进行,它由两部分组成:

用于实现自下而上的流动布局。

将子功能的UI、逻辑、操作等所有代码封装的集合定义为,借用网页的概念,对外的行为可以抽象为:

看法

视图定义如下:

@interface BaseElement : NSObject 

@property (nonatomic, strong, nullable) UIView *view;
@property (nonatomic, assign) BOOL appear;

- (void)viewDidLoad;

@end

布局事件

@protocol BaseElementProtocol 
@optional
- (void)tapHandler:(UITapGestureRecognizer *)sender;

@end

更新

给数据属性赋值,触发更新,通过表单实现。

@property (nonatomic, strong, nullable) id data;

当分配值时,该方法被调用。

- (void)setData:(id)data {
    _data = data;
    [self processAppear:self.appear];
}

当赋值的时候,方法会根据状态更新View的状态,决定创建或者销毁View。

数据流图

关于更新时的生命周期和数据流图这里就不详细讲解了。

动画效果

图中是需要支持的实际业务场景,目前老代码实现主要存在的问题有:

这种实现方式非常分散,在添加新视图时很容易遗漏。支持一种更好的方法:

可扩展性

之间没有依赖关系,各自可以物理隔离,代码放在各个业务组件中,业务组件依赖交互区业务框架层,通过注册的形式独立提供给交互区,框架会实例化类,使其正常工作。

[self.container addElementByClassName:@"PlayInteractionAuthorElement"];
[self.container addElementByClassName:@"PlayInteractionRateElement"];
[self.container addElementByClassName:@"PlayInteractionDescriptionElement"];

业务框架层容器管理

SDK只是提供了容器的抽象定义和实现,在业务场景中需要结合具体的业务场景进一步定义容器的作用域和职责。

以上整理出来的功能,将整个页面划分为左边、右边、底部三个区域,这三个区域就是对应的容器,所有的子功能都可以归类到这三个容器中,如下图所示:

协议

Feed 使用 实现,在 Cell 中,除了交互区域外,就只有一个播放器,因此所有对外的调用都可以抽象出来,如下图所示。

从概念上讲,只需要 1 个交互式区域协议,但可以分为两个部分:

所有的类都必须实现这个协议,所以在SDK中将其继承并实现在基类上,这样可以省略掉在特定情况下不需要实现的方法。

@interface PlayInteractionBaseElement : BaseElement 
@end

为了更加清晰的划分协议职责,利用接口隔离的思想,将其进一步拆分为统一的聚合协议。

@protocol PlayInteractionDispatcherProtocol 

@end

页面生命周期协议:ol

简单列出了一些方法,这些方法都是Cell对应的生命周期方法,完全是抽象的,与业务无关,所以不会随着业务量的增加而扩充。

@protocol PlayInteractionCycleLifeDispatcherProtocol 

- (void)willDisplay;

- (void)setHide:(BOOL)flag;

- (void)reset;

@end

玩家生命周期协议:

玩家的状态和方法也是抽象的,和业务无关。

@protocol PlayInteractionPlayerDispatcherProtocol 

@property (nonatomic, assign) PlayInteractionPlayerStatus playerStatus;

- (void)pause;

- (void)resume;

- (void)videoDidActivity;

@end

- 弹出窗口和面具

弹窗、遮罩这些view规则不是容器管理的,所以需要额外一种管理方式。这里定义一下概念,是一个比较抽象的概念,即可以实现弹窗、遮罩等功能,也可以实现和View无关的功能。同样,进行代码拆分。

@interface PlayInteractionBaseManager : NSObject 

- (UIView *)view;

@end

方法调度

批量抖音删除喜欢的作品_抖音喜欢的怎么批量删除_抖音批量删除喜欢

业务框架层定义的协议需要框架层调用,SDK层是感知不到的,由于协议较多,需要一种机制来封装批量调用流程,如下图所示:

层次结构

旧的交互区用的是范式,抖音全程用的是MVVM,多套范式会增加学习和维护成本,而且使用开发时层级太多,所以我们考虑统一成MVVM。

总体层次结构

MVVM整体层次结构

在MVVM结构中,职责和概念非常接近,也可以理解为更加纯粹,更加专业。

拆分之后,各个子功能整合到了一起,代码量有限,可以更好的支持业务发展。

结合MVVM结构

业务层

业务层存储实现,主要有两种类型:

将总业务和交互区域代码放在一起,将子业务线放在业务线中,代码物理隔离之后,职责会更加清晰,但是这也带来一个问题,在框架调整的时候,需要修改多个,可能会有修改遗漏,因此可以在重构之初放在一起,等稳定之后再迁移出去。

过度设计的错误

设计常常走向两个极端:没有设计和过度设计。

所谓无设计,就是在现有的架构和模型内,照搬使用,不去额外考虑其差异和特点。

过渡性设计通常发生在人们尝到没有设计的苦果并感到害怕之后。他们开始为所有东西想出一堆配置、组合和扩展设计,最终把简单的东西搞复杂了。太多和太少一样糟糕。

设计是在质量、成本和时间等因素之间进行权衡的艺术。

执行

业务发展不能停下,开发与重建同时进行,就好比在高速公路上不停地换轮胎,需要做好充足的计划与记录,才能确保设计方案的顺利实施。

变革评估

首先预估修改规模及周期:

变更规模巨大且耗时长,风险难以控制。每个版本都有大量的业务需求,需要更改大量代码。重构时,如果重构后的代码与新增需求代码发生冲突,解决起来会非常困难,因此可以考虑分阶段开发。

功能的重要性上面已经多次提到,我们需要考虑重构之后功能是否正常,如果出现问题该如何处理,如何证明重构之后的功能和之前一致,对产品数据没有影响。

实施策略

基本思路就是实现一个新的页面,切换到该页面,如果核心指标不是明显负值,就加大音量,音量满后再删除旧代码,原理图如下:

它分为三个阶段:

第二阶段是重点,也是耗时最长的阶段,这个阶段需要同时维护新旧两套页面,开发和测试工作量加倍,所以第二阶段的时间尽量缩短,不要急着改代码,可以先把第一阶段做完,把设计各方面准备好再开始。

2个目的:

两套页面开发方式

第二阶段,两套页面切换方法成本较大,需要开发两套,测试两次,虽然可以共享部分代码,但是成本还是大大增加,所以需要尽量缩短此阶段。

在开发和测试两套系统时,发现问题并不容易,一旦出现问题,即便可以灵活切换,也需要很长时间才能修复问题、恢复上线、得出数据结论。

如果每个版本有问题,就上线,发现问题,修复上线,再次发现新问题,这样就会无限循环,可能永远都不可能完全发布。

如上图,假设一个版本在一周内迭代,第二周发现问题并修复,则需要经过灰度、灰度上线(灰度发布)、验证(AB数据稳定需要2周时间),一共需要6周。

让每个学生都了解整体的运作机制和成本,有利于统一目标,缩短这一阶段的周期。

删除旧代码

架构设计准备的很充分,删除旧代码也很简单,只要删除旧文件就可以了,1天就搞定。

代码 push 进去之后,有些长尾的东西会持续 2、3 个版本,比如有些分支修改了被删除的代码,由于文件已经不存在了,只要修改了就一定会有冲突,合并之前需要 git 源分支,把有冲突的旧页面删除。

防塌陷保护

针对该协议开发了两套页面,如果在新页面中增加了一个功能,省略了一个方法,希望页面不会崩溃,这个特性可以通过C语言的消息转发来实现,在方法中判断该方法是否存在,如果不存在,可以增加一个方法来处理。

- (id)forwardingTargetForSelector:(SEL)aSelector {
  Class clazz = NSClassFromString(@"TestObject");
  if (![self isExistSelector:aSelector inClass:clazz]) {
    class_addMethod(clazz, aSelector, [self safeImplementation:aSelector], [NSStringFromSelector(aSelector) UTF8String]);
  }

  Class Protector = [clazz class];
  id instance = [[Protector alloc] init];
  return instance;
}

- (BOOL)isExistSelector:(SEL)aSelector inClass:(Class)clazz {
  BOOL isExist = NO;
  unsigned int methodCount = 0;
  Method *methods = class_copyMethodList(clazz, &methodCount);
  NSString *aSelectorName = NSStringFromSelector(aSelector);
  for (int i = 0; i < methodCount; i++) {
    Method method = methods[i];
    SEL selector = method_getName(method);
    NSString *selectorName = NSStringFromSelector(selector);
    if ([selectorName isEqualToString: aSelectorName]) {
      isExist = YES;
      break;
    }
  }
  return isExist;
}

- (IMP)safeImplementation:(SEL)aSelector {
  IMP imp = imp_implementationWithBlock(^(){
    // log
  });
  return imp;
}

通过线上保障降低影响范围,通过内测提示尽早发现问题,在开发和内测阶段,可使用更强交互手段提示,如弹窗等,并可进行点对点联动,进行上报统计。

抗劣化

需要明确的规则和机制来防止恶化,并且必须不断努力来维持它。

不是所有人都能理解设计意图,不同职责的代码应该放在该放的地方,比如和业务无关的代码就应该移到框架层,降低被破坏的概率。紧张的开发节奏意味着,哪怕是简单的 if else 语句也容易出问题,比如多加一个条件,几乎就要再写一个 if,写了几十个之后,人们就会发现写不下去了,然后就会拆掉重构,希望重构一次之后,能够尽可能的维护长久。

更严重的是,在重构过程中,代码可能会恶化,如果问题出现的速度比解决的速度快,你将永远忙于救火,永远无法彻底解决问题。

在新的方案中,业务逻辑放在了中间,公共的代码留在容器里,不需要业务同事去修改这部分代码,如果不整体去理解的话,很容易出问题,所以这部分代码有专人去维护,如果业务同事需要修改框架层代码,就有专人去修改。

每个文件按照业务线划分为独立文件,自己维护的文件可以添加文件变更通知或者移动到业务仓库进行物理隔离。

日志记录和故障排除

稳定复现的问题相对容易排查和解决,但概率性问题,尤其是 iOS 系统问题导致的问题,排查难度较大,即便猜到了问题的可能原因,修改后也很难自测验证,只能上网观察。

关键信息要提前记录下来,比如用户反馈某个视频有问题,你需要根据日志找到对应的view、、等信息。

信息同步

如果变更范围过大,需要及时告知业务线的开发、测试、产品人员,具体有以下几种方式:

开发者最关心的点是什么时候该增加体量,什么时候该全面扩容,什么时候该删除老代码,这样就不需要维护两套代码了。

第二是修改,当框架还不够稳定的时候,需要经常修改,如果修改了,需要受影响功能的维护人员验证确认是否需要参与测试。

产品同事也应该意识到这一点,虽然产品人员不注重怎么做事,但是一旦出现问题,没有意识到的话,就会很麻烦。

保证质量

最重要的是及时发现问题,这是避免或减少影响的前提。

常规的RD自测,QA功能测试,集成测试等都必不可少,这里就不细说了,主要讨论一下其他手段,可以更及时的发现问题。

新的开发需求,需要开发新旧页面两套代码,同样需要做两遍测试。虽然强调了很多遍,但是因为涉及多条业务线、跨团队、跨职责,时间线很长,很容易漏掉一些东西。新页面体量小,一旦出现问题很难察觉。因此对线上和测试用户做了不同的处理:

故障排除

稳定递归问题比较容易定位和解决,有两类问题比较麻烦,我们来详细说一下:

负面指标

如果核心指标为负,就不可能增加产量,甚至可能需要停止实验来解决问题。

在一个分享例子中,分享总量和人均分享量明显为负数,进行了如下调查:

排除故障指标与排除错误类似,两者的目的都是找出差异、缩小范围,并最终找到代码。

这个问题有很多值得思考的地方。重构的时候,如果看到不好的代码,要不要改?

比如上面的问题,增加功能之后不知道是否要排除点击,这个很容易被忽略。长按属于底层逻辑,具体按钮属于业务细节,底层逻辑依赖细节不好,可维护性很差。但是修改之后很可能会影响交互体验和产品指标,特别是核心指标,一旦受到影响,就没有太大的商榷余地了。

评估具体情况,如果估计会影响功能或交互,尽量不要改动,大改造尽量先解决核心问题,局部问题可以稍后再单独解决。

下面是长按面板分享数据的截图,明显减少了,其他来源基本保持不变,就不贴图了。

长按会让面具的出现率降低10%左右,这是比较自然的猜测。

比较 View 视图以确认问题。

类似的问题还有很多,成交量拉升、成交量爆满的过程需要充足的时间预估和耐心,而这个过程会大大超出预期。抖音的核心指标几乎都和互动区域有关,很多分析师和产品都要关注,因此首先要了解分析师、产品、开发者对负面指标认知的差异。

如果大部分指标为正,有部分为负,则判定为负数。

开发人员可能会考虑设计的合理性,代码的合理性,或者从整体得失的角度去考虑差异,但分析师会优先考虑没有问题和隐患,两种方式考虑的角度和目标不同,没有对错之分。其实分析师帮助发现了很多问题。目前分析师和产品很多,每个指标都有一位分析师或产品负责,如果某个核心指标明显是负数,那么跟对应的分析师和产品商量起来很难达成共识,甚至先放量再排查的方案也很难让人接受。建议大家学会看指标,尽快跟进,关键时候找人帮忙推广。

概率问题

概率性问题的难点在于难以复现,无法调试定位问题,修改后也无法测试验证,需要上线才能确定是否修复。举一个iOS9上的实际例子,发现过程:

以下是相当常见的堆栈,大约 50% 的 iOS 9 用户都会出现这种情况:

堆栈在系统库中,看不到源码,在堆栈中找不到相关问题代码,所以无法定位问题,整个解决过程比较长,下面是我尝试过的方法,供大家参考:

逆向一般过程:

@implementation UIView
- (void)_withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists:(Block)action {
    id engine = [self _layoutEngine];
    id delegate = [engine delegate];
    BOOL suspended = [delegate _isUnsatisfiableConstraintsLoggingSuspended];
    [delegate _setUnsatisfiableConstraintsLoggingSuspended:YES];
    action();
    [delegate _setUnsatisfiableConstraintsLoggingSuspended:suspended];
    if (suspended == YES) {
        return;
    }
    NSArray *constraints = [self _constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended];
    if (constraints.count != 0) {
        NSMutableArray *array = [[NSMutableArray alloc] init];
        for (NSLayoutConstraint *_cons : constraints) {
            if ([_cons isActive]) {
                [array addObject:_cons];
            }
        }
        if (array.count != 0)  {
            [NSLayoutConstraint deactivateConstraints:array]; // NSLayoutConstraint 入口
            [NSLayoutConstraint activateConstraints:array];
        }
    }
    objc_setAssociatedObject(
                self,
                @selector(_constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended),
                nil,
                OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

@end

@implementation NSLayoutConstraint
+ (void)activateConstraints:(NSArray *)_array {
    [self _addOrRemoveConstraints:_array activate:YES]; // crash堆栈中倒数第3个调用
}
+ (void)deactivateConstraints:(NSArray *)_array {
    [self _addOrRemoveConstraints:_array activate:NO];
}
@end

鼓起勇气取得成果

最后一部分是经过深思熟虑后加上的。重构本身是开发的一部分,很正常,但重构总是很难进行,有些人只是简单尝试,甚至半途而废。在公司严苛的招聘下,能进来的都是聪明人,他们不缺乏解决问题的智慧,但缺乏勇气。回头看这次重构和上面提到的“之前试过的方法”,简直如出一辙。

代码难以维护是很容易发现的,优化和重构也是很自然的事情。然而,有两个原因导致重构无法有效进行:

在讨论什么时候开始之前,我们可以先看一个词。工作中有一个很流行的词叫ROI,意思是投入与回报的比率。投入越少,回报越高越好。不劳而获是最好的。这个词指导了很多决策。

重构无疑是一项费力的工作,需要投入大量的精力和时间,但直接的收益却并不明显。一旦出现问题,就有风险。重构也很难获得别人的认可。比如从产品的角度看,功能根本没有改变,代码还能运行,为什么现在有新的需求等着开发,而要重构呢?有问题的代码不断被拖延,变得越来越严重。

诚然,当痛点足够多的时候,重构是最赚钱的,但重构只是表面现象,真正的收益不变。在这之前,需要很多额外的维护成本,以及降级之后的重构成本。从长远利益的角度看,要改就尽快改。决定做这件事很难,说服所有人就更难了。每个人对长期利益的理解、判断可能都不一样,所以很难达成共识。

思考者多,行动者少,人们往往对未知的事物保持谨慎,而支撑人们前行的,是对科技追求的勇气。

重构的最佳时机就是现在。

局部重建,一点一点,最终会形成整体,即使出现问题,影响也是局部的。这是自下而上的做法,本身就很好,也经常被使用。与之对应的做法是自上而下的整体重建。这里要强调的是,局部重建和整体重建都只是手段,选择什么手段取决于要解决的问题。如果根本问题是整体结构和架构的问题,局部重建是解决不了的。

例如,在这种重建过程中,许多人认为这些变化可能更小,并且更加谨慎。

不要忘记跑步,因为您害怕拉球。

如果您对技术充满热情,欢迎加入我们。

分享