項目簡介
基于HeathKit和高德地圖開發健康跑步App,實現實時繪畫運動軌跡、健康數據管理功能。
做這個項目出于兩個原因:
1、喜歡跑步(也為了減減肥<( ̄3 ̄)>);
2、喜歡運用自己的知識實際,里面有我自己寫的一些開源組件( 技術有限,設計得不好的地方,大家多多指導);
運行效果如下:
項目目錄
Config目錄:接口配置文件、宏定義和頭文件配置文件;
AppINit目錄:關于App的啟動設置,如第三方SDK初始化、界面初始化、HeathKit初始化配置;
Module目錄:業務模塊,由以下這幾個模塊組成:公共模塊、跑步模塊、記錄模塊、個人模塊、設置模塊、登陸注冊模塊;
Resource目錄:圖片資源和字體資源;
RunKit目錄:一些類的拓展、工具類、網絡層方案、持久化存儲層方案;
Vendor目錄:一些不支持Cocoapod第三方庫;
Pod:支持Cocoapod第三方庫;
業務層架構
MVVM架構(使用Facebook的KVOController實現view和viewModel的綁定,項目往ReactCocoa遷移中)
viewModel如何設計?
viewModel負責從原始數據源獲取原始數據,運用對應的數據處理邏輯,轉化為view層顯示的數據。他不引入UIKit相關類,所以他與UI無關,也方便我們進行單元測試。實際上,它就是一層function core,理想上對于相同的輸入會導出相同的結果。所以viewmodel得設計主要包含三部分內容:輸入、輸出、命令,簡化成函數表達就是y = f(x),f函數指的是命令,x是輸入,y就是輸出,這里要注意輸出對外界來說只是一個只讀屬性。示例如下:
@interface ResultViewModel : NSObject
/**
* 跑步距離
*/
@property (nonatomic, copy, readonly) NSString *distanceLabelText;
/**
* 跑步時間
*/
@property (nonatomic, copy, readonly) NSString *timeLabelText;
/**
* 跑步步數
*/
@property (nonatomic, copy, readonly) NSString *paceLabelText;
/**
* 卡路里
*/
@property (nonatomic, copy, readonly) NSString *kcalLableText;
/**
* 消耗雞腿數
*/
@property (nonatomic, copy, readonly) NSString *countLabelText;
/**
* 運動軌跡(不同顏色)
*/
@property (nonatomic, copy, readonly) NSArray *colorSegmentArray;
/**
* 地圖顯示區域
*/
@property (nonatomic, assign, readonly) MKCoordinateRegion region;
/**
* 跑步排名
*/
@property (nonatomic, copy, readonly) NSString *rank;
/**
* 網絡失敗
*/
@property (nonatomic, strong, readonly) NSNumber *netFail;
/**
* 構造器
*
* @param run 跑步記錄
*
* @return
*/
- (instancetype)initWithRunModel:(Run *)run;
/**
* 上傳跑步記錄并獲取排名
*/
- (void)postRunRecordToServerAndGetRank;
/**
* 僅僅獲取獲取跑步排名
*/
- (void)getRankData;
@end
viewModel與view如何綁定?
綁定的目的就是為了解決view與viewModel通信的問題。MVVM天然最好的綁定機制就是Facebook的ReactCocoa,它是函數式響應式編程思想的一個體現,它的核心就是響應數據的變化、統一異步編程模型,綁定的具體做法就是view層通過訂閱viewModel上面的信號,先模擬處理一遍,這里模擬的意思是先從腦海里過一遍邏輯,實際不響應,當有信號發過來的時候才實際觸發。
但是他需要一定的學習成本,學習成本較大,本人也在不斷學習當中,所有我們換種方式來實現這種響應機制。想一下,cocoa中是不是有提供這種監聽-響應的機制,沒錯,就是KVO,但是原生KVO寫起來會惡心死人,所有我們可以借助Facebook提供一個KVO框架(kvoController)來實現優雅的綁定。(Facebook真是為了iOS的開發做出很多貢獻,開源了那么多好用的工具)。
綁定方式就是view層 kvo viewModel層的readonly屬性,一旦屬性變化就觸發響應的處理邏輯。示例如下:
[self.KVOController observe:self.viewModel keyPath:@"rank" options:NSKeyValueObservingOptionNew block:^(id observer, id object, NSDictionary *change) {
if (self.viewModel.rank) {
self.recordCardView.rankLabel.text = self.viewModel.rank;
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO;
}
}];
功能實現
項目搭好條條框框,現在來分析具體的功能實現。本項目有兩個功能,一個是跑步,另外一個就是記錄,每個大功能點下又分幾個小功能點,功能的示意圖如下:
跑步
這里主要分析跑步過程的具體邏輯,界面如下:
源碼在這個文件:"NewRunViewModel.m"
跑步數據源
跑步數據來源定位,這里定位SDK選擇高德SDK,雖然原生也是高德地圖,但經過測試發現原生的定位很不準,我也不知道具體原因是什么。
定義一個定位管理器,設置好相應的配置參數,因為為了跑步數據的精確度,所以將定位的準確度設置為最好,調用 [self.locationManager startUpdatingLocation]開啟持續定位,具體實現如下:
-(AMapLocationManager *)locationManager{
if (!_locationManager) {
_locationManager = [[AMapLocationManager alloc] init];
_locationManager.delegate = self;
_locationManager.desiredAccuracy = kCLLocationAccuracyBest;
_locationManager.distanceFilter = kCLDistanceFilterNone;
//設置允許后臺定位參數,保持不會被系統掛起
[_locationManager setPausesLocationUpdatesAutomatically:NO];
if([[[UIDevice currentDevice] systemVersion] floatValue]>9.0){
[_locationManager setAllowsBackgroundLocationUpdates:YES];//iOS9(含)以上系統需設置
}
}
return _locationManager;
}
定位成功后會不斷的回調AMapLocationManagerDelegate的- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location方法,并不是所有定位數據都是有效,需要對數據進行過濾,過濾的依據就是horizontalAccuracy和時間偏差。horizontalAccuracy表示水平準確度,這么理解,它是以定位點為圓心的半徑,返回的值越小,證明準確度越好,如果是負數,則表示corelocation定位失敗,我們知道GPS信號會受地域的影響,有時強,有時弱,設置30是一個中和的做法,因為我們不能保證每次定位回來的數據都是絕對精確,如果設置得太小,可能過濾得到的數據很少,太大就會誤差太大。howRecent用于計算定位結果與當前時間偏差,如果偏差超過2秒就過濾,這個2秒也是一個中和值。過濾完數據就可以計算跑步的距離,保存在_distance這個全局變量中。
- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location {
if (location.horizontalAccuracy < 30) {
NSDate *eventDate = location.timestamp;
NSTimeInterval howRecent = [eventDate timeIntervalSinceNow];
if (fabs(howRecent) < 2.0 ) {
if (self.locations.count > 0) {
_distance += [location distanceFromLocation:self.locations.lastObject];
}
[self.locations addObject:location];
}
}
}
獲取數據之后怎么實時刷新UI呢?
我的做法是在NewRunViewController開啟一個定時器,時間間隔是1s,每隔1秒往VM傳運動時間,運動時間相當于函數的自變量,經過VM處理后,它會給C發數據改變的信號,信號相對于函數的因變量。實現如下:
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0f
target:self
selector:@selector(eachSecond:)
userInfo:nil
repeats:YES];
/**
* 運動計數器回調
*
* @param timer
*/
- (void)eachSecond:(NSTimer*)timer {
_seconds++;
self.viewModel.duration = _seconds;
}
//下面是綁定的代碼,監聽VM傳過來的信號,有變化就刷新UI
[self.KVOController observe:self.viewModel keyPath:@"runDataChange" options:NSKeyValueObservingOptionOld block:^(id observer, id object, NSDictionary *change) {
if ([self.viewModel.runDataChange boolValue]) {
[_boardView configureViewWithViewModel:self.viewModel.currentRunData];
}
}];
智能判斷跑步狀態
通過CMMotionManager(是蘋果的運動管理器框架,可以獲取設備加速計、陀螺儀的即時數據)來智能判斷跑步狀態,以決定是否繼續記錄。這個功能點體現在當用戶運動幅度變小的時候,小到一定程度的時候,app就判斷用戶處于休息階段,當這個階段持續超過8秒就暫停跑步記錄,進入以下狀態:
但用戶又開始運動的時候,運動幅度到達一定程度的時候,有開啟跑步狀態。
它的實現原理是通過陀螺儀來實現(暫時還沒適配舊版本手機,因為iphone5以下沒有陀螺儀),一般我們跑步的時候,手機拿在手上或者放在褲袋里,所以y軸和z軸偏移最大也最頻繁,所以通過判斷y軸和z軸的加速度,如果他們的加速度小于2,則用戶不處于跑步狀態,這個2的值是自己試出來- -,如果大家有更好的依據歡迎到github issue我。具體實現如下:
NSOperationQueue* queue = [[NSOperationQueue alloc]init];
/**
* 陀螺儀是否可用
*/
if (self.motionManger.gyroAvailable) {
[self.motionManger startGyroUpdatesToQueue:queue withHandler:^(CMGyroData * _Nullable gyroData, NSError * _Nullable error) {
CGFloat y = gyroData.rotationRate.y;
CGFloat z = gyroData.rotationRate.z;
if (fabs(y)>2||fabs(z)>2) {
_stopCount = 0;
if(![self.isRunning boolValue]) self.isRunning = @YES;
}else{
_stopCount++;
if (_stopCount > 8) {
if([self.isRunning boolValue]) self.isRunning = @NO;
}
}
}];
}else{
NSLog(@"陀螺儀不可用");
}
運動軌跡
我將運動軌跡的繪畫邏輯分離到MapViewController中,里面也有一個定位管理對象,定位成功也會不斷的回調,相比NewRunController回調的處理,這里的處理多了對地圖的處理,通過兩個坐標確定一條線,并把線添加到地圖上,代碼如下:
- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location {
if (location.horizontalAccuracy < 30) {
_firstLocate = NO;
NSDate *eventDate = location.timestamp;
NSTimeInterval howRecent = [eventDate timeIntervalSinceNow];
if (fabs(howRecent) < 2.0 && location.horizontalAccuracy < 30) {
if (self.locations.count > 0) {
CLLocationCoordinate2D coords[2];
coords[0] = ((CLLocation *)self.locations.lastObject).coordinate;
coords[1] = location.coordinate;
MKCoordinateRegion region =
MKCoordinateRegionMakeWithDistance(location.coordinate, 500, 500);
[self.myMapView setRegion:region animated:YES];
[self.myMapView addOverlay:[MKPolyline polylineWithCoordinates:coords count:2]];
}
[self.locations addObject:location];
}
}else{
if (_firstLocate) {
MKCoordinateRegion region =
MKCoordinateRegionMakeWithDistance(location.coordinate, 500, 500);
[self.myMapView setRegion:region animated:YES];
_firstLocate = NO;
}
}
}
通過mapView的一個delegate方法設置軌跡的相關屬性
- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id < MKOverlay >)overlay {
if ([overlay isKindOfClass:[MKPolyline class]]) {
MKPolyline *polyLine = (MKPolyline *)overlay;
MKPolylineRenderer *aRenderer = [[MKPolylineRenderer alloc] initWithPolyline:polyLine];
aRenderer.strokeColor = UIColorFromRGB(0x43B5FE);
aRenderer.lineWidth = 3;
return aRenderer;
}
return nil;
}
保存跑步記錄
數據保存在本地數據庫中,出于學習的目的,我這邊持久層選擇了CoreData,它是蘋果推薦的持久層存儲框架,底層是sqlite,做了面向對象的封裝。上手有點難度,需要一定的學習成本,關于CoreData的具體使用,大家自行Google或baidu,在這里就不展開將。我們通過.xcdatamodeld可以十分方便地創建我們的實體對象,該項目主要有兩個實體對象:跑步記錄、實時位置數據,兩者是有關聯的,一次跑步數據關聯著一系列實時位置數據。
當時在設計數據存儲方案的時候,遇到這樣一個問題:
如果用戶沒登錄就發起跑步,跑步結束后數據插入到數據庫,這些數據是沒有用戶認領的。當用戶登陸的時候,這部分無用戶態的數據該如何處理。當用戶退出登陸的時候,原有記錄的數據是保存還是清除?保存又該如何處理呢?
后來我參考了Nike的Running的處理邏輯,一旦登陸用戶,這些無用戶態的數據就被登陸用戶認領,退出登錄數據保存在本地。
既然處理邏輯想好了,這么數據存儲方案要如何讓設計呢?
大家可以看我基于CoreData封裝的CoreDataManager:
@interface CoreDataManager : NSObject
/**
* 臨時管理上下文對象
*/
@property (readonly, strong, nonatomic) NSManagedObjectContext *tempManagedObjectContext;
/**
* 管理上下文對象
*/
@property (readonly, strong, nonatomic) NSManagedObjectContext *managedObjectContext;
/**
* 全局管理類
*
* @return
*/
+ (CoreDataManager *)shareManager;
/**
* 切換數據庫,如果沒有就新建
*
* @param name 數據庫名字
*/
- (void)switchToDatabase:(NSString *)name;
/**
* 切換到臨時數據庫
*/
- (void)switchToTempDatabase;
/**
* 保存上下文對象
*/
- (void)saveContext;
/**
* 保存臨時上下文對象
*/
- (void)saveTempContext;
@end
為了讓大家更好地了解這個方案,我普及一點點CoreData的知識,CoreData框架包含三層內容:
1、底層數據庫;
2、持久化存儲助手,作為業務層與持久層的協調對象,負責從數據庫獲取數據并返回適合的數據給業務層:
3、管理上下文對象,參與具體的業務交互;
一個數據庫對應一個上下文對象,所以我的方案設計了兩個上下文對象,一個對應著存放臨時數據的數據庫,另一個對應存放用戶數據的數據。tempManagedObjectContext主要作用是為了獲取臨時數據用于合并數據庫,平時業務交互直接用managedObjectContext就行,因為底層會根據當前活躍的數據庫切換相應的上下文對象。切換數據庫的實現原理:
DBNAME = name;
_managedObjectContext = nil;
_persistentStoreCoordinator = nil;
用一個static變量存放數據庫的名字,數據庫的命名規則是以用戶的賬戶名的MD5哈希值作為用戶的數據庫名。因為切換了數據庫,上下文對象改變了,持久化存儲助手也改變,因為兩個都是懶加載,置為nil,到時會重新調用他們的getter方法,getter方法內部根據對應的DBNAME創建相應的對象。
切換數據庫的應用場景有三個:app初始化的時候、登陸的時候、退出登陸的時候。
跑步結果
效果如下:
這邊有個功能點就是根據不同速度繪畫不同顏色的運動軌跡。
實現原理:創建一個MKPolyline(地圖軌跡類)的派生類MultiColorPolyline,該類多了一個屬性color,用來記錄當前軌跡的顏色。將普通的軌跡轉化為帶顏色的軌跡實現邏輯放在MathController這個轉換的工具類中,具體代碼如下:
+ (NSArray *)colorSegmentsForLocations:(NSArray *)locations {
NSMutableArray *speeds = [NSMutableArray array];
double slowestSpeed = DBL_MAX;
double fastestSpeed = 0.0;
//獲取最慢速度和最快速度
for (int i = 1; i < locations.count; i++) {
Location *firstLoc = [locations objectAtIndex:(i-1)];
Location *secondLoc = [locations objectAtIndex:i];
CLLocation *firstLocCL = [[CLLocation alloc] initWithLatitude:firstLoc.latitude.doubleValue longitude:firstLoc.longtitude.doubleValue];
CLLocation *secondLocCL = [[CLLocation alloc] initWithLatitude:secondLoc.latitude.doubleValue longitude:secondLoc.longtitude.doubleValue];
double distance = [secondLocCL distanceFromLocation:firstLocCL];
double time = [secondLoc.timestamp timeIntervalSinceDate:firstLoc.timestamp];
double speed = distance/time;
slowestSpeed = speed < slowestSpeed ? speed : slowestSpeed;
fastestSpeed = speed > fastestSpeed ? speed : fastestSpeed;
[speeds addObject:@(speed)];
}
double midSpeed = (slowestSpeed + fastestSpeed)/2;
// 慢的用紅色
CGFloat s_red = 139/255.0f;
CGFloat s_green = 254/255.0f;
CGFloat s_blue = 132/255.0f;
// 不快不慢的用黃色
CGFloat m_red = 101/255.0f;
CGFloat m_green = 254/255.0f;
CGFloat m_blue = 249/255.0f;
// 快的用綠色
CGFloat f_red = 67/255.0f;
CGFloat f_green = 181/255.0f;
CGFloat f_blue = 254/255.0f;
NSMutableArray *colorSegments = [NSMutableArray array];
for (int i = 1; i < locations.count; i++) {
Location* firstLoc = [locations objectAtIndex:(i-1)];
Location* secondLoc = [locations objectAtIndex:i];
CLLocationCoordinate2D coords[2];
coords[0].latitude = firstLoc.latitude.doubleValue;
coords[0].longitude = firstLoc.longtitude.doubleValue;
coords[1].latitude = secondLoc.latitude.doubleValue;
coords[1].longitude = secondLoc.longtitude.doubleValue;
NSNumber * speed = [speeds objectAtIndex:(i-1)];
UIColor * color = [UIColor blackColor];
if (speed.doubleValue < midSpeed) {
double ratio = (speed.doubleValue - slowestSpeed) / (midSpeed - slowestSpeed);
CGFloat red = s_red + ratio * (m_red - s_red);
CGFloat green = s_green + ratio * (m_green - s_green);
CGFloat blue = s_blue + ratio * (m_blue - s_blue);
color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0f];
} else {
double ratio = (speed.doubleValue - midSpeed) / (fastestSpeed - midSpeed);
CGFloat red = m_red + ratio * (f_red - m_red);
CGFloat green = m_green + ratio * (f_green - m_green);
CGFloat blue = m_blue + ratio * (f_blue - m_blue);
color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0f];
}
MultiColorPolyline *segment = [MultiColorPolyline polylineWithCoordinates:coords count:2];
segment.color = color;
[colorSegments addObject:segment];
}
return colorSegments;
}
遍歷獲取最大速度和最小速度,根據速度與最大速度和最小速度比較,設置一個比例,根據比例調配相應的顏色,顏色的計算算法如上,就不展開講了。
小結
今天分析了大體框架和跑步模塊一些細節的實現,關于記錄模塊的分析我打算放在第二篇來分析,先做下預告,內容主要有三個:
實現view的復用機制解決內存暴漲問題、貝塞爾曲線與動畫實現一個優雅的數據展示界面、HeathKit框架的使用。
項目地址:github.com/caixindong/Running-Life---iOS,有問題歡迎大家提出討論,大家覺得不錯,就賞個star。