はじめに
「あと5分だけ…」の二度寝からの遅刻、誰もが経験あると思います。
既存の目覚ましアプリの多くは「パズルを解かないと止められない」というストレス方式。でも本当の問題は起きた後にあります。天気が急変した、電車が遅延してる、今日に限って予定が詰まってる——何から手をつければ間に合うかが分からず、結局遅刻する。
そこで作ったのが Stellise(ステライズ) です。
💡 オンデバイスAIで睡眠を解析し、天気と交通情報を加味して「今朝やるべきこと」をリアルタイムに再計算する、プライバシー重視のiOSアプリ
| 朝(晴れ) | 朝(雨) | 夜 |
|---|---|---|
![]() |
![]() |
![]() |
背景は画像素材ではなく、SceneKitでリアルタイム生成しています(後述)
この記事の目的: 実装を晒して、設計・UX・コード品質について忌憚のないフィードバックをいただきたいです。「ここがダメ」「この設計は危ない」「こうしたほうがいい」など、なんでも歓迎です。
🎯 解決したい課題
起きられない → 起きた後も迷う → 遅刻する
この3段階の課題を一気通貫で解決したいと考えました。
- スマートアラーム: 眠りの浅いタイミングで起こす(オンデバイスML)
- 朝タスクの自動再計算: 天気悪化・電車遅延を検知 → AIがタスクの取捨選択と順序を再構成
- ストレスなし: パズルではなく「やることリスト」で自然に行動を促す
🏗 技術構成
┌─────────────────────────────────────────────┐
│ iOS App (Swift / SwiftUI / Combine) │
│ ├── SceneKit (手続き生成の3D背景) │
│ ├── TensorFlow Lite (YAMNet 音声分類) │
│ ├── CoreMotion (体動検知) │
│ ├── CoreLocation / MapKit (経路計算) │
│ └── StoreKit 2 / App Intents / EventKit │
├─────────────────────────────────────────────┤
│ Backend │
│ ├── Flask (PythonAnywhere) ← 本体API │
│ ├── Vercel Serverless (Node.js) ← チャット │
│ └── Firebase (Auth / Firestore) │
├─────────────────────────────────────────────┤
│ 外部API │
│ ├── OpenWeather │
│ ├── Google Maps Distance Matrix │
│ └── Gemini (タスク提案 / サポートチャット) │
└─────────────────────────────────────────────┘
🔥 技術的にがんばったポイント
1. SceneKitで3D背景をフルプロシージャル生成
画像素材を1枚も使わず、空・太陽・月・星・雲・雨をすべてコードで生成しています。
// 月のクレーターもCGContextで手続き生成
private func createMoonCraterTexture() -> UIImage {
let size = CGSize(width: 256, height: 256)
UIGraphicsBeginImageContext(size)
let ctx = UIGraphicsGetCurrentContext()!
// ランダムな位置・サイズでクレーターを描画
for _ in 0..<12 {
let x = CGFloat.random(in: 30...226)
let y = CGFloat.random(in: 30...226)
let r = CGFloat.random(in: 8...25)
ctx.setFillColor(UIColor(white: 0.6, alpha: 0.3).cgColor)
ctx.fillEllipse(in: CGRect(x: x-r, y: y-r, width: r*2, height: r*2))
}
// ...
}
- 時刻に応じて太陽/月が東→南中→西へ弧を描いて移動
- 天気APIの結果で雲の密度・雨の強さが変化
- 雨は「降雨」だけでなく「窓ガラスに伝う水滴」まで表現
-
preferredFramesPerSecond = 30でバッテリー消費を抑制
🙏 フィードバック求む: SceneKitの使い方、パフォーマンス最適化の観点で「こうしたほうがいい」があれば教えてください。
2. オンデバイスMLで睡眠解析(プライバシー重視)
マイク入力 → AVAudioEngine → PCM変換 → YAMNet (TFLite) → いびき/咳の分類
CoreMotion → 加速度センサー → 体動カウント → 睡眠深度推定
すべて端末内で完結。音声の生データをサーバに送りません。
// SoundAnalyzer.swift — スレッドセーフなTFLite推論ワーカー
final class SoundInferenceWorker: @unchecked Sendable {
private let interpreter: Interpreter
private let queue = DispatchQueue(label: "tflite-inference", qos: .userInitiated)
func classify(audioData: Data) async throws -> [ClassificationResult] {
return try await withCheckedThrowingContinuation { continuation in
queue.async { [self] in
// TFLite Interpreter はスレッドセーフでないため、
// 専用キューでシリアライズ
// ...
}
}
}
}
3. 夜空の星を決定論的に生成(メモリ節約)
NightViewの背景の星は、SwiftUIの@Stateに座標を保持するとメモリが無駄なので、線形合同法(LCG)ベースの擬似乱数で毎フレーム同じ位置に再描画しています。
// 決定論的な擬似乱数 → 毎回同じ星空を再現
struct LCG {
var state: UInt64
mutating func next() -> UInt64 {
state = state &* 6364136223846793005 &+ 1442695040888963407
return state
}
}
4. AppState(状態管理の中核)
AppStateがObservableObjectとして天気・タスク・交通・睡眠・アラームの全状態を一元管理しています。
自覚している問題: AppStateが62KBの巨大ファイルになっており、明らかなGod Objectです。TaskService、WeatherService、AlarmServiceなどに分割すべきと思いつつ、個人開発の速度を優先してしまいました。
🙏 フィードバック求む: SwiftUIアプリでの状態管理分割のベストプラクティス、iOS 17+の@Observableへの移行判断基準など、知見があれば教えてください。
5. ドラッグ&ドロップで朝タスクを並べ替え
SwiftUIのレイアウトシステムとドラッグジェスチャーの衝突を防ぐため、ドラッグ中は一時的なorderedTasksを保持し、リリース時にAppStateへコミットするパターンを採用しました。
// TaskListView.swift
// .global 座標系を使うことで、ビュー内部オフセット変化による
// ジェスチャーのグリッチを防止
.gesture(
DragGesture(coordinateSpace: .global)
.onChanged { value in
// 一時配列を操作
updateDragPosition(value.translation)
}
.onEnded { _ in
// AppStateにコミット
commitReorder()
}
)
🔒 プライバシー設計
| データ | 処理場所 | サーバ送信 |
|---|---|---|
| 音声(いびき・咳検知) | 端末内(YAMNet) | ❌ |
| 体動データ | 端末内(CoreMotion) | ❌ |
| 睡眠スコア(数値のみ) | サーバ | ✅(Firebase匿名認証経由) |
| カレンダー予定 | サーバ | ✅(タイトルと時刻のみ) |
| おおよその位置情報 | サーバ | ✅(天気・経路計算用) |
- 広告SDK・トラッキングSDK は一切搭載していません
- ユーザーデータをAIモデルの学習に二次利用しません
🛡 バックエンド設計で気をつけたこと
サーバサイドでのプレミアム検証
クライアントのis_premiumフラグは一切信用しません。Firestoreのサブスクリプション状態を2日TTLでキャッシュし、サーバサイドで毎回検証しています。
# backend.py — クライアントを信用しない設計
def get_user_premium_status(uid: str) -> bool:
"""
⚠️ クライアントが送ってくる is_premium は絶対に使わない。
サーバ側で Firestore のサブスク状態を検証する。
"""
cached = _premium_cache.get(uid)
if cached and (now - cached["checked_at"]).days < 2:
return cached["is_premium"]
# Firestore から直接確認
doc = db.collection("users").document(uid).get()
# ...
クォータのトランザクション保証
Firestoreトランザクションでアトミックにインクリメント&チェック。レースコンディションでの無料ユーザーの超過を防止しています。
AI出力のサニタイズ
LLMの出力は信用できないので、sanitize_tasks()で所要時間を1〜30分にクランプ、時刻フォーマット検証、禁止キーワードフィルタ、最大15タスクキャップを適用しています。
⚠️ 自覚している課題・改善したい点
ここから先が特にフィードバックをいただきたい部分です。
アーキテクチャ
| 課題 | 詳細 |
|---|---|
| AppState の God Object 化 | 62KBの巨大ファイル。天気・タスク・アラーム・睡眠の責務が混在 |
| 3言語のバックエンド | Python (Flask) + Node.js (Vercel) + PHP (レンタルサーバ) の3ランタイム。保守コスト高 |
| APIバージョニング未実施 |
/suggest_tasks のような非バージョン付きエンドポイント。破壊的変更時に全旧バージョンが壊れる |
| DIの欠如 |
AppStateがLocationManager等を直接生成しており、ユニットテスト困難 |
バックエンド
| 課題 | 詳細 |
|---|---|
| Vercelのレートリミットが実質無効 |
new Map()はコールドスタートでリセットされ、インスタンス間で共有されない |
| チャットエンドポイントに認証なし |
backend.pyはFirebase Auth必須だが、chat.jsは認証なし。URL発見でGemini APIを無断利用される |
datetime.now()のTZ問題 |
travelエンドポイントで1箇所だけnaive datetimeを使用。サーバTZが想定と異なると誤動作 |
| sleep logのペイロード未ホワイトリスト | リクエスト全体をFirestoreに保存しており、任意フィールドを注入可能 |
iOS
| 課題 | 詳細 |
|---|---|
| NightViewの毎秒再描画 |
Timer.publish(every: 1.0)で時計を駆動。睡眠中のバッテリー消費が気になる |
| プライバシーポリシー/利用規約リンク | アプリ内はまだNotionのURLを参照。Webサイトのページに統一すべき |
| テストカバレッジ |
delete_account、認証失敗パス、クォータ境界値のテストが不足 |
📊 技術スタック一覧
| 区分 | 使用技術 |
|---|---|
| 言語・UI | Swift / SwiftUI / Combine |
| 3D・グラフィックス | SceneKit(背景の手続き生成)、Canvas |
| 機械学習(オンデバイス) | TensorFlow Lite(YAMNet) |
| センサー・位置 | CoreMotion / CoreLocation / MapKit |
| 音声 | AVFoundation / AudioToolbox |
| OS連携 | App Intents / EventKit / UserNotifications |
| 課金 | StoreKit 2 |
| 認証・同期 | Firebase (Auth / Firestore) |
| バックエンドAPI | Flask (Python) / Vercel (Node.js) |
| 外部API | OpenWeather / Google Maps / Gemini |
💬 フィードバックをください!
以下の観点で特にご意見いただけると嬉しいです。もちろんそれ以外も大歓迎です。
-
アーキテクチャ: AppStateの分割方法、iOS 17+
@Observableへの移行判断 - SceneKit: パフォーマンス改善、Metal移行の是非
- オンデバイスML: YAMNet以外の選択肢、Core ML移行のメリデメ
- バックエンド: 3ランタイム統合の現実解、Vercelでの適切なレートリミット実装
- UX: オンボーディングフロー、遅刻警告の改善案
- プライバシー設計: 見落としているリスク
- 課金設計: サブスクの価格設定、フリーミアムの境界線
App Store: https://apps.apple.com/jp/app/id6760934295
GitHub: https://github.com/yuu-0617
公式サイト: https://stelise-app.com
X (Twitter): https://x.com/yuu_tokiwai
Instagram: https://www.instagram.com/stellise_app
コメント・Issue・プルリクエスト、なんでも歓迎です。厳しめのフィードバックほど成長につながると思っているので、遠慮なくお願いします 🙇
この記事が参考になったら「LGTM」していただけると励みになります。


