一、寫在前面
在我的iOS開發(fā)學(xué)習(xí)過程中,閱讀過許多同學(xué)的高仿項目文章、源碼,對我助益頗深。但是許許多多的高仿項目在技術(shù)方面各有側(cè)重,所以我先把本項目中值得探討的技術(shù)點列出,方便正好需要的同學(xué)。
本項目重點探討:
- UITableview的性能優(yōu)化
- UIScrollView的進(jìn)階使用
- 少數(shù)派客戶端導(dǎo)航欄動態(tài)效果的實現(xiàn)
- UITableview的多種控件嵌套
- 手動封裝一些常用的視圖控件
二、簡述
首先來看一下項目的運行效果:
對于原客戶端的一些重復(fù)性細(xì)節(jié)沒有全部實現(xiàn),歡迎大家fork。
這里是 LYSSPai項目地址。
在本文中,我會先介紹項目的整體實現(xiàn)思路,然后對于開發(fā)過程中遇到的值得探討的點進(jìn)一步講述。
項目中的數(shù)據(jù)來源為使用
Charles抓包獲取,用json文件存在bundle中。
項目中的素材來源為官方客戶端ipa包使用iOS Images Extractors解析獲得。
聲明:僅用于學(xué)習(xí)交流,嚴(yán)禁用于商業(yè)用途。
三、整體實現(xiàn)思路
在這一節(jié),我會按照頁面來介紹整體開發(fā)思路。
1. 首頁
1.1 頁面簡述
- 這是項目的首頁,主要結(jié)構(gòu)是頂部的導(dǎo)航欄和下面的內(nèi)容。
- 導(dǎo)航欄效果:
在頁面向上滑動時,頂部導(dǎo)航欄的文字、按鈕尺寸會隨之動態(tài)減小,而后整體上移,懸停在頂部,模擬系統(tǒng)的導(dǎo)航欄效果。當(dāng)頁面下滑時,效果相反。 - 內(nèi)容展示部分:
首先有一個左右滑動的類似輪播圖部分。用以展示重點推薦的專題、文章、廣告等。
接下來是一篇文章。
然后又是一個手動滑動的類似輪播圖。用來展示付費的欄目。
剩余部分全為文章。
1.2 實現(xiàn)思路
1.2.1 內(nèi)容展示
使用UITableview,包含三種cell。
輪播圖為橫向的UIScrollView,為其中的每一個子cell設(shè)置tag值,點擊事件以delegate的方式交由首頁VC實現(xiàn)。
文章展示cell為普通的cell。右上角的菜單按鈕點擊事件以delegate的方式交由首頁VC實現(xiàn)。
1.2.2 導(dǎo)航欄實現(xiàn)
導(dǎo)航欄的動態(tài)效果需要隨著內(nèi)容滑動而進(jìn)行,而后懸停在頂部。其中涉及導(dǎo)航欄的高度變化以及懸停效果。
我們很容易想到使用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,必須顯式調(diào)用set方法
[self.tableView setTableHeaderView:headerView];
[self.tableView endUpdates];
而sectionHeader是默認(rèn)帶有懸停效果的,但是我沒有找到可以高效更新視圖高度的方法,所以這種方法果斷放棄。
對于tableHeader的懸停效果,可以在頁面滑到臨界點時,將tableHeader加入到與tableview同一層級的view中,手動實現(xiàn)懸停效果,這也是許多UIScrollView的子View想要實現(xiàn)頁面懸停效果的方式。
但是有一點需要知道,UITableView是一個龐大的對象,對它頻繁更新勢必會影響性能。而動態(tài)更改tableHeader時,會不停地改變整個UITableView的布局。為了一個小小的動態(tài)效果實在不必如此。所以,我使用一個單獨的view作為頂部的導(dǎo)航欄,并且將它和tableview加入到同一個容器scrollview中。這樣動態(tài)效果僅僅影響這個單獨的view布局。
1.2.3 分類專題頁
點擊首頁右上角的按鈕或者在內(nèi)容cell中左劃,會進(jìn)入分類專題頁面。這個頁面只是簡單模擬實現(xiàn)了一下。
1.2.4 文章閱讀頁面
點擊文章cell或者輪播部分的文章類型子cell,會進(jìn)入對應(yīng)的文章閱讀頁面。
這個頁面底部導(dǎo)航欄為手動模擬實現(xiàn)。文章展示使用
WKWebView。在整個頁面包含web內(nèi)容部分,均可以右劃返回。
關(guān)于使用WebView展示內(nèi)容的探討,在我的簡書文章從簡書iOS客戶端,來談?wù)凥ybrid方案細(xì)節(jié)設(shè)計進(jìn)行了詳細(xì)探討,歡迎大家閱讀。
2.發(fā)現(xiàn)
這個頁面和首頁類似,并且比首頁簡單,略過不表。
3.消息
這個頁面沒有特別復(fù)雜的部分。不過自己封裝了選擇器View,效果和原客戶端完全一致,需要的同學(xué)可以閱讀這部分代碼。其中涉及到UIScrollView的一些進(jìn)階特性,一會會詳述。
四、重點詳述
1. tableview性能優(yōu)化
-
優(yōu)化場景
頁面開發(fā)完成后,cell嵌套scrollview,其中還包括多個子cell,如果不加優(yōu)化的話,可以預(yù)見使用體驗不會太好。在第一次滑動到第二個輪播圖時,很明顯感受到頁面fps下降。而后滑動流暢,fps基本保持在60。所以我們知道,優(yōu)化重點在于輪播圖的首次加載、渲染。輪播圖首次出現(xiàn)在屏幕范圍中之后,被加入緩存,所以再次滑到這里時便不會卡頓。
說到性能優(yōu)化,不得不推薦一下ibireme的文章,強烈建議沒看過的同學(xué)認(rèn)真閱讀一下iOS 保持界面流暢的技巧。
-
優(yōu)化思路
滑動頁面時fps在60左右時,用戶不會感覺到卡頓,這是優(yōu)化的目標(biāo)。也就是說,我們需要在1s/60 = 16.7ms內(nèi),完成每一幀的渲染。而視圖渲染需要CPU運算+GPU渲染運算共同完成。所以我們需要分析在這個場景下,CPU與GPU各自的工作量,合理調(diào)配,從而使它們的每一幀運算耗時總和低于16.7ms。
-
cell重用
cell重用是非?;A(chǔ)但又非常重要的優(yōu)化手段,正確使用tableview的cell重用機制。
-
cell高度緩存
tableview的渲染過程中,有多少個cell,就會調(diào)用多少次- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath方法,從而確定contentSize。所以,盡量將cell的高度提前計算并且進(jìn)行緩存,避免在這個代理方法中進(jìn)行計算,可以有效優(yōu)化tableview的渲染。
-
布局計算優(yōu)化
布局的計算是CPU的工作,當(dāng)頁面層級復(fù)雜時,布局計算就會耗費較多時間。同時,應(yīng)該明確的一點是使用Masonry自動布局是將布局計算量交給CPU去完成,勢必會相對增加耗時。所以,在復(fù)雜cell的優(yōu)化中,一般建議手動計算布局,會稍微提升一些性能。除此之外,如果頁面布局計算量比較大的話,將布局計算在頁面渲染之前完成并且緩存,會有效減少視圖渲染時的16.7ms中的CPU運算時間。
在本項目中,我為輪播圖cell封裝了一個frameModel,在頁面數(shù)據(jù)獲取完成后,提前計算輪播圖的布局結(jié)果,在頁面渲染時,無需計算便可以直接賦值。
//count為輪播圖子cell數(shù)量
+(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循環(huán)并且每一個循環(huán)體都稍有計算量,將這些計算工作提前并且在子線程執(zhí)行是非常明智的。我們要讓那16.7ms“用在刀刃上”。
-
正確選擇視圖控件,為視圖瘦身
UIView和CALayer的關(guān)系大家應(yīng)該都有所了解。UIView在CALayer的基礎(chǔ)上,封裝了交互操作相關(guān)的部分,UIView是比CALayer更重量的。如果當(dāng)前控件不需要響應(yīng)用戶操作,我們應(yīng)該盡可能使用CALayer替代UIView。
在本項目中,付費內(nèi)容輪播圖部分,整個子cell需要響應(yīng)用戶的點擊操作。所以只需要在子cell的最底層view添加手勢識別。而背景圖片、用戶頭像等元素是不需要響應(yīng)特殊操作的,所以這些控件不使用UIImageView,改用CALayer。其實文字部分,也可以不使用UILabel,這是可以繼續(xù)優(yōu)化的部分。
這是頭像部分的布局代碼:
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];
-
網(wǎng)絡(luò)內(nèi)容異步加載
待頁面顯示出來之后,網(wǎng)絡(luò)內(nèi)容再慢慢加載,也是為了將時間用在刀刃上。
異步加載網(wǎng)絡(luò)圖片的框架,有大家都熟知的SDWebImage,也有ibireme的YYWebImage。據(jù)介紹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];
-
圓角設(shè)置
又是老生常談的圓角設(shè)置。使用CALayer的相關(guān)屬性來實現(xiàn)圓角效果會觸發(fā)離屏渲染,增加GPU的工作量。在這一點的優(yōu)化上,可以使用CPU將圖片素材直接裁剪為圓角圖片再進(jìn)行顯示。當(dāng)然,最優(yōu)的方案當(dāng)然是讓你們的美工直接提供圓角素材~
這里我直接使用了YYImage的圓角處理。
2. UIScrollView的進(jìn)階使用
這個部分我主要講的是消息頁面的選擇器控件封裝的思路。
先看效果:

