先日社内で開催されたiOSxFirebaseハッカソンに参加しました。お題は、iOSアプリであること、Firebaseを使っていること。
ハッカソン自体は10:00〜19:00の平日で、19:00以降はプレゼン、懇親会などを実施しました。
実は運営もやりました。(そして優勝もしてしまいました・・ボソッ)
ハッカソンの運営は初めてだったので、いろいろと運営の知見も得られましたがそれは別として、本記事では参加者側の視点でアレコレとりとめもない話を書きます。
何を作るか
事前にアイデアソンをやったもののハッカソン自体は10:00〜19:00という短い期間です。何をどう作るかは悩みどころでした。
まずは以下のようなことを考えながら当日を迎えた気がします。
- アプリの目的がはっきりしていること。(用途)
- その目的に沿った機能は何か(仕様)
- お題であるFirebaseをどう活かすか、そもそも動くのか?(技術)
- 1日で作りきることができるか(日程)
社外から見えられた審査員の方々も同じようなおっしゃっていましたが、これって要するに仕事の進め方と同じですよね。
アプリの目的
当社では現在「運動し放題制度」という制度を検討中です。これは4〜5名程度のメンバーでチームを組んで、個人で運動目標を設定し、目標と活動実績をチームで共有しながら、最終的にチーム目標(個人目標とは別で良い)を達成すれば自分たちで設定したインセンティブが得られる!というものです。運動を続けるためにはどうすれば良いか、を実証実験している最中で、これのチームの運動をサポートするようなアプリを作ろうと思い立ちました。
そこでアプリの目的は、
- チームメンバーの運動状況を共有することで、運動に対するモチベーションを維持する
- アプリ使用状況や活動内容からアプリ自体を改善して、さらに上記の目的にフィットさせる
としました。2番目のは漠然と思っていたことですが、プレゼンの時にしゃべってしまったので、最初から考えていたことにしよう・・・
アプリの仕様
【要求仕様】
- チームメンバーの歩数を取得しTableViewリストで表示する
- 1週間の歩数を集計してSlack(運動し放題制度活動記録Channel)に投稿する
- チーム目標の達成状況(ないしは脱落!)を表示する
- 運動し始めたらなんとかしてPushでメンバーに通知したい(願望)
- その他運動記録ができたら良いな(漠然)
【技術仕様】
- HealthKitを使う(なんとなく使い方はわかるかも)
- ヘルスデータはFirestoreに入れればなんとかなるかも(Queryとかあるはず)
- PushはなんかそれらしいのがFirebaseにあったな・・・
- SlackへはIncoming Hookで投稿できるはず
- Slackへの投稿でAlamofireを使う予定
- RxSwiftは使うか・・・
大分いい加減です。
心がけたこと
とにかくアプリが動かないと始まらないと考えました。最悪機能は中途半端でもアプリがランチして何がしかの画面を表示すること。これは必須です。こころがけたのは下記のような事柄です。
- まずアプリを起動する(あとはなんとかなる!)
- コアになる機能を動かす(特にHealthKitとFirestoreに集中する)
- 時間配分を考える(2人チームだったので、どちらかがスタックしないように作業配分する)
とにかくアプリを起動することを最初に行いました。プロジェクト、ターゲットの設定を行い真っ白い画面でアプリが起動するまで小1時間かかったかもしれません。
並行してもう一人のメンバーである桃山さん(仮名)にアイコンを作成してもらいました。最終的にアイコンはプレゼンでも使わなかったのですが、アプリ作成のモチベーションアップにはなりました。意外にこうゆうのも気持ちの面で効いてきます!
やったこと
- HealthKitから歩数は取得できなかった。(サンプル通りにコーディングしても動かなかった)
- なのでワークアウトを表示することにした。
- 桃山さんに走っているアニメーションよくね?と無茶振りする。無茶振りに答えて速度が変わるアニメーションを用意してくれた!
- 当初とは違って、UITableviewCellには「ニックネーム」「消費エネルギー」「走行距離」「走る人のアニメーション」を表示することに。
- 桃山さんにUITableviewCell実装を一括発注して作ってもらう。
- Firebaseメンターさんにやり方を聴きながらFirestoreを実装した。(とりあえず動くまでは簡単でした)
- RxSwiftを使ってそれっぽく実装した。
- 桃山さんに色などを調整してもらう。
- タイムア〜〜〜ップ!!!
最後まで微妙なバグが残っていたせいもあって、プレゼンテーションは資料なしで行く!と決断しました。(もう疲れてパワポ作る気力も無かったし)
アプリがアニメーションしてるので、これでなんとか体裁は繕えるだろう、との目算もありました。
参加した感想
- 疲れました。金曜日のハッカソンの後、土曜日、日曜日と疲れが取れず・・・歳
- 最低限の機能実装しかできなかったのが悔しい。しかし、逆に言えばアプリ作成の最低限のラインは死守できたかなと思います。
- 1日ハッカソンではこのあたりが限界ですね。
- 桃山さんのおかげで見栄えがいいやつができました。これは大きい!、ありがとうございます!
ソースコードの説明
アプリ全体として、RxSwiftとAlamofire(間に合わず使ってないけど)を利用します。
アーキテクチャーとして、MVVMを採用。(特にViewModelはイマイチだけど)
Model部としては下記のようなものを用意しました。備考欄に実際の結果(反省?)を記しています。
名称 | 責務 | 結果 |
---|---|---|
HealthUtil | HealthKitを使ってヘルスデータを取り出す | 歩数がうまく取れず、途中でワークアウト情報に変更 |
FirebaseUtil | Firestoreを使って個人のヘルスデータを蓄積し共有する | 簡単に動作した。しかし重複データ処理などまったく何もしていない |
SlackUtil | 定期的に運動結果をSlackに報告する | 時間切れ、未実装、空クラスのみ |
Push通知 | 空クラスさえ見当たらない・・・ |
HealthKit
open class HealthUtil {
private let healthStore = HKHealthStore()
static public let shared = HealthUtil()
private init(){}
public struct Observables {
fileprivate let authSubject = PublishSubject<Bool>()
var auth: Observable<Bool> { return authSubject }
fileprivate let workoutsSubject = PublishSubject<[HKWorkout]>()
var workouts: Observable<[HKWorkout]> { return workoutsSubject }
fileprivate let stepCountSubject = PublishSubject<(Date, Int)>()
var stepCount: Observable<(Date, Int)> { return stepCountSubject }
}
public let observables = Observables()
public func auth() {
let readDataTypes: Set<HKObjectType> = [
HKWorkoutType.workoutType(),
HKObjectType.quantityType(forIdentifier: .heartRate)!,
]
healthStore.requestAuthorization(toShare: nil, read: readDataTypes) { (success, error) in
self.observables.authSubject.onNext(success)
}
}
public func getWorkouts() {
let predicate = HKQuery.predicateForWorkouts(with: HKWorkoutActivityType.running)
let sortDescriptor = NSSortDescriptor(key:HKSampleSortIdentifierStartDate, ascending: false)
let sampleQuery = HKSampleQuery(sampleType: HKWorkoutType.workoutType(), predicate: predicate, limit: 0, sortDescriptors: [sortDescriptor]) { (sampleQuery, results, error) in
if let _results = results as? [HKWorkout]
{
self.observables.workoutsSubject.onNext(_results)
}
}
self.healthStore.execute(sampleQuery)
}
Observableなどは、トリガーとなる関数名と重複したり(Namespace問題)、コード的にまとめたかったので public struct Observables {...}
として宣言し、public let observables = Observables()
でインスタンス化しています。
HealthKitはあまり情報がないようで、特に歩数の取得は途中でギブアップしました。当初は各メンバーの歩数の集計を表示する予定で、さらに1週間の総歩数をSlackにアップする予定でしたが、ワークアウト情報に変更しました。
Firestore
Firestoreで使用するデータ構造は下記のようにしました。
collectionは users
とhealthData
の2つ。collectionはnickname
とteam_id
を使ってアプリにて結合されます。
enum table: String
{
case users
case healthData
}
enum keys: String
{
case nickname
case team_id
case stepCount
case date
case totalDistance
case totalEnergyBurned
}
ユーザの追加と読み出し
func add(nickName: String, team_id: String)
{
var ref: DocumentReference? = nil
ref = db.collection(table.users.rawValue).addDocument(data: [
keys.nickname.rawValue: nickName,
keys.team_id.rawValue: team_id
]) { err in
if let err = err {
print("Error adding document: \(err)")
} else {
print("Document added with ID: \(ref!.documentID)")
}
}
}
func readUsers(team_id: String)
{
db.collection(table.users.rawValue).whereField(keys.team_id.rawValue, isEqualTo: team_id).getDocuments(){ (querySnapshot, err) in
if let err = err {
print("Error getting documents: \(err)")
} else {
var users = [User]()
for document in querySnapshot!.documents {
print("\(document.documentID) => \(document.data())")
let user = User(document.data())
users.append(user)
}
self.observables.usersSubject.onNext(users)
}
}
}
func read(nickName: String)
{
db.collection(table.users.rawValue).whereField(keys.nickname.rawValue, isEqualTo: nickName).getDocuments(){ (querySnapshot, err) in
if let err = err {
print("Error getting documents: \(err)")
} else {
for document in querySnapshot!.documents {
print("\(document.documentID) => \(document.data())")
let user = User(document.data())
self.observables.userSubject.onNext(user)
}
}
}
}
ヘルスデータの追加と読み出し
func addHealthData(nickName: String, stepCount: Int, totalDistance: String, totalEnergyBurned: String)
{
var ref: DocumentReference? = nil
ref = db.collection(table.healthData.rawValue).addDocument(data: [
keys.nickname.rawValue: nickName,
keys.stepCount.rawValue: stepCount,
keys.totalEnergyBurned.rawValue: totalEnergyBurned,
keys.totalDistance.rawValue: totalDistance,
keys.date.rawValue: Timestamp(date: Date())
//"date": FieldValue.serverTimestamp()
]) { err in
if let err = err {
print("Error adding document: \(err)")
} else {
print("Document added with ID: \(ref!.documentID)")
}
}
}
func readHealthData()
{
db.collection(table.healthData.rawValue).getDocuments() { (querySnapshot, err) in
if let err = err {
print("Error getting documents: \(err)")
} else {
var healthData = [HealthData]()
for document in querySnapshot!.documents {
print("\(document.documentID) => \(document.data())")
healthData.append(HealthData(document.data()))
}
self.observables.healthDataSubject.onNext(healthData)
}
}
}
ハッカソンの結果
優勝して新型「MacBook Air」をいただきました! 社内開催でしかも参加チームがそれほど多くなかったせいかもしれません。運営もやっていた身で商品を頂いて良いものか数秒
だけ迷いましたが、潔く
いただきました。ありがとうございました! 桃山さん、ありがとう〜