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

少数派

一、写在前面

在我的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的tableHeader和sectionHeader,那么先来明确一下这两种视图的特性: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(inti =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];

}

returnmodel;

}

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

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

UIView和CALayer的关系大家应该都有所了解。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];

returnimage;

} completion:nil];

网络内容异步加载

待页面显示出来之后,网络内容再慢慢加载,也是为了将时间用在刀刃上。异步加载网络图片的框架,有大家都熟知的SDWebImage,也有ibireme的YYWebImage。据介绍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];

returnimage;

} 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;

}

elseif(self.selectedTag ==1&& scrollView.contentOffset.x < LYScreenWidth)

{

self.selectedTag =0;

}

}

//        如果无惯性(慢速拖拽),此时需要满足拖动的范围才会进行移动

else

{

if(self.selectedTag ==0&& scrollX >=0.5* LYScreenWidth)

{

self.selectedTag =1;

}

elseif(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.3animations:^{

[self.contentView setContentOffset:scrPoint];

}];

//    通知选择器,进行小横条的移动

[self.selectView selectBtnChangedTo:self.selectedTag];

}

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

先重新看一下效果:

导航栏效果展示

这里使用scrollview的代理方法- (void)scrollViewDidScroll:(UIScrollView *)scrollView来实现。这是代码的部分:


//    scrollview刚刚开始滑动,此时导航标题大小和按钮大小进行变化

if(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视图的封装。这是头文件部分:


@interfaceLYLoadingView : 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 *subviewinsubviewsEnum)

{

if([subview isKindOfClass:self])

{

[subview removeFromSuperview];

returnYES;

}

}

returnNO;

}

+ (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];

returnYES;

}

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。有不足之处欢迎大家指出,也欢迎讨论项目中的其他实现方式,希望帮助到需要的同学。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容

  • 一、写在前面 在我的iOS开发学习过程中,阅读过许多同学的高仿项目文章、源码,对我助益颇深。但是许许多多的高仿项目...
    halohily阅读 9,338评论 30 282
  • *7月8日上午 N:Block :跟一个函数块差不多,会对里面所有的内容的引用计数+1,想要解决就用__block...
    炙冰阅读 2,477评论 1 14
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,066评论 4 62
  • 盖聂: 1,有些梦想虽然遥不可及,但不是不可能实现。只要我足够的强。 3,杀戮永远不是变强的理由,一个人,若以打败...
    鳄鱼Y_C阅读 718评论 0 1
  • KeyPoint基于组件的系统设计,有松耦合性,鼓励重用(微服务)How 保持应用程序可发布将新功能隐藏起来直到完...
    张培_阅读 374评论 0 0