一個非常簡單的控件。但是有一個細(xì)節(jié)需要注意:使用輕劃手勢左右滑動時,頁面必然進(jìn)行滾動。而使用拖拽時,則會判斷拖拽范圍來決定是否進(jìn)行滾動。
這個效果我使用了
UIScrollView的代理方法- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate來實現(xiàn)。這里是代碼:
//停止拖拽時的代理
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
// 如果是內(nèi)容頁的橫向滑動
if (scrollView == self.contentView)
{
NSLog(@"slowing?? %@",decelerate ? @"YES" : @"NO");
CGFloat scrollX = scrollView.contentOffset.x;
// 如果帶有慣性(快速滑動),則內(nèi)容頁必然進(jìn)行對應(yīng)的移動
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;
}
}
// 如果無慣性(慢速拖拽),此時需要滿足拖動的范圍才會進(jìn)行移動
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];
}
}
當(dāng)輕劃頁面時,scrollview是有慣性的,而拖拽時是沒有慣性的,利用這個特性來進(jìn)行相應(yīng)的判斷。
這里是小橫條移動的動畫:
//內(nèi)容頁進(jìn)行移動的封裝
- (void)contentViewScrollAnimation
{
//根據(jù)此時選中的按鈕計算出contentView的偏移量
CGFloat offsetX = self.selectedTag * LYScreenWidth;
CGPoint scrPoint = self.contentView.contentOffset;
scrPoint.x = offsetX;
//默認(rèn)滾動速度有點慢 加速了下
[UIView animateWithDuration:0.3 animations:^{
[self.contentView setContentOffset:scrPoint];
}];
// 通知選擇器,進(jìn)行小橫條的移動
[self.selectView selectBtnChangedTo:self.selectedTag];
}
3. 導(dǎo)航欄動態(tài)效果的實現(xiàn)
先重新看一下效果:

這里使用scrollview的代理方法
- (void)scrollViewDidScroll:(UIScrollView *)scrollView來實現(xiàn)。這是代碼的部分:
// scrollview剛剛開始滑動,此時導(dǎo)航標(biāo)題大小和按鈕大小進(jìn)行變化
if (Y <= -97 && Y > -130)
{
// 以字號為36和20計算得出的臨界Y值為-97和-130,根據(jù)此刻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的對應(yīng)尺寸,若大于最小值(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);
}];
}
這里計算比較繁瑣,可以仔細(xì)看一下。
4. UITableview的多種控件嵌套
這個部分內(nèi)容在前文的頁面實現(xiàn)部分已經(jīng)簡單講過,這里列出來是提醒初學(xué)的朋友可以稍作留意。
5. 手動封裝一些常用的視圖控件
在本項目中,我封裝了頁面的導(dǎo)航欄視圖HeaderView,選擇器視圖SelectView以及頁面的加載loading視圖LYLoadingView。需要了解的同學(xué)可以留心看一些。
這里簡單展示一下loading視圖的封裝。
這是頭文件部分:
@interface LYLoadingView : UIView
//隱藏傳入view中的loadingview
+ (BOOL)hideLoadingViewFromView:(UIView *)view;
//為傳入view顯示一個loadingview
+ (BOOL)showLoadingViewToView:(UIView *)view WithFrame:(CGRect)frame;
@end
這是實現(xiàn)部分:
+ (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];
頁面數(shù)據(jù)獲取完成后,table進(jìn)行reload,然后移除loading視圖:
[self.newsTableView reloadData];
// 隱藏loadingview
[LYLoadingView hideLoadingViewFromView:self.view];
五、寫在最后
這個項目并沒有100%完全復(fù)原官方客戶端,筆者閑暇時間不允許,所以算是倉促結(jié)束,并且寫了這篇文章作結(jié)尾。項目中還存在一些bug,也有未完成的功能點,歡迎大家fork。
有不足之處歡迎大家指出,也歡迎討論項目中的其他實現(xiàn)方式,希望幫助到需要的同學(xué)。
最后再貼一下 LYSSPai項目地址。如果覺得不錯,希望點個star~
halo