專案簡介
基於HeathKit和高德地圖開發健康跑步App,實現實時繪畫運動軌跡、健康資料管理功能。
做這個專案出於兩個原因:
1、喜歡跑步(也為了減減肥);
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就是輸出,這裡要注意輸出對外界來說只是一個只讀屬性。示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
@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屬性,一旦屬性變化就觸發響應的處理邏輯。示例如下:
1 2 3 4 5 6 7 |
[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]開啟持續定位,具體實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
-(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這個全域性變數中。
1 2 3 4 5 6 7 8 9 |
- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location { if (location.horizontalAccuracy 0) { _distance += [location distanceFromLocation:self.locations.lastObject]; } [self.locations addObject:location]; } } } |
獲取資料之後怎麼實時重新整理UI呢?
我的做法是在NewRunViewController開啟一個定時器,時間間隔是1s,每隔1秒往VM傳運動時間,運動時間相當於函式的自變數,經過VM處理後,它會給C發資料改變的訊號,訊號相對於函式的因變數。實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
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我。具體實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
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回撥的處理,這裡的處理多了對地圖的處理,通過兩個座標確定一條線,並把線新增到地圖上,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location { if (location.horizontalAccuracy 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方法設定軌跡的相關屬性
1 2 3 4 5 6 7 8 9 10 |
- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id )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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
@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就行,因為底層會根據當前活躍的資料庫切換相應的上下文物件。切換資料庫的實現原理:
1 2 3 |
DBNAME = name; _managedObjectContext = nil; _persistentStoreCoordinator = nil; |
用一個static變數存放資料庫的名字,資料庫的命名規則是以使用者的賬戶名的MD5雜湊值作為使用者的資料庫名。因為切換了資料庫,上下文物件改變了,持久化儲存助手也改變,因為兩個都是懶載入,置為nil,到時會重新呼叫他們的getter方法,getter方法內部根據對應的DBNAME建立相應的物件。
切換資料庫的應用場景有三個:app初始化的時候、登陸的時候、退出登陸的時候。
跑步結果
效果如下:

這邊有個功能點就是根據不同速度繪畫不同顏色的運動軌跡。
實現原理:建立一個MKPolyline(地圖軌跡類)的派生類MultiColorPolyline,該類多了一個屬性color,用來記錄當前軌跡的顏色。將普通的軌跡轉化為帶顏色的軌跡實現邏輯放在MathController這個轉換的工具類中,具體程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
+ (NSArray *)colorSegmentsForLocations:(NSArray *)locations { NSMutableArray *speeds = [NSMutableArray array]; double slowestSpeed = DBL_MAX; double fastestSpeed = 0.0; //獲取最慢速度和最快速度 for (int i = 1; i 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 |
遍歷獲取最大速度和最小速度,根據速度與最大速度和最小速度比較,設定一個比例,根據比例調配相應的顏色,顏色的計算演算法如上,就不展開講了。
小結
今天分析了大體框架和跑步模組一些細節的實現,關於記錄模組的分析我打算放在第二篇來分析,先做下預告,內容主要有三個:
實現view的複用機制解決記憶體暴漲問題、貝塞爾曲線與動畫實現一個優雅的資料展示介面、HeathKit框架的使用。
專案地址:github.com/caixindong/Running-Life—iOS,有問題歡迎大家提出討論,大家覺得不錯,就賞個star。