iOS 基于MVVM设计模式的微信朋友圈开发

6字数 5781阅读 11023
前言
  • 微信朋友圈一直以来都是iOS开发人员争相模仿的界面,主要是其包含了丰富的iOS所需知识点,以及常用的功能模块。当然各个功能模块实现过程中的细节处理以及用户体验的优化,这才是我们开发者在日常开发中需要关注和加强的地方。
  • 本文笔者将着重分析微信朋友圈实现的具体过程以及细节处理,争取把里面的所有知识点,模块虽小,但五脏俱全,其中最主要分析的是朋友圈的界面布局的细节处理以及性能优化。希望为大家提供一点思路,少走一些弯路,填补一些细坑。文章仅供大家参考,若有不妥之处,还望不吝赐教,欢迎批评指正。
  • 微信朋友圈的基本架构是基于MVVM + RAC + ViewModel-Based Navigation来实现的,如若不懂,还请点击iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发(一)
  • 微信朋友圈的界面控件布局和富文本显示内容,主要是使用YYKit来完成的,若对其不熟练的,请事先做好准备哦。
分析

前期在敲代码之前,需要着重分析一下整个微信朋友圈界面的实现方案,这可能是本篇文章的核心所在了(PS:这里特别提醒一下广大开发者,在实现某一个功能前,请务必确定一个实现方案,可能实现的方案千千万,这就需要开发者通过自身的理解来确定一个最优的方案来实现,而不是一昧毫无头绪的敲代码,造成后期又得重新迭代的悲剧!!!)。微信朋友圈的效果图如下(PS:万恶的马赛克...)。


Moment.jpeg

当然整体的界面的布局还是比较复杂的,前期看了UI还是挺让人望而却步的。首先,我们可以确定的是整体是利用UITableView来实现的,是不是大家已经隐隐约约感受到还是原来的配方,还是熟悉的味道,相同的tableView,变得只是Cell罢了。其次,笔者经过多日在GitHub上搜寻一些实现微信朋友圈的开发的Demo,以及做了大量的市场调研和内容对比,发现最具代表性的两个Demo分别是:gsdios/GSD_WeiXinzhengwenming/WeChat,其他Demo大多数都是参考这两个Demo来做的,当然这两个Demo实现微信朋友圈的方法涉及到两个不同的方案,笔者就带大家简单分析一下各自的方案实现过程以及目前存在的弊端(PS:这里所谓的弊端,只是针对微信朋友圈而言的)。两者的界面模块划分如下(PS: ① 红色框 , ②:绿色框):

Moment_UI.jpeg

当然这两个Demo的实现朋友圈的 共同之处就是:将图上所示的红框①整体用一个UITableViewCell来展示。不同之处 就是:图上所示的绿框②的控件选取不同罢了。
UITableViewCell上布局子控件相对于大家肯定是小菜一碟,这里笔者就针对两个Demo绿框②的控件的选取上做文章以及分析其目前存在的弊端。当然这两个方案目前都不是最最优化的方案,通过分析其中存在弊端,逐渐引申出比较令人合理的方案,当然笔者最终会给出自己的方案,但也许未必是最优的方案,更好的方案或许就存在大家的手中,笔者这里主要强调的是 知其然,知其所以然。话不多说,Let's Do It!

  • 方案一 【gsdios/GSD_WeiXin
    该方案将绿框②的控件选取的是一个普通的UIView,当然内部显示文本(评论、回复、点赞)的子控件用的是UILabel来展示。虽然这种写起来比较通俗易懂,就是根据评论列表点赞列表的内容,不断修改内部UILabelframe来达到要求,但是却带来了如下的弊端:

    1. 布局复杂:考虑到绿框②内部子控件的布局的复杂性,其作者采用的是其自己写的SDAutoLayout来实现,笔者对SDAutoLayout用的也不是非常熟练,关于其布局代码的实现请留意其DemoSDTimeLineCellCommentView.h/m文件即可,尽管其内部布局代码看起来还算简单,但是如果我们不使用SDAutoLayout,那么采用传统的frame布局,想想还是比较复杂的,比如:我们要计算出红框①(UITableViewCell)的高度,首先需要计算出绿框②内部所有子控件(UILabel)的尺寸,从而推算出绿框②的整体高度,最终方能确定红框①(UITableViewCell)的高度。笔者猜想该作者这里可能主要是为了凸显SDAutoLayout的自动布局的强大和便捷,好一个项庄舞剑,意在沛公呀。
    2. 动态创建:我们知道红框①(UITableViewCell)是支持复用的,这是毋庸置疑的,但是我们知道每一条说说(红框①)中包含的评论列表的个数是不一样且Cell高度也会不一样。这样就会涉及到当用户滚动朋友圈列表且cell复用的时候,绿框②内部的子控件的个数也是动态的,可能增多,又可能减少,这样就造成了动态增加或删除绿框②内部的子控件,想必大家都知道尽量不要在UITableViewCell中动态创建子控件,这是比较耗性能的,常规的做法都是事先在- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(nullable NSString *)reuseIdentifier一口气创建所有你要显示的控件,这样你只需要根据数据的属性来显示或隐藏某个子控件即可,这样就避免了动态创建子控件的场景。但是由于朋友圈列表的每条说说的评论列表个数是不能事先确定的,所以必然会存在绿框②动态创建子控件的悲剧。
    3. 不支持大数据:对于上面动态创建控件的问题,其实该作者在内部SDTimeLineCellCommentView.h/m也是做了优化处理的,其做法主要是将动态创建的子控件(UILabel)装进一个数组(commentLabelsArray)里面,这样可以减少一部分的动态创建子控件过程,但是还是会存在动态创建子控件,其主要逻辑就是根据你传进来的说说的评论列表的个数 (commentItemsArray.count)与commentLabelsArray.count比较罢了,如果前者小于等于后者,就不需要动态创建,只是对commentLabelsArray中的子控件做显示和隐藏处理即可;反之如果前者大于后者,这需要动态创建(commentItemsArray.count - commentLabelsArray.count)个子控件,然后又被加入到数组commentLabelsArray里面的过程。关键代码如下所示:
      首先微信朋友圈的评论列表的个数是支持大数据的(PS:笔者瞎猜的…),那就必须确保绿框②能支持大数据的显示,显然随着评论列表的个数逐渐增多,以及UITableViewCell的不断复用,则绿框②commentLabelsArray里面装的子控件也会越来越多且保持只增不减的趋势,这样该方案就显得比较的无力了。
    - (void)setCommentItemsArray:(NSArray *)commentItemsArray{
      _commentItemsArray = commentItemsArray;
      long originalLabelsCount = self.commentLabelsArray.count;
      long needsToAddCount = commentItemsArray.count > originalLabelsCount ? (commentItemsArray.count - originalLabelsCount) : 0;
      for (int i = 0; i < needsToAddCount; i++) {
          MLLinkLabel *label = [MLLinkLabel new];
          [self addSubview:label];
          [self.commentLabelsArray addObject:label];
        }
    }  
    

    以上就是【gsdios/GSD_WeiXin】目前笔者发现其存在的些许问题以及谈谈笔者个人的一些理解。当然这个方案在针对大量的评论数据的处理上或许稍有吃力,但是如果当评论列表的个数是固定,例如:优酷视频的评论回复(如下图)。这个方案也不失为一个好的解决方案。所以说业务场景不同,实现方案不同,可见在敲代码之前,先思考后确定实现方案是多么重要。

    YouKu_UI.png

  • 方案二 【zhengwenming/WeChat
    该方案将绿框②的控件选取的是一个UITableView,也就是说Cell(红色框①)里面嵌套了一个UITableView,其内部子控件就是UITableViewCell来处理,后面的处理其实就跟我们平常处理UITableView的方法一样,创建TableView,遵守协议,实现协议方法… ,可能会不习惯的就是平常创建的TableView,我们都是将其添加在控制器的View上,这里只是添加在UITableViewCell上罢了,其他并无差异。内部实现说到底其实就是充分利用UITableView的特性,选取不同UITableViewCell来显示点赞列表评论列表而已,相比于方案一来说,该方案主要发挥出了UITableView的特性,通过实现UITableView的协议方法就能实现评论和点赞列表的展示,且实现起来更加简单易懂,这可能是目前市场上绝大多数的做法。虽然外表看似毫无破绽,但是其中隐藏巨大弊端。之前笔者也利用这种方案,写过类似微信朋友圈的评论回复,详情请参考:iOS 实现微信朋友圈评论回复功能(二),但是其中存在的问题,笔者却没有叙述,实属抱歉,当然这里笔者将详述其存在弊端和产生的原因,以及让大家重新加深对UITableView的理解。弊端如下:

    1. 复用问题: 若想保证UITableView滚动流畅,纵享丝滑,就离不开UITableViewCell的复用机制(PS:这个复用机制想必大家应该已经滚瓜烂熟了,这里笔者就不在赘述),这也是UITableView的核心所在。首先正常情况下,我们可以确定的是红色框①这个UITableViewCell是能够Cell复用的,这个应该是毫无争议的。但是红色框①内部嵌套的绿色框②这个TableView中,其内部显示评论数据的UITableViewCell是否也是支持Cell的复用机制呢???可能大家的第一印象就是觉得是能的。但是这里笔者强调的是 绿色框②CommentCell是不支持复用的!!!大家认为CommentCell能够复用的,都是认为其复用机制完全跟红色框①(MommentCell)复用机制一样,都是会随着用户滑动的朋友圈列表,MommentCell 和 CommentCell离开都会完全离开屏幕,然后将完全离开屏幕的MommentCell 和 CommentCell存入缓存池,等到要显示Cell的时候又去缓存池根据reuseIdentifier去取MommentCell 和 CommentCell,如果取得到,就直接拿来用;如果取不到,就去创建等过程....,这里笔者只能说cell复用的概念倒是背的的挺熟,但是Cell复用的机制却不够理解。原因是:* 之所以红色框①这个MomentCell能够遵循Cell复用的机制,是因为首先其所处在的UITableView的尺寸大小是和屏幕尺寸大小一致,其次朋友圈列表能够滑动的前提就是保证该TableView的内容高度大于TableView的高度,即tableView.contentSize.height > tableView.frame.size.height,需要强调的是:Cell能否产生复用取决于所处的tableView能否滚动,②并且Cell能够随着列表滚动完全离开所处的TableView的显示范围。结合这两点必要条件,很快可以推断出红色框①这个UITableViewCell是能够满足Cell复用的条件的。接着我们带着这两个必要条件来分析一下绿色框②这个TableView,首先明确的是,该TableView的高度是根据评论列表中每个评论内容(CommentCell)的高度总和(PS:tableView.height = cell0.height+cell1.height+cell2.height ...),这样就导致了该tableView的内容高度等于tableView的尺寸高度,即(tableView.frame.size.height = tableView.contentSize.height),所以评论列表是不会滚动的,这样就不满足条件①;其次,其tableView内部的CommentCell相对于所处的tableView的显示区域是完全暴露的,根本不满足条件②,所以最终真相大白,水落石出了,是不是豁然开朗,心情舒畅。 当然这里笔者友情提醒广大开发者千万不要误认为,只要Cell看不见就一定会产生复用的误区,主要是要明确该Cell相对于所处的TableView的显示区域是否看不见。(PS:知识点有木有),当然大家可以跑跑笔者写的这篇文章:iOS 实现微信朋友圈评论回复功能(二)所提供的Demo,来验证一下笔者的这一说法。最后,如果绿色框②这个TableView一旦失去了Cell复用机制,用脚趾头想想也知道,那造成的后果务必会重蹈方案一存在的三个弊端的悲剧,这里笔者就不再赘述了,且笔者个人认为整体性能还不如方案一的。
  • 方案三 【CoderMikeHe/WeChat
    该方案正是笔者目前使用的方案,该方案不仅很好的解决了方案一方案二目前存在的弊端,而且使用起来极其简单方便以及性能优化上更是前两个方案无法比拟的,当然最主要的还是考察技巧性(黑魔法)。首先笔者在认定该方案之前,前期笔者是做了大量的准备工作,以及仔细琢磨了红色框①(PS:类似一条说说)这个整体的子模块组成。当然必须明确的是微信朋友圈的需求:绿色框②能够展示大量的评论数据(即:评论内容列表的个数>=100 ,虽然我们会很少看到某个人的某条说说,有100多个人的评论内容,而且微信的朋友圈信息流动性非常快,这种大数据的产生会很少发生,但是这种大数据不代表没有)。①考虑到微信朋友圈这一个硬需求,笔者着重从性能上出发,第一想到的就是利用Cell的复用机制来展示每条说说的评论内容;②考虑到前两个方案都是把红色框①当做一个整体来处理,且都来了类似的弊端以及针对评论内容大数据所带来的性能问题,以免重蹈覆辙,笔者将红色框①拆分为下图几个模块:一条说说(红色框①) = 组(段)头(绿色框②) + Cell(紫色框③) + 组(段)尾(黑色框④)

    Moment_Plan3_UI.jpeg

通过上图所示,虽然该方案在模块划分上是比较的分散,但是其总体带来的性能是非常客观的,大大保证了朋友圈列表滚动的流畅性。其中当然最最主要的原因还是归功于上图所示的组(段)头(绿色框②)、Cell(紫色框③)、组(段)尾(黑色框④)这三个控件都是可以通过使用TableView的数据源方法以及代理方法(代码如下)轻松实现View的复用机制的,而且都是平常开发中常用的方法,这样前面两个方案所存在的弊端就迎刃而解了。

/// UITableViewDelegate
/// 组(段)头
- (nullable UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section;
/// 组(段)尾
- (nullable UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section;

/// UITableViewDataSource
/// Cell
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath;

当然,组(段)头、组(段)尾的内部控件布局,想必对于大家已经是手到擒来的东西,这里笔者也不过多讨论,详情请参考笔者提供的Demo,自行领会。这里笔者主要想说的就是Cell(紫色框③),首先平常开发过程中,Cell的宽度一般是跟所处的tableView的宽度是一致的,但是微信朋友圈里面的这个评论Cell明显不是,这里笔者需要强调的是:重写是个好东西。这里的关键点就是在于重写自定义的UITableViewCell- (void)setFrame:(CGRect)frame方法,关键代码如下:

/// PS:重写cell的设置尺寸的方法, 这是评论View关键
- (void)setFrame:(CGRect)frame{
    frame.origin.x = MHMomentContentLeftOrRightInset+MHMomentAvatarWH+MHMomentContentInnerMargin;
    frame.size.width = MHMomentCommentViewWidth();
    [super setFrame:frame];
}

当然对于这种方案(组(段)头+Cell+组(段)尾)的实现过程,笔者以前就写过一篇文章,来详细介绍这其中的关键点,详情请参考:iOS 实现微信朋友圈评论回复功能(一)。最后,笔者个人认为这个方案目前是实现类似微信朋友圈这种支持无限评论需求的最优雅的实施方案。
当然还有一种方案就是:微信官方团队做朋友圈开发的实现方案。如果这篇文章能够有幸被微信的开发人员看到,也请微信的开发人员分享一下微信官方的朋友圈的实现方案哦;或者如果笔者的这个方案正好和微信官方的如出一辙,那么也请为笔者疯狂打Call(权威认证)哦。最后笔者希望这篇文章能够为大家解除些许疑惑,带来些许帮助。

功能

分析开发笔者在微信朋友圈时多遇到一些比较需要技巧性的功能模块的实现以及细节处理,当然实现的方案一定不是唯一的,但是笔者的目的是希望大家能够积极讨论,然后继续完善朋友圈的各个功能模块。

  • 评论/回复时,TableView滚动到指定的区域
    这个功能是目前小伙伴问的最多的小功能模块之一,具体效果图如下所示(PS:打开自己手机中微信朋友圈,玩弄一下):首先我们的明确该功能主要是为了避免键盘评论输入框遮盖住用户想要评论或回复的内容。其次微信朋友圈官方做法(需求)是:① 用户点击组(段)头(绿色框②)上的弹出的【评论】按钮 , 弹出键盘评论输入框,我们需要保证组(段)尾(黑色框④)的底部显示在评论输入框的顶部,且伴随着评论输入框高度的变化而组(段)尾(黑色框④)的底部仍旧显示在评论输入框的顶部; ② 用户点击评论Cell(紫色框③)弹出键盘评论输入框,我们需要保证评论Cell(紫色框③)的底部显示在评论输入框的顶部,且伴随着评论输入框高度的变化而评论Cell(紫色框③)的底部仍旧显示在评论输入框的顶部;
    Comment.gif

    经过上述的需求分析,我们可以明确的是:当弹出键盘评论输入框时,滚动TableView到合适的区域来满足上述的条件即可,滚动TableView无非就是设置其contentOffset.y(PS:- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated)的值即可,目前最关键的就是计算出:需要计算出contentOffset.y的值即可。
    笔者仅以上面的需求①为例,来说说笔者的思路和做法,首先如果弹出键盘评论输入框时,TableView不设置合适contentOffset,则UI效果无非是以下三种情况:(PS:红色分割线代表键盘评论输入框弹出时,评论输入框的顶部所处的位置 , 其他颜色的分割线代表组(段)尾(黑色框④)的底部位置)
场景一 场景二 场景三
Moment_UI_Up.png
Moment_UI_NO.png
Moment_UI_Down.png
delta = Y1 - Y2 < 0 delta = Y1 - Y2 = 0 delta = Y1 - Y2 > 0

场景一:该情况如果不设置TableView滚动,那么势必会导致键盘评论输入框遮盖住评论内容(PS:蓝色分割线),从而影响用户体验;如果想要显示出评论内容,只需要让TableView向上(delta < 0)滚动abs(Delta)距离。
场景二: 该情况属于理想状态,TableView无需上下滚动。
场景三:该情况若不处理,则会导致要评论的内容,距离评论输入框很远,会让用户怀疑这条评论究竟是评论谁的,从而影响用户体验。为了达到需求,则需要让TableView向下(delta > 0)滚动Delta距离即可。
想必通过上表的画图分析,想必大家已经胸有成竹了吧。目前最主要的是:如何计算出Delta = Y1 - Y2的值?
首先我们应该知道,评论输入框组(段)尾(黑色框④)处于不同的坐标系,不能直接计算,所以,关键点就是我们必须将组(段)尾(黑色框④)的坐标系转换成和评论输入框一样的坐标系,其次再设置TableViewcontentOffset.y = contentOffset.y + delta即可。需求②也采用类似的方法来处理即可,这里笔者不再赘述,详情见Demo,关键代码如下所示:

/// 评论的时候 滚动tableView
- (void)_scrollTheTableViewForComment{
    CGRect rect = CGRectZero;
    CGRect rect1 = CGRectZero;
    if (self.selectedIndexPath.row == -1) {
        /// 获取整个尾部section对应的尺寸 获取的rect是相当于tableView的尺寸
        rect = [self.tableView rectForFooterInSection:self.selectedIndexPath.section];
        /// 将尺寸转化到window的坐标系 (关键点)
        rect1 = [self.tableView convertRect:rect toViewOrWindow:nil];
    }else{
        /// 回复
        /// 获取整个尾部section对应的尺寸 获取的rect是相当于tableView的尺寸
        rect = [self.tableView rectForRowAtIndexPath:self.selectedIndexPath];
        /// 将尺寸转化到window的坐标系 (关键点)
        rect1 = [self.tableView convertRect:rect toViewOrWindow:nil];
    }
    if (self.keyboardHeight > 0) { /// 键盘抬起 才允许滚动
        /// 这个就是你需要滚动差值
        CGFloat delta = self.commentToolView.mh_top - rect1.origin.y - rect1.size.height;
        [self.tableView setContentOffset:CGPointMake(0, self.tableView.contentOffset.y-delta) animated:NO];
    }
}
未完...待续...(PS:不说再见,我们后会有期)

本篇文章笔者主要是分析实现微信朋友圈的最优方案,希望能为大家在敲代码之前树立一个正确的参考,这样能够避免大家走许多弯路。当然微信朋友圈的技术要点和技术细节,虽然看似简单,但是细节处理非常重要,笔者在接下来的时间内,会陆续为其增加更多功能模块,以及将在开发WeChat朋友圈中用到的好用技术以及细节处理分享出来,希望提供大家一个参考,争取能为大家答疑解惑。当然也希望大家踊跃发言,共同交流,共同进步。

期待
  1. 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:WeChat
参考链接