0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

アプリ開発100本ノック!Day7 通知で有名人の生活を体験するアプリを作ったら、スレッドの闇に飲まれた話

Posted at

こんにちはめっちゃ初心者です

自分vlogとかすきなんですが、同じような人こんなこと思ったことないですか?
「憧れのあの人と同じ24時間を過ごせたら…」
そんな思いつきから、有名人や特定のキャラクターの1日のスケジュールを通知で教えてくれるアプリを開発しました。

大まかな構成

このアプリは、インターネット接続を必要とせず、iPhone単体で動作するシンプルな構成を目指しました。

データ: キャラクターとスケジュールのデータは、Swiftファイル内に直接記述(ハードコード)。

画面:

CharacterSelectionView: 最初に表示されるキャラクター選択画面。

MainView: キャラクター選択後に、その人のスケジュールを表示するメイン画面。

ContentView: 上記2つの画面のどちらを表示するかを決定する「交通整理員」役の画面。

データ永続化: ユーザーが選択したキャラクター情報は@AppStorage (UserDefaults) を使ってデバイスに保存。アプリを再起動しても選択状態を維持します。

苦戦した点

僕が今回苦戦したところを備忘録をかねて記載。

画面が固まる!スレッドの罠 libdispatch.dylibエラー

キャラクターを選択しても画面が遷移せず、白い画面のまま固まる現象が発生。コンソールにはlibdispatch.dylibという、これまた難解なエラーが…。

原因: UI更新をバックグラウンドスレッドから行おうとしていたことでした。

SwiftUIでは、「画面の見た目を変える」という操作は、必ずメインスレッドという特別なレーンで行うルールがあります。しかし、通知の許可を求める処理や、通知を予約する処理は、iOSのシステムによってバックグラウンドスレッド(別の作業レーン)で実行されることがあります。

このルールを破ったため、システムが「危険な操作だ!」と判断し、アプリの動作を停止させられたみたいっす。

解決策:
モダンなSwift Concurrency(並行処理)を使い、処理のスレッドを明確に制御しました。

UIイベントの処理は.onTapGestureの中からTaskブロックで開始。

時間のかかる可能性のある関数はasync/awaitを使って非同期化。

特に、古いコールバック形式のAPI center.add(request) { ... } を、モダンな try await center.add(request) に書き換えたことが決定打となりました。

private func scheduleNotifications(for character: Character) async {
    // ...
    for item in character.schedule {
        // ...
        do {
            // モダンなasync/await構文で、スレッドを安全に扱える
            try await center.add(request)
        } catch {
            // ...
        }
    }
}

今後の改良点

MVPは完成しましたが、やりたいことはまだまだたくさんあります。

バックエンド連携: キャラクターデータをSupabaseやFirebaseで管理し、アプリを更新せずに新しいキャラクターを追加できるようにする。

達成度トラッキング: 通知されたタスクをユーザーが「完了」したか記録し、達成率などを表示する。

ウィジェット対応: 今日の次のスケジュールをホーム画面のウィジェットに表示する。

UI/UXの向上: アニメーションやカスタムフォントを追加して、より魅力的なデザインにする。

最後に:全てのコード

以下に、今回作成したアプリの全コードを掲載します。

import Foundation

struct ScheduleItem: Identifiable, Codable {
    var id: String = UUID().uuidString
    var time: String
    var action: String
}

struct Character: Identifiable, Codable {
    var id: String = UUID().uuidString
    var name: String
    var description: String
    var imageName: String
    var schedule: [ScheduleItem]
}
IGNORE_WHEN_COPYING_START
content_copy
download
Use code with caution.
Swift
IGNORE_WHEN_COPYING_END
CharacterData.swift
Generated swift
import Foundation

// imageNameに対応する画像をAssets.xcassetsに追加してください
let characters: [Character] = [
    Character(
        name: "ストイック医学部生",
        description: "1日の勉強時間は12時間。合格への道を共に歩もう。",
        imageName: "medic_student_icon",
        schedule: [
            ScheduleItem(time: "06:00", action: "起床・英単語30分"),
            ScheduleItem(time: "08:00", action: "朝食・数学の演習"),
            ScheduleItem(time: "12:00", action: "昼食・物理の復習"),
            ScheduleItem(time: "13:00", action: "午後の勉強開始(化学)"),
            ScheduleItem(time: "18:00", action: "夕食・30分休憩"),
            ScheduleItem(time: "21:00", action: "今日の総復習"),
            ScheduleItem(time: "23:00", action: "就寝")
        ]
    ),
    Character(
        name: "ショートスリーパーMr.H",
        description: "1日が何時間か知っているかい?",
        imageName: "sleeper",
        schedule: [
            ScheduleItem(time: "03:00", action: "起床・瞑想"),
            ScheduleItem(time: "05:00", action: "重要タスク(コーディング)"),
            ScheduleItem(time: "09:00", action: "チームミーティング"),
            ScheduleItem(time: "12:00", action: "昼食・30分の仮眠"),
            ScheduleItem(time: "18:00", action: "ジムでトレーニング"),
            ScheduleItem(time: "23:00", action: "読書・翌日の計画"),
            ScheduleItem(time: "00:00", action: "就寝")
        ]
    )
]
import SwiftUI

@main
struct YourAppNameApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
IGNORE_WHEN_COPYING_START
content_copy
download
Use code with caution.
Swift
IGNORE_WHEN_COPYING_END
ContentView.swift
Generated swift
import SwiftUI

struct ContentView: View {
    @AppStorage("selectedCharacterData") private var selectedCharacterData: Data?

    var body: some View {
        if selectedCharacterData != nil {
            MainView()
        } else {
            CharacterSelectionView()
        }
    }
}
import SwiftUI
import UserNotifications

@MainActor
struct CharacterSelectionView: View {
    @AppStorage("selectedCharacterData") private var selectedCharacterData: Data?
    
    let characterList = characters

    var body: some View {
        NavigationView {
            List {
                ForEach(characterList) { character in
                    HStack(spacing: 15) {
                        Image(character.imageName)
                            .resizable()
                            .scaledToFit()
                            .frame(width: 60, height: 60)
                            .clipShape(Circle())
                        
                        VStack(alignment: .leading) {
                            Text(character.name)
                                .font(.headline)
                            Text(character.description)
                                .font(.subheadline)
                                .foregroundColor(.gray)
                                .lineLimit(2)
                        }
                    }
                    .padding(.vertical, 5)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        Task {
                            await selectCharacter(character)
                        }
                    }
                }
            }
            .navigationTitle("キャラクターを選択")
        }
        .task {
            await requestNotificationPermission()
        }
    }
    
    private func selectCharacter(_ character: Character) async {
        let encodedData: Data?
        do {
            let encoder = JSONEncoder()
            encodedData = try encoder.encode(character)
        } catch {
            print("❌ キャラクターデータのエンコードに失敗しました: \(error)")
            return
        }
        
        await scheduleNotifications(for: character)
        
        self.selectedCharacterData = encodedData
    }
    
    private func requestNotificationPermission() async {
        do {
            let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])
            if granted {
                print("✅ 通知が許可されました。")
            } else {
                print("⚠️ 通知が拒否されました。")
            }
        } catch {
            print("❌ 通知の許可でエラーが発生しました: \(error.localizedDescription)")
        }
    }

    private func scheduleNotifications(for character: Character) async {
        let center = UNUserNotificationCenter.current()
        
        center.removeAllPendingNotificationRequests()
        
        for item in character.schedule {
            let content = UNMutableNotificationContent()
            content.title = "次はこれ! (\(character.name))"
            content.body = "\(item.time) - \(item.action)"
            content.sound = .default
            
            let timeParts = item.time.split(separator: ":").compactMap { Int($0) }
            guard timeParts.count == 2 else {
                print("不正な時刻フォーマットです: \(item.time)")
                continue
            }
            
            var dateComponents = DateComponents()
            dateComponents.hour = timeParts[0]
            dateComponents.minute = timeParts[1]
            
            let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
            let request = UNNotificationRequest(identifier: item.id, content: content, trigger: trigger)
            
            do {
                try await center.add(request)
            } catch {
                print("❌ 通知の予約に失敗しました: \(item.action) - \(error.localizedDescription)")
            }
        }
        
        print("✅ \(character.schedule.count)件の通知を予約しました。")
    }
}

import SwiftUI
import UserNotifications

@MainActor
struct MainView: View {
    @AppStorage("selectedCharacterData") private var selectedCharacterData: Data?
    @State private var selectedCharacter: Character?

    var body: some View {
        VStack(spacing: 20) {
            if let character = selectedCharacter {
                Image(character.imageName)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 120, height: 120)
                    .clipShape(Circle())
                    .overlay(Circle().stroke(Color.gray.opacity(0.5), lineWidth: 2))
                    .shadow(radius: 5)
                    .padding(.top, 30)
                
                Text("現在、\(character.name)\nの生活を体験中です。")
                    .font(.headline)
                    .multilineTextAlignment(.center)
                    .padding(.horizontal)
                
                List {
                    Section(header: Text("今日のスケジュール")) {
                        ForEach(character.schedule) { item in
                            HStack {
                                Text(item.time)
                                    .fontWeight(.bold)
                                    .frame(width: 60)
                                Text(item.action)
                            }
                        }
                    }
                }
                
                Spacer()
                
                Button(action: {
                    resetCharacterSelection()
                }) {
                    Label("キャラクターを選び直す", systemImage: "arrow.triangle.2.circlepath")
                        .font(.headline)
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(Color.red)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                }
                .padding()

            } else {
                Text("キャラクター情報を読み込み中...")
            }
        }
        .onAppear(perform: decodeCharacter)
    }
    
    private func decodeCharacter() {
        guard let data = selectedCharacterData else { return }
        
        do {
            let decoder = JSONDecoder()
            selectedCharacter = try decoder.decode(Character.self, from: data)
        } catch {
            print("❌ キャラクターデータのデコードに失敗しました: \(error)")
            selectedCharacterData = nil
        }
    }
    
    private func resetCharacterSelection() {
        UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
        print("スケジュールされた通知をすべてキャンセルしました。")
        
        selectedCharacterData = nil
    }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?