小小回顧
上一篇分析了項(xiàng)目的大體框架和跑步模塊一些關(guān)鍵技術(shù)的實(shí)現(xiàn)。今天這篇是Running Life源碼分析的第二篇,主要探討以下問題:
1、HealthKit框架的使用黔龟;
2、貝塞爾曲線+幀動(dòng)畫實(shí)現(xiàn)優(yōu)雅的數(shù)據(jù)展示界面;
3氏身、實(shí)現(xiàn)一個(gè)view的復(fù)用機(jī)制解決內(nèi)存暴漲的問題巍棱;
HealthKit
2014年6月2日召開的年度開發(fā)者大會上,蘋果發(fā)布了一款新的移動(dòng)應(yīng)用平臺观谦,可以收集和分析用戶的健康數(shù)據(jù),蘋果命名為“Healthkit ”桨菜。它管理著用戶得健康數(shù)據(jù)豁状,包括用戶走路的步數(shù)、消耗的卡路里倒得、體重泻红、身高等等。相信大多數(shù)微信用戶都知道微信運(yùn)動(dòng)這個(gè)功能霞掺,每天和好友PK自己的走路的步數(shù)谊路,其實(shí)在iOS端的微信不做步數(shù)統(tǒng)計(jì),它的數(shù)據(jù)源來自Healthkit菩彬。
那如何引入這個(gè)框架呢缠劝?
首先你要在你的App ID設(shè)置那里選擇支持HealthKit,然后在Xcode做相應(yīng)的設(shè)置:
這樣基本的配置工作就結(jié)束了骗灶,接下就是碼代碼了:
我將SDK和框架的相關(guān)注冊工作分離到“SDKInitProcess.h”這個(gè)文件惨恭,這樣做是為了方便對SDK注冊管理,此外還可以給AppDelegate瘦身耙旦。注冊如下:
-(void)initHealthKitSetting{
if (![HKHealthStore isHealthDataAvailable]) {
NSLog(@"設(shè)備不支持healthKit");
}
HKObjectType *walkingRuningDistance = [HKObjectType quantityTypeForIdentifier:HKQuantityTypeIdentifierDistanceWalkingRunning];
NSSet *healthSet = [NSSet setWithObjects:walkingRuningDistance, nil];
//從健康應(yīng)用中獲取權(quán)限
[self.healthStore requestAuthorizationToShareTypes:nil readTypes:healthSet completion:^(BOOL success, NSError * _Nullable error) {
if (success)
{
NSLog(@"獲取距離權(quán)限成功");
}
else
{
NSLog(@"獲取距離權(quán)限失敗");
}
}];
}
-(HKHealthStore *)healthStore{
if (!_healthStore) {
_healthStore = [[HKHealthStore alloc]init];
}
return _healthStore;
}
HKObjectType是你想從HealthKit框架獲取的數(shù)據(jù)類型脱羡,針對我們的項(xiàng)目,我們需要獲取距離數(shù)據(jù)免都。
我將獲取HealthKit數(shù)據(jù)封裝在工具類的HealthKitManager中锉罐,代碼如下:
typedef void(^completeBlock)(id);
typedef void(^errorBlock)(NSError*);
/**
* 健康數(shù)據(jù)管理對象
*/
@interface HealthKitManager : NSObject
/**
* 全局管理類
*
* @return
*/
+ (HealthKitManager *)shareManager;
/**
* 獲取某年某月的健康數(shù)據(jù)中路程數(shù)據(jù)(原生數(shù)據(jù))
*
* @param year 年份
* @param month 月份
* @param block 成功回調(diào)
* @param errorBlock 失敗回調(diào)
*/
- (void)getDistancesWithYear:(NSInteger)year
month:(NSInteger)month
complete:(completeBlock) block
failWithError:(errorBlock)errorBlock;
/**
* 獲取某年某月的卡路里數(shù)據(jù)(計(jì)算數(shù)據(jù))
*
* @param weight 體重
* @param year 年份
* @param month 月份
* @param block 成功回調(diào)
* @param errorBlock 失敗回調(diào)
*/
- (void)getKcalWithWeight:(float)weight
year:(NSInteger)year
month:(NSInteger)month
complete:(completeBlock) block
failWithError:(errorBlock)errorBlock;
@end
第一個(gè)方法是從HealthKit獲取每個(gè)月份的距離數(shù)據(jù),我來帶大家看具體的實(shí)現(xiàn)代碼:
- (void)getDistancesWithYear:(NSInteger)year
month:(NSInteger)month
complete:(completeBlock) block
failWithError:(errorBlock)errorBlock{
//想獲取的數(shù)據(jù)類型绕娘,我們項(xiàng)目需要的是走路+跑步的距離數(shù)據(jù)
HKSampleType* sampleType = [HKQuantityType quantityTypeForIdentifier:HKQuantityTypeIdentifierDistanceWalkingRunning];
//按數(shù)據(jù)開始日期排序
NSSortDescriptor* start = [NSSortDescriptor sortDescriptorWithKey:HKSampleSortIdentifierStartDate ascending:NO];
//以下操作是為了將字符串的日期轉(zhuǎn)化為NSDate對象
NSDateFormatter* formatter = [[NSDateFormatter alloc]init];
[formatter setDateFormat:@"yy-MM-dd"];
NSString* startDateStr = [NSString stringWithFormat:@"%ld-%ld-%ld",(long)year,(long)month,(long)1];
//每個(gè)月第一天
NSDate* startDate = [formatter dateFromString:startDateStr];
//每個(gè)月的最后一天
NSDate* endDate = [[NSDate alloc]initWithTimeInterval:31*24*60*60-1 sinceDate:startDate];
//定義一個(gè)謂詞邏輯脓规,相當(dāng)于sql語句,我猜測healthKit底層的數(shù)據(jù)存儲應(yīng)該用的也是CoreData
NSPredicate* predicate = [HKQuery predicateForSamplesWithStartDate:startDate endDate:endDate options:HKQueryOptionNone];
//定義一個(gè)查詢操作
HKSampleQuery* query = [[HKSampleQuery alloc]initWithSampleType:sampleType predicate:predicate limit:HKObjectQueryNoLimit sortDescriptors:@[start] resultsHandler:^(HKSampleQuery * _Nonnull query, NSArray<__kindof HKSample *> * _Nullable results, NSError * _Nullable error) {
if (error) {
if (errorBlock) {
errorBlock(error);
}
}else{
//一個(gè)字典险领,鍵為日期抖拦,值為距離
NSMutableDictionary* multDict = [NSMutableDictionary dictionaryWithCapacity:30];
//遍歷HKQuantitySample數(shù)組,遍歷計(jì)算每一天的距離數(shù)據(jù)舷暮,然后放進(jìn)multDict
for (HKQuantitySample* sample in results) {
NSString* dateStr = [formatter stringFromDate:sample.startDate] ;
if ([[multDict allKeys]containsObject:dateStr]) {
int distance = (int)[[multDict valueForKey:dateStr] doubleValue];
distance = distance + [sample.quantity doubleValueForUnit:[HKUnit meterUnit]];
[multDict setObject:@(distance) forKey:dateStr];
}else{
int distance = (int)[sample.quantity doubleValueForUnit:[HKUnit meterUnit]];
[multDict setObject: @(distance) forKey:dateStr];
}
}
NSArray* arr = multDict.allKeys;
//multDict的鍵值按日期來排序:1號~31號
arr = [arr sortedArrayUsingComparator:^NSComparisonResult(id _Nonnull obj1, id _Nonnull obj2) {
NSString* str1 = [NSString stringWithFormat:@"%@",obj1];
NSString* str2 = [NSString stringWithFormat:@"%@",obj2];
int num1 = [[str1 substringFromIndex:6] intValue];
int num2 = [[str2 substringFromIndex:6] intValue];
if (num1<num2) {
return NSOrderedAscending;
}else{
return NSOrderedDescending;
}
}];
//按排序好的鍵取得值裝進(jìn)resultArr返回給業(yè)務(wù)層
NSMutableArray* resultArr = [NSMutableArray arrayWithCapacity:30];
for (NSString* key in arr) {
[resultArr addObject:multDict[key]];
}
if (block) {
block(resultArr);
}
}
}];
//執(zhí)行查詢
[self.healthStore executeQuery:query];
}
第一個(gè)方法相關(guān)講解我已經(jīng)寫在注釋里态罪;
第二個(gè)方法是根據(jù)healthKit獲取的距離數(shù)據(jù)計(jì)算出卡路里數(shù)據(jù),它是根據(jù)以下公式計(jì)算得的:
卡路里(kcal) = 公里數(shù)(km) * 體重(kg) * 1.036
那么HealthKit的相關(guān)講解就到這里下面,想了解更多內(nèi)容可以戳這里
數(shù)據(jù)展示界面
效果如下:
上面的日歷控件不難實(shí)現(xiàn)复颈,由NSCalendar + UICollectionView實(shí)現(xiàn),就不展開講了。這里主要分析的下面那個(gè)坐標(biāo)圖以及動(dòng)畫效果如何實(shí)現(xiàn)耗啦。
實(shí)現(xiàn)的關(guān)鍵點(diǎn):CAShapeLayer凿菩、UIBezierPath、CABasicAnimation
實(shí)現(xiàn)的代碼在"RecordShowView.m"這個(gè)類中:
坐標(biāo)系的繪畫:
- (void)drawBaseView {
CGFloat W = self.frame.size.width;
CGFloat H = self.frame.size.height;
CGFloat smallPathAlign = (W-40)/(5*7);
CGFloat smallPathLineH = 4;
UIBezierPath* smallPath = [UIBezierPath bezierPath];
for (int i = 1; i<=35; i++) {
[smallPath moveToPoint:CGPointMake(20+smallPathAlign*i, H-20)];
[smallPath addLineToPoint:CGPointMake(20+smallPathAlign*i, H-20-smallPathLineH)];
}
CAShapeLayer* smallPathLayer = [CAShapeLayer layer];
smallPathLayer.path = smallPath.CGPath;
smallPathLayer.strokeColor = [UIColor lightGrayColor].CGColor;
smallPathLayer.lineWidth = 2;
[self.layer addSublayer:smallPathLayer];
UIBezierPath* frameworkPath = [UIBezierPath bezierPath];
[frameworkPath moveToPoint:CGPointMake(20, 0)];
[frameworkPath addLineToPoint:CGPointMake(20, H-20)];
[frameworkPath moveToPoint:CGPointMake(20, H-20)];
[frameworkPath addLineToPoint:CGPointMake(W-20, H-20)];
CGFloat bigPathAlign = (W-40)/5;
CGFloat bigPathLineH = 5;
for (int i = 1; i<=4; i++) {
[frameworkPath moveToPoint:CGPointMake(20+bigPathAlign*i, H-20)];
[frameworkPath addLineToPoint:CGPointMake(20+bigPathAlign*i, H-20-bigPathLineH)];
}
[frameworkPath moveToPoint:CGPointMake(20, 20)];
[frameworkPath addLineToPoint:CGPointMake(20+10, 20)];
CAShapeLayer* frameworkLayer = [CAShapeLayer layer];
frameworkLayer.path = frameworkPath.CGPath;
frameworkLayer.strokeColor = [UIColor grayColor].CGColor;
frameworkLayer.lineWidth = 2;
[self.layer addSublayer:frameworkLayer];
_textLayer = [CATextLayer layer];
_textLayer.string = @"1200卡路里";
_textLayer.fontSize = 12;
_textLayer.bounds = CGRectMake(0, 0, 100, 100);
_textLayer.foregroundColor = UIColorFromRGB(0x43B5FE).CGColor;
_textLayer.contentsScale = [UIScreen mainScreen].scale;
_textLayer.position = CGPointMake(20+10+55,60);
[self.layer addSublayer:_textLayer];
}
CAShapeLayer本身沒有形狀,它的形狀來源于你給定的一個(gè)路徑帜讲,我們可以通過UIBezierPath指定路徑繪畫出各種各樣的形狀衅谷。我們實(shí)現(xiàn)的效果只需要通過畫線就可以完成,moveToPoint方法指定線的起點(diǎn)似将,addLineToPoint指定線的終點(diǎn)获黔,這樣就可以確定一條線。最后將CAShapeLayer添加到父view的圖層就可以顯示出來了在验。
柱狀圖的繪畫
實(shí)現(xiàn)代碼如下:
/**
* 繪畫柱狀圖
*
* @param values 數(shù)值數(shù)組
* @param lineColor 柱狀圖顏色
*
* @return 柱狀圖layer
*/
-(CAShapeLayer *)drawRecordValue:(NSArray *)values lineColor:(UIColor *)lineColor{
CGFloat W = self.frame.size.width;
CGFloat H = self.frame.size.height;
CGFloat lineHight = H- 20 * 2;
CGFloat lineWidth = W- 40;
CGFloat aliginW = lineWidth/(5*7);
UIBezierPath* recordPath = [UIBezierPath bezierPath];
CAShapeLayer *layer = [CAShapeLayer layer];
for (int i = 1; i<values.count; i++) {
CGFloat recordH = [values[i] intValue]*lineHight/1200 > self.frame.size.height -20? self.frame.size.height - 20:[values[i] intValue]*lineHight/1200;
[recordPath moveToPoint:CGPointMake(20+aliginW*i, H-20)];
[recordPath addLineToPoint:CGPointMake(20+aliginW*i, H-20-recordH)];
}
layer.path = recordPath.CGPath;
layer.strokeColor = lineColor.CGColor;
layer.lineWidth = 4;
layer.lineCap = kCALineCapRound;
//設(shè)置幀動(dòng)畫
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
animation.fromValue = @(0.0);
animation.toValue = @(1.0);
animation.autoreverses = NO;
animation.duration = 0.8;
[layer addAnimation:animation forKey:nil];
return layer;
}
遍歷數(shù)組計(jì)算出每個(gè)柱狀圖的高度玷氏,根據(jù)高度設(shè)置繪畫的路徑,路徑確定后腋舌,給layer添加一個(gè)動(dòng)畫效果盏触,這個(gè)動(dòng)畫其實(shí)就是柱狀圖的繪畫過程,注意這里動(dòng)畫keyPath是strokeEnd块饺。
這個(gè)方法外部不能直接調(diào)用赞辩,而是通過設(shè)置柱狀圖的數(shù)值數(shù)組來間接調(diào)用。大家可以看下數(shù)值數(shù)組的setter方法授艰,這里又有一個(gè)要注意的點(diǎn)诗宣,就是設(shè)置新的柱狀圖的時(shí)候,要移除舊的柱狀圖:
/**
* 走路+跑步卡路里數(shù)據(jù)
*
* @param normalRecords
*/
- (void)setNormalRecords:(NSArray *)normalRecords {
if (_normalLayer) {
[_normalLayer removeFromSuperlayer];
}
_normalRecords = [normalRecords copy];
_normalLayer = [self drawRecordValue:normalRecords lineColor:UIColorFromRGB(0x43B5FE)];
[self.layer addSublayer:_normalLayer];
}
/**
* 跑步卡路里數(shù)據(jù)
*
* @param specialRecords
*/
- (void)setSpecialRecords:(NSArray *)specialRecords {
if (_specialLayer) {
[_specialLayer removeFromSuperlayer];
}
_specialRecords = [specialRecords copy];
_specialLayer = [self drawRecordValue:specialRecords lineColor:UIColorFromRGB(0X2E38AD)];
[self.layer addSublayer:_specialLayer];
}
設(shè)計(jì)View復(fù)用機(jī)制
應(yīng)用有這樣一個(gè)使用場景想诅,用戶可以點(diǎn)擊日歷的某一天查看那一天的跑步記錄召庞,每次記錄都會生成一張小卡片,左右滑動(dòng)可以查看每一次跑步記錄:
如果卡片視圖是直接根據(jù)記錄new出來来破,當(dāng)記錄一多就會造成內(nèi)存暴漲篮灼,嚴(yán)重時(shí)會crash。所以我設(shè)計(jì)了一個(gè)View的復(fù)用機(jī)制徘禁,解決這種多page視圖復(fù)用的問題诅诱。
源碼在"XDPageView.m"文件中,大家可以看下頭文件提供方法送朱,已做注釋:
@interface XDPageView : UIView
/**
* 當(dāng)前頁下標(biāo)
*/
@property (nonatomic, assign, readwrite) NSInteger currentPageIndex;
/**
* 自定義page視圖娘荡,使用的時(shí)候判斷是否有dequeueView,如果有就直接dequeueView驶沼,沒有再實(shí)例化一個(gè)新視圖炮沐,可以參考tableView cell 復(fù)用機(jī)制的使用
*/
@property (nonatomic, copy, readwrite) UIView *(^loadViewAtIndexBlock)(NSInteger pageIndex,UIView *dequeueView);
@property (nonatomic, copy, readwrite) UIView *(^loadViewAtIndexBlock)(NSInteger pageIndex,UIView *dequeueView);
/**
* page的數(shù)量
*/
@property (nonatomic, copy, readwrite) NSInteger(^pagesCount)();
@end
以下設(shè)計(jì)思路:
1、創(chuàng)建兩個(gè)容器回怜,一個(gè)用于裝可見視圖大年,最大容量為2,我們假設(shè)當(dāng)前頁面和下一頁面屬于可見;一個(gè)用于裝復(fù)用視圖翔试,最大容量為1轻要。為了記錄可見視圖的下標(biāo)(方便判斷頁面狀態(tài),頁面是屬于當(dāng)前頁垦缅、上一頁還是下一頁)冲泥,裝可視圖的容器,我使用字典來設(shè)計(jì)壁涎;對于復(fù)用池里的視圖無需考慮頁面的狀態(tài)和順序凡恍,所以我使用集合來設(shè)計(jì):
/**
* 可見視圖以及下標(biāo),可見視圖最大數(shù)量為2
*/
@property (nonatomic, strong, readwrite) NSMutableDictionary *visibleViewsItemMap;
/**
* 容量最大為1的復(fù)用池
*/
@property (nonatomic, strong, readwrite) NSMutableSet *dequeueViewPool;
2粹庞、每當(dāng)滑進(jìn)一個(gè)頁面咳焚,滑出的頁面裝入復(fù)用容器洽损,當(dāng)前頁面和下一頁面裝入可見容器(下一頁屬于即將可見庞溜,它復(fù)用“復(fù)用容器”里的視圖,復(fù)用視圖一旦被使用就移出“復(fù)用容器”):
- (void)loadViewsForNeed {
CGFloat itemW = _pageSize.width;
if (itemW) {
CGFloat W = self.bounds.size.width;
//當(dāng)前頁的下標(biāo)
NSInteger startIndex = floorf((float)_scrollView.contentOffset.x / _pageSize.width);
//如果page數(shù)大于1則設(shè)置可見item數(shù)為2碑定,如果只有一頁流码,那么可見就只有1個(gè)
NSInteger numberOfVisibleItems = (_scrollView.contentOffset.x/W) == 0.0 ? 1 : 2;
numberOfVisibleItems = MIN(numberOfVisibleItems, _pageCount);
//當(dāng)前頁和它的下一頁設(shè)置為可見的
NSMutableSet *visibleIndexs = [NSMutableSet set];
for (int i = 0; i < numberOfVisibleItems; i++) {
NSInteger index = startIndex + i;
[visibleIndexs addObject:@(index)];
}
for (NSNumber *num in [_visibleViewsItemMap allKeys]) {
//對于已不可見的視圖,移出可見視圖加入復(fù)用池
if (![visibleIndexs containsObject:num]) {
UIView *view = _visibleViewsItemMap[num];
[self queueInPoolWithView:view];
[view removeFromSuperview];
[_visibleViewsItemMap removeObjectForKey:num];
}
}
for (NSNumber *num in visibleIndexs) {
UIView *view = _visibleViewsItemMap[num];
//加載新的可見視圖延刘,加載完成后加入可視圖容器中
if (view == nil) {
view = [self loadItemViewAtIndex:[num integerValue]];
_visibleViewsItemMap[num] = view;
[_scrollView addSubview:view];
}
}
}
}
小結(jié)
項(xiàng)目的主要細(xì)節(jié)已經(jīng)分析完漫试,大家有什么的地方不懂或者有疑問的,可以在github issue 我或者在評論區(qū)提出碘赖。
項(xiàng)目地址:github.com/caixindong/Running-Life---iOS驾荣。
下一篇博文預(yù)告:XDNetworking網(wǎng)絡(luò)框架的設(shè)計(jì)及源碼分析。