美文网首页iOS开发技巧iOS开源&高仿项目精选暂时没看却需要看的
「iOS」高仿【少数派】客户端 代码+思路讲解

「iOS」高仿【少数派】客户端 代码+思路讲解

作者: halohily | 来源:发表于2017-09-13 17:14 被阅读6030次
    少数派

    一、写在前面

    在我的iOS开发学习过程中,阅读过许多同学的高仿项目文章、源码,对我助益颇深。但是许许多多的高仿项目在技术方面各有侧重,所以我先把本项目中值得探讨的技术点列出,方便正好需要的同学。

    本项目重点探讨:

    • UITableview的性能优化
    • UIScrollView的进阶使用
    • 少数派客户端导航栏动态效果的实现
    • UITableview的多种控件嵌套
    • 手动封装一些常用的视图控件

    二、简述

    首先来看一下项目的运行效果:


    LYSSPai运行展示

    对于原客户端的一些重复性细节没有全部实现,欢迎大家fork。

    这里是 LYSSPai项目地址

    在本文中,我会先介绍项目的整体实现思路,然后对于开发过程中遇到的值得探讨的点进一步讲述。

    项目中的数据来源为使用Charles抓包获取,用json文件存在bundle中。
    项目中的素材来源为官方客户端ipa包使用iOS Images Extractors解析获得。
    声明:仅用于学习交流,严禁用于商业用途。

    三、整体实现思路

    在这一节,我会按照页面来介绍整体开发思路。

    1. 首页

    首页展示-1
    首页展示-2

    1.1 页面简述

    • 这是项目的首页,主要结构是顶部的导航栏和下面的内容。
    • 导航栏效果:
      在页面向上滑动时,顶部导航栏的文字、按钮尺寸会随之动态减小,而后整体上移,悬停在顶部,模拟系统的导航栏效果。当页面下滑时,效果相反。
    • 内容展示部分:
      首先有一个左右滑动的类似轮播图部分。用以展示重点推荐的专题、文章、广告等。
      接下来是一篇文章。
      然后又是一个手动滑动的类似轮播图。用来展示付费的栏目。
      剩余部分全为文章。

    1.2 实现思路

    1.2.1 内容展示

    使用UITableview,包含三种cell。
    轮播图为横向的UIScrollView,为其中的每一个子cell设置tag值,点击事件以delegate的方式交由首页VC实现。
    文章展示cell为普通的cell。右上角的菜单按钮点击事件以delegate的方式交由首页VC实现。

    1.2.2 导航栏实现

    导航栏的动态效果需要随着内容滑动而进行,而后悬停在顶部。其中涉及导航栏的高度变化以及悬停效果
    我们很容易想到使用UITableView的tableHeadersectionHeader,那么先来明确一下这两种视图的特性:
    tableHeader没有顶部悬停效果,但是可以方便地更改视图的高度:

    CGRect newFrame = headerView.frame;
    newFrame.size.height = newFrame.size.height + webView.frame.size.height;
    headerView.frame = newFrame;
    
    //beginUpdates和endUpdates方法用来以动画形式更改高度
    [self.tableView beginUpdates];
    
    //要更改tableHeader,必须显式调用set方法
    [self.tableView setTableHeaderView:headerView];
    
    [self.tableView endUpdates];
    

    而sectionHeader是默认带有悬停效果的,但是我没有找到可以高效更新视图高度的方法,所以这种方法果断放弃。
    对于tableHeader的悬停效果,可以在页面滑到临界点时,将tableHeader加入到与tableview同一层级的view中,手动实现悬停效果,这也是许多UIScrollView的子View想要实现页面悬停效果的方式。
    但是有一点需要知道,UITableView是一个庞大的对象,对它频繁更新势必会影响性能。而动态更改tableHeader时,会不停地改变整个UITableView的布局。为了一个小小的动态效果实在不必如此。所以,我使用一个单独的view作为顶部的导航栏,并且将它和tableview加入到同一个容器scrollview中。这样动态效果仅仅影响这个单独的view布局。

    1.2.3 分类专题页
    分类专题页

    点击首页右上角的按钮或者在内容cell中左划,会进入分类专题页面。这个页面只是简单模拟实现了一下。

    1.2.4 文章阅读页面
    文章阅读页
    点击文章cell或者轮播部分的文章类型子cell,会进入对应的文章阅读页面。
    这个页面底部导航栏为手动模拟实现。文章展示使用WKWebView。在整个页面包含web内容部分,均可以右划返回。

    关于使用WebView展示内容的探讨,在我的简书文章从简书iOS客户端,来谈谈Hybrid方案细节设计进行了详细探讨,欢迎大家阅读。

    2.发现

    发现页展示

    这个页面和首页类似,并且比首页简单,略过不表。

    3.消息

    消息页展示

    这个页面没有特别复杂的部分。不过自己封装了选择器View,效果和原客户端完全一致,需要的同学可以阅读这部分代码。其中涉及到UIScrollView的一些进阶特性,一会会详述。

    四、重点详述

    1. tableview性能优化

    • 优化场景

    页面开发完成后,cell嵌套scrollview,其中还包括多个子cell,如果不加优化的话,可以预见使用体验不会太好。在第一次滑动到第二个轮播图时,很明显感受到页面fps下降。而后滑动流畅,fps基本保持在60。所以我们知道,优化重点在于轮播图的首次加载、渲染。轮播图首次出现在屏幕范围中之后,被加入缓存,所以再次滑到这里时便不会卡顿。
    说到性能优化,不得不推荐一下ibireme的文章,强烈建议没看过的同学认真阅读一下iOS 保持界面流畅的技巧

    • 优化思路

    滑动页面时fps在60左右时,用户不会感觉到卡顿,这是优化的目标。也就是说,我们需要在1s/60 = 16.7ms内,完成每一帧的渲染。而视图渲染需要CPU运算+GPU渲染运算共同完成。所以我们需要分析在这个场景下,CPU与GPU各自的工作量,合理调配,从而使它们的每一帧运算耗时总和低于16.7ms。

    • cell重用

    cell重用是非常基础但又非常重要的优化手段,正确使用tableview的cell重用机制。

    • cell高度缓存

    tableview的渲染过程中,有多少个cell,就会调用多少次- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath方法,从而确定contentSize。所以,尽量将cell的高度提前计算并且进行缓存,避免在这个代理方法中进行计算,可以有效优化tableview的渲染。

    • 布局计算优化

    布局的计算是CPU的工作,当页面层级复杂时,布局计算就会耗费较多时间。同时,应该明确的一点是使用Masonry自动布局是将布局计算量交给CPU去完成,势必会相对增加耗时。所以,在复杂cell的优化中,一般建议手动计算布局,会稍微提升一些性能。除此之外,如果页面布局计算量比较大的话,将布局计算在页面渲染之前完成并且缓存,会有效减少视图渲染时的16.7ms中的CPU运算时间。
    在本项目中,我为轮播图cell封装了一个frameModel,在页面数据获取完成后,提前计算轮播图的布局结果,在页面渲染时,无需计算便可以直接赋值。

    //count为轮播图子cell数量
    +(instancetype)PaidNewsFrameModelWithCount:(NSInteger)count
    {
        PaidNewsFrameModel *model = [[self alloc] init];
        
        float cellWidth = LYScreenWidth * 0.55;
        float cellHeight = LYScreenWidth * 0.7;
        model.cellTitleFrame = CGRectMake(25, 10, 100, 18);
        model.moreFrame = CGRectMake(LYScreenWidth - 65, 11, 40, 16);
        model.backScrollViewFrame = CGRectMake(0, 43, LYScreenWidth, cellHeight);
        model.paidNewsViewFrames = [[NSMutableArray alloc] init];
        model.paidTitleFrames = [[NSMutableArray alloc] init];
        model.avatorFrames = [[NSMutableArray alloc] init];
        model.nicknameFrames = [[NSMutableArray alloc] init];
        model.updateInfoFrames = [[NSMutableArray alloc] init];
    
        for ( int i = 0; i < count; i++)
        {
            NSValue *paidNewsViewFrame = [NSValue valueWithCGRect:CGRectMake(25 + (cellWidth + 15) * i, 0, cellWidth, cellHeight)];
            [model.paidNewsViewFrames addObject:paidNewsViewFrame];
            NSValue *avatorFrame = [NSValue valueWithCGRect:CGRectMake(15, cellHeight - 90, 20, 20)];
            [model.avatorFrames addObject:avatorFrame];
            NSValue *nicknameFrame = [NSValue valueWithCGRect:CGRectMake(45, cellHeight - 85, cellWidth - 75, 12)];
            [model.nicknameFrames addObject:nicknameFrame];
            NSValue *updateInfoFrame = [NSValue valueWithCGRect:CGRectMake(15, cellHeight - 50, cellWidth - 30, 12)];
            [model.updateInfoFrames addObject:updateInfoFrame];
        }
        return model;
    }
    

    可以看到,带有for循环并且每一个循环体都稍有计算量,将这些计算工作提前并且在子线程执行是非常明智的。我们要让那16.7ms“用在刀刃上”。

    • 正确选择视图控件,为视图瘦身

    UIViewCALayer的关系大家应该都有所了解。UIView在CALayer的基础上,封装了交互操作相关的部分,UIView是比CALayer更重量的。如果当前控件不需要响应用户操作,我们应该尽可能使用CALayer替代UIView。
    在本项目中,付费内容轮播图部分,整个子cell需要响应用户的点击操作。所以只需要在子cell的最底层view添加手势识别。而背景图片、用户头像等元素是不需要响应特殊操作的,所以这些控件不使用UIImageView,改用CALayer。其实文字部分,也可以不使用UILabel,这是可以继续优化的部分。
    这是头像部分的布局代码:

    CALayer *avator = [[CALayer alloc] init];
    [paidNewsView.layer addSublayer:avator];
    NSValue *avatorFrame = self.model.paidNewsFrame.avatorFrames[i];
    avator.frame = avatorFrame.CGRectValue;
    [avator yy_setImageWithURL:[NSURL URLWithString:self.model.PaidNewsData[i][@"avatar"]] placeholder:nil options:kNilOptions progress:nil transform:^UIImage * _Nullable(UIImage * _Nonnull image, NSURL * _Nonnull url) {
        image = [image yy_imageByRoundCornerRadius:40.0];
        return image;
    } completion:nil];
    
    • 网络内容异步加载

    待页面显示出来之后,网络内容再慢慢加载,也是为了将时间用在刀刃上。
    异步加载网络图片的框架,有大家都熟知的SDWebImage,也有ibiremeYYWebImage。据介绍YYWebImage的性能是要比SD好一些的,这个我没有亲自验证。
    这里我使用了YYWebImage:

    [avator yy_setImageWithURL:[NSURL URLWithString:self.model.PaidNewsData[i][@"avatar"]] placeholder:nil options:kNilOptions progress:nil transform:^UIImage * _Nullable(UIImage * _Nonnull image, NSURL * _Nonnull url) {
        image = [image yy_imageByRoundCornerRadius:40.0];
        return image;
    } completion:nil];
    
    • 圆角设置

    又是老生常谈的圆角设置。使用CALayer的相关属性来实现圆角效果会触发离屏渲染,增加GPU的工作量。在这一点的优化上,可以使用CPU将图片素材直接裁剪为圆角图片再进行显示。当然,最优的方案当然是让你们的美工直接提供圆角素材~
    这里我直接使用了YYImage的圆角处理。

    2. UIScrollView的进阶使用

    这个部分我主要讲的是消息页面的选择器控件封装的思路。
    先看效果:

    selectView效果展示
    一个非常简单的控件。但是有一个细节需要注意:使用轻划手势左右滑动时,页面必然进行滚动。而使用拖拽时,则会判断拖拽范围来决定是否进行滚动。
    这个效果我使用了UIScrollView的代理方法- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate来实现。
    这里是代码:
    //停止拖拽时的代理
    - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
    {
    //    如果是内容页的横向滑动
        if (scrollView == self.contentView)
        {
            NSLog(@"slowing?? %@",decelerate ? @"YES" : @"NO");
            CGFloat scrollX = scrollView.contentOffset.x;
    //        如果带有惯性(快速滑动),则内容页必然进行对应的移动
            if (decelerate)
            {
                if (self.selectedTag == 0 && scrollView.contentOffset.x > 0)
                {
                    self.selectedTag = 1;
                }
                else if (self.selectedTag == 1 && scrollView.contentOffset.x < LYScreenWidth)
                {
                    self.selectedTag = 0;
                }
            }
    //        如果无惯性(慢速拖拽),此时需要满足拖动的范围才会进行移动
            else
            {
                if (self.selectedTag == 0 && scrollX >= 0.5 * LYScreenWidth)
                {
                    self.selectedTag = 1;
                }
                else if (self.selectedTag == 1 && scrollX <= 0.5 * LYScreenWidth){
                    self.selectedTag = 0;
                }
            }
            [self contentViewScrollAnimation];
        }
    }
    

    当轻划页面时,scrollview是有惯性的,而拖拽时是没有惯性的,利用这个特性来进行相应的判断。
    这里是小横条移动的动画:

    //内容页进行移动的封装
    - (void)contentViewScrollAnimation
    {
        //根据此时选中的按钮计算出contentView的偏移量
        CGFloat offsetX = self.selectedTag * LYScreenWidth;
        CGPoint scrPoint = self.contentView.contentOffset;
        scrPoint.x = offsetX;
        //默认滚动速度有点慢 加速了下
        [UIView animateWithDuration:0.3 animations:^{
            [self.contentView setContentOffset:scrPoint];
        }];
    //    通知选择器,进行小横条的移动
        [self.selectView selectBtnChangedTo:self.selectedTag];
    }
    

    3. 导航栏动态效果的实现

    先重新看一下效果:

    导航栏效果展示
    这里使用scrollview的代理方法- (void)scrollViewDidScroll:(UIScrollView *)scrollView来实现。
    这是代码的部分:
     //    scrollview刚刚开始滑动,此时导航标题大小和按钮大小进行变化
        if (Y <= -97 && Y > -130)
        {
            //            以字号为36和20计算得出的临界Y值为-97和-130,根据此刻Y值计算此时的字号
            CGFloat fontSize = (-((16.0 * Y)/33.0)) - 892.0/33.0;
            self.titleLabel.font = [UIFont fontWithName:@"HelveticaNeue-Bold" size:fontSize];
            //            NSLog(@"point:: %f",self.titleLabel.font.pointSize);
            //            更新titlelabel的高度约束
            [self.titleLabel mas_updateConstraints:^(MASConstraintMaker *make) {
                make.height.mas_equalTo(self.titleLabel.font.pointSize + 0.5);
            }];
            //            计算此刻button的对应尺寸,若大于最小值(16),则更新约束
            CGFloat buttonSize = self.titleLabel.font.pointSize * (5.0/9.0);
            if (buttonSize >= 16.0)
                [self.button mas_updateConstraints:^(MASConstraintMaker *make) {
                    make.width.mas_equalTo(buttonSize);
                    make.height.mas_equalTo(buttonSize);
                }];
        }
    

    这里计算比较繁琐,可以仔细看一下。

    4. UITableview的多种控件嵌套

    这个部分内容在前文的页面实现部分已经简单讲过,这里列出来是提醒初学的朋友可以稍作留意。

    5. 手动封装一些常用的视图控件

    在本项目中,我封装了页面的导航栏视图HeaderView,选择器视图SelectView以及页面的加载loading视图LYLoadingView。需要了解的同学可以留心看一些。
    这里简单展示一下loading视图的封装。
    这是头文件部分:

    @interface LYLoadingView : UIView
    //隐藏传入view中的loadingview
    + (BOOL)hideLoadingViewFromView:(UIView *)view;
    //为传入view显示一个loadingview
    + (BOOL)showLoadingViewToView:(UIView *)view WithFrame:(CGRect)frame;
    @end
    

    这是实现部分:

    + (BOOL)hideLoadingViewFromView:(UIView *)view
    {
        NSEnumerator *subviewsEnum = [view.subviews reverseObjectEnumerator];
        for (UIView *subview in subviewsEnum)
        {
            if([subview isKindOfClass:self])
            {
                [subview removeFromSuperview];
                return YES;
            }
        }
        return NO;
    }
    
    + (BOOL)showLoadingViewToView:(UIView *)view WithFrame:(CGRect)frame
    {
        LYLoadingView *loadingView = [[LYLoadingView alloc] initWithFrame:frame];
        loadingView.backgroundColor = [UIColor whiteColor];
        UIActivityIndicatorView *indicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray];
        indicator.center = CGPointMake(frame.size.width/2, frame.size.height/2 - 100);
        [indicator startAnimating];
        [loadingView addSubview:indicator];
        [view addSubview:loadingView];
        return YES;
    }
    

    loading视图模仿官方app的一个简单菊花指示器。
    使用时,在页面渲染最开始在视图上加一个loadingview:

    //    初始化loadingview
    CGRect loadingViewFrame = CGRectMake(0, 130, LYScreenWidth, LYScreenHeight - 130);
    [LYLoadingView showLoadingViewToView:self.view WithFrame:loadingViewFrame];
    

    页面数据获取完成后,table进行reload,然后移除loading视图:

    [self.newsTableView reloadData];
    //        隐藏loadingview
    [LYLoadingView hideLoadingViewFromView:self.view];
    

    五、写在最后

    这个项目并没有100%完全复原官方客户端,笔者闲暇时间不允许,所以算是仓促结束,并且写了这篇文章作结尾。项目中还存在一些bug,也有未完成的功能点,欢迎大家fork。
    有不足之处欢迎大家指出,也欢迎讨论项目中的其他实现方式,希望帮助到需要的同学。

    最后再贴一下 LYSSPai项目地址。如果觉得不错,希望点个star~

    halo

    相关文章

      网友评论

      • ifelseboyxx:麻烦问下,少数派的官方 App 在 App store 还有的下吗
        ifelseboyxx:@钱刀为 没有的,
        钱刀为:@halohily 在哪里?
        halohily:@ifelseboyxx 当然
      • beec90dd09c9:以字号为36和20计算得出的临界Y值为-97和-130,根据此刻Y值计算此时的字号
        CGFloat fontSize = (-((16.0 * Y)/33.0)) - 892.0/33.0;
        关于计算的这部分没看懂呢,能给简单解释一下吗,没看懂怎么计算的,892.0等这些值是怎么来的
        beec90dd09c9:@halohily 嗯好,我再看看研究研究:relaxed:谢谢你
        halohily:@我我我是浮夸 抱歉回复晚了,隔了这么久,我自己都仔细回想了一下,难怪别人会看不懂:joy: 。 这里是根据y值分别为-97和-130时,计算得到字号分别为36和20。想要在变化过程中字号变化平滑,那么Y值和计算结果之间的关系必须是线性的,也就是数学图像呈直线。用数学里最简单的根据两点确定一条直线,得出的你要问的那几个值
      • averroe:没有适配iPhone X呢?
        halohily:@averroe 是的:joy: 后来没再更新了
      • averroe:没有适配iPhone X呢?
      • 十字路口右转:上面那个左右滚动的banner,用collectionView会不会更简单些
      • 小小面试官:PaidNewsFrameModelWithCount
        "将这些计算工作提前并且在子线程执行是非常明智的", 我看源码里处理这个计算是在主线程做的,并没有放到子线程
        halohily:@你的钱掉了 编码的时候先做了其他优化,性能已经能到预期了,所以其他地方都是体现了一下文章提到的点,因为这个地方“提前计算”是更重要的,所以代码简略了一下。代码部分结束的比较仓促。最后总结文章的时候是按照理论最优的方式来写的:wink:
      • 沙暴送葬:想请教楼主两个问题,1.在不追求性能的情况下,动态计算cell高度用什么方法,是Masonry+FDTemplateLayoutCell 吗?
        2. [NSValue valueWithCGRect:CGRectMake(15, cellHeight - 50, cellWidth - 30, 12)]; CGRectMake这样写的话不能适配屏幕
      • 科仔:不错
        halohily:@科仔 :wink::wink:
      • 也一:怀念ios开发的日子:joy:
        也一:@July丶ye 嗯。纯ios开发 势必走向死胡同
        kinmo:@也一 跳坑了
        也一:@凌晨吃西瓜 数值算法
      • 黄花菜先生:你用layer怎么自动布局
        halohily:@不会凉的黄花菜 用layer的时候当然就不用自动布局了。而且我在文中也说了,追求性能到极致的时候最好手动计算布局,舍弃autolayout。
      • 春泥Fu:"但是有一个细节需要注意:使用轻划手势左右滑动时,页面必然进行滚动。而使用拖拽时,则会判断拖拽范围来决定是否进行滚动。"

        这句话可以理解,用户如果快速滑动,像刷微博那种就是带有减速效果,如果是手指贴着屏幕不离开滑动就是没减速效果吗?
        halohily:@春泥Fu 我测试是这样的。iOS本身就有轻扫和拖拽手势,但是这里的这个特性据我自己测试用滚动代理实现比用手势要更接近官方客户端的体验。

      本文标题:「iOS」高仿【少数派】客户端 代码+思路讲解

      本文链接:https://www.haomeiwen.com/subject/hdxlsxtx.html