ボクココ

個人開発に関するテックブログ

SwiftUI4での@Published警告に対応した話

ども、@kimihom です。

今年ももう終わりですね。

去年、ボクココで書いた記事数は39記事でした。そして、今年も39時目となるこの記事を公開します。サンキュー!

大量の警告通知

今年、SwiftUI4がリリースされ、SwiftUI3で書いていたコードから大量の警告が届きました。 Xcodeでアプリを繋ぎ、アプリを色々とタップすると、以下の文言が大量に出力されるようになりました。

Publishing changes from within view updates is not allowed, this will cause undefined behavior.

上記の文言で Google 検索すると、英語でさまざまな対応方法が記されています。私が調べた結果、YouTubeの配信が最もわかりやすく解説してましたので、紹介します。英語ではありますが、ソースコードを読むだけでも理解が進みました。

コード改善

まず、上記ビデオを見ると、最終的には sync 関数を自前定義しています。Boolのみの定義であるため、これを他の型でも sync できるように改善しました。

extension View {
    func sync<T: Equatable>(_ published: Binding<T>, with binding: Binding<T>) -> some View {
        self
            .onChange(of: published.wrappedValue) { published in
                binding.wrappedValue = published
            }
            .onChange(of: binding.wrappedValue) { binding in
                published.wrappedValue = binding
            }
    }
}

この実装により、Bool, Int, String など、それぞれの定義が可能になります。

final class MyViewModel: NSObject, ObservableObject {
  @Published var email = ""
}

# Before
struct EmailView: View {
    @EnvironmentObject var myViewModel : MyViewModel

    var body: some View {
        VStack {
            TextField("メールアドレス", text: $myViewModel.email)
        }
    }
}


# After
struct EmailView: View {
    @EnvironmentObject var myViewModel : MyViewModel
    @Binding var email: String

    var body: some View {
        VStack {
            TextField("メールアドレス", text: $email)
        }
        .sync($myViewModel.email, with: $email)
    }
}

Published での変数を直接 text指定すると動作はするけど非推奨になったようです。

正直な話、今までの SwiftUI での開発は、「なんでもPublishedで定義して、それをSwiftUIに埋め込めればいいのか」と考えてました。今回のソースコードの場合、「Before の $myViewModel.email)」です。そのため、わざわざ @State@Binding を使う必要のある時は、1つのViewだけで使うときに限られていると考えてました。

SwiftUI4 からは、以下の2つを意識しようと考えてます。

  • @Published で作った変数を、$ で SwiftUI 側に適用することはしないように気をつける
  • Button などで @Published の変数を変える場合、View 側で @Binding 側で変更させる

完璧な実装ができたとは言いづらい状態ですが、この2つを意識すればSwiftUIの警告は消えることを確認済みです。

他のケースで同様の警告が出た場合

別のケースで、以下が発生するケースがありました。

Publishing changes from within view updates is not allowed, this will cause undefined behavior.

理由は、メインスレッドでないところからSwiftUIのデータを更新しようとしたためでした。

この場合は DispatchQueue.main.sync を呼ぶことで対応できました。

func checkStartCall(callback: @escaping () -> Void) {
    requestRecordPermission { granted in // 別プロセスで呼ばれる
        DispatchQueue.main.sync {
            callback()
        }
    }
}

終わりに

SwiftUI 自体、今後もどんどんと改善が続きそうです。それについていくだけで大変なものです。しかし、頑張った先に利用者さんがいることを考えて頑張っていければと思います。

日本語での解説がほとんどなかったので、本記事が参考になれば幸いです。

基礎から学ぶ SwiftUI

基礎から学ぶ SwiftUI

  • 作者:林晃
  • シーアンドアール研究所
Amazon

Swift での HTTPS での音声再生をさせる方法

ども、@kimihom です。

SwiftUI でアプリ開発をしていると、その情報はもう古かったりして正しく動作しないことが多々あるので、執筆した現時点で動作を確認できたコードとともに解説していこうと思う。

やりたいこと

  • API で取ってきた 音声URLを再生させる
  • 再生した録音を停止ボタンで停止、再開ボタンで再開させる
  • 音声の再生速度を早めたり遅めたりする
  • 15秒先に進んだり、15秒前に戻れたりする

Swift でのAudio実装を調べていると、AVPlayer を使う方法と AVAudioPlayer を使う方法がある。ぱっと見 AVAudioPlayer を使うのが正解に見えそうだが、どうやら AVAudioPlayer は Web経由でのHTTPでの再生が実装できない?ようで、AVPlayer を使うのが正解だった。

Apple Developer Documentation

iOS 公式ドキュメントでは標準の定義しか書かれておらず、実際に実装するには、Web上にあるいろいろな事例を見ながら組み合わせていく流れになるだろう・・。

実装

var audioPlayer: AVPlayer?

func startPlay() {
    let url = URL.init(string: "https://www.hello.com/sample.wav")
    audioPlayer = AVPlayer.init(playerItem: AVPlayerItem(url: url))

    // 再生が終わった際のイベント定義
    NotificationCenter.default
        .addObserver(self, selector: #selector(playerDidFinishPlaying),
                     name: .AVPlayerItemDidPlayToEndTime,
                     object: audioPlayer?.currentItem)

    // 秒数の表示
    Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { timer in
        if let audioPlayer = self.audioPlayer,
           let currentItem = audioPlayer.currentItem,
           currentItem.status == .readyToPlay {
            let timeElapsed = CMTimeGetSeconds(audioPlayer.currentTime()) // 現在の再生時間の取得
            let timeDuration = currentItem.duration.seconds
            // 再生中のUI処理 秒数取得されるので / 60 で分数、% 60 で秒数
        }
    }

    audioPlayer?.play()
}

@objc func playerDidFinishPlaying(note: NSNotification) {
    // 再生が終わった際のイベント処理
}

func stop() {
    audioPlayer?.pause()
    // audioPlayer?.play() で再開
}

// 音声再生スピードの変更
func changeSpeed(speed: Float) {
    audioPlayer?.rate = speed
}

// 音声再生位置の移動
func changeLocation(seconds: Double) {
    guard let audioPlayer = self.audioPlayer else { return }

    let timeScale = CMTimeScale(NSEC_PER_SEC)
    let rhs = CMTime(seconds: seconds, preferredTimescale: timeScale)
    let time = CMTimeAdd(audioPlayer.currentTime(), rhs)
    audioPlayer.seek(to: time)
}

SwiftUI の部分を記載していないけども、ひとまずSwift側だけ記しておいた。単に音を流すだけであれば、AVPlayer を初期化して play するだけでシンプルに実装できる。

より細かく音声再生の制御をすることも AVPlayer ではサポートしており、今回はより音声再生を便利に使える他の機能の実装例も記した。

Timer.scheduledTimer を使って今の再生秒数を取得したり、全体の再生時間を取得できたりする。

audioPlayer.rate の数字を 0.25 ~ 2.0 くらいの間で操作すると、音声再生のスピードを変えることができる。

audioPlayer.seek で特定の秒数に遷移させることができる。

再生が終わった後の定義で謎に objc なコードが出てきてしまうのは、今後 iOS バージョンアップできっとなくなっていくことだろう。

終わりに

Swift5 での現状はこれでうまく動くということで記事としておいた。

objc 周りのコードを無くしたかったり、最終的にゲージの表示などもできるようにしたいけど、ひとまず音声再生プレイヤーとしてまともに動く動作にすることができた。

そうして私は開発を続けていくのである。

SwiftUIでのプレビュー機能を使えるようにしよう

ども、@kimihom です。

iOSアプリ開発でSwiftUIを使っていると、XcodeのCanvas機能を利用することができる。これを使うとわざわざエミュレーターやiPhoneで見ずとも、UIの調整が可能だ。

最初のセットアップさえうまくできれば、かなり便利に利用できるので、SwiftUIを使っているなら必ずマスターしたいものである。

プレビューの表示

私が一番ハマったのが、「ViewModel側で色々と変数を定義しているけど、その変数をどうやってプレビューに渡すのか」という点だった。例えば以下のようなコードがあったとしよう。

// MyView.swift
struct MyView: View {
    @EnvironmentObject var myViewModel : MyViewModel

    var body: some View {
        ZStack {
            if let name = myViewModel.name {
                Text("こんにちは\(name)さん")
            } else {
                Text("初めまして ゲストさん")
            }
        }
    }
}

struct MyView_Previews: PreviewProvider {
    static var previews: some View {
        MyView().environmentObject(MyViewModel())
    }
}
// MyViewModel.swift
final class MyViewModel: NSObject, ObservableObject {
    @Published var name? // WebAPIから取ってきたユーザー名
}

このコードでは MyViewModel に name が保存されていれば、その名前を表示しようとしている。しかし、nameはデフォルトで何も名前が定義されていないので、プレビューでは必ず "初めましてゲストさん" が表示されてしまう。 このサンプルの文言くらいだったらいいのだけど、ログインしている/していないでUIが大きく変わるような場合、これだとプレビューが全く使い物にならない。

ではどうすればいいのか。気づけば簡単なことだったが、 "プレビューを見たい時だけ init() にオブジェクトを入れておく" が答えだ。

final class MyViewModel: NSObject, ObservableObject {
    @Published var name? // WebAPIから取ってきたユーザー名

    override init() {
        super.init()

        // 初期化コード
        initSomething()

        // MyView テスト時
        self.name = "太郎"
    }
}

これで、WebAPIから仮に取ってきたとされる名前を事前に入れておくことで、UIの確認が無事にできるようになる。 この解決方法さえ理解できれば、SwiftUIでのプレビュー表示の便利さにびっくりすることだろう。

もちろん、実際にアプリを動かす時には、このコード(self.name = "太郎") はコメントアウトするのを忘れずに。

より便利に使う

iPhoneアプリだけでなく、iPad側のUIや、ダークモードの確認なども簡単にできるので、もはや必須と言ってもいいだろう。

これら細かい設定は、SwiftUIプレビュー画面のメニューで操作可能だ。

MyView().environmentObject(MyViewModel())
                .preferredColorScheme(.dark)
                .previewDevice("iPad mini (6th generation)")

終わりに

SwiftUIに慣れると、楽しくiOSアプリを作ることができる!

iOSアプリのエンジニアはぜひマスターしたいところである。

Swift アプリでどんなケースでもログを出す方法

ども、@kimihom です。

iOSアプリの開発をしていると、以下のようなケースでログが見れないことに不便さを感じることがある。

  • バックグラウンドで動作している時のログを見たい
  • iOS の電源を再起動した後のログを見たい
  • その他 アプリを開いた状態 以外でのログを見たい

これらのケースでログを見ることはできないと諦めていたのだけど、調べると方法があったのでまとめておく。

OSLog の利用

シンプルに1つのクラスを作るのが良さそうだ。

import os

class MyLog {
    static let nsLog = OSLog(subsystem: "jp.co.myapp", category: "ログ")

    static func p(_ log: String) {
        os_log("%@", log: nsLog, type: .default, log)
    }
}


// 実際の利用
MyLog.p("sample")

まず、このログが Xcode でのアプリ実行で出力される通常のログを確認しよう。

MyApp[29609:10452863] [ログ] sample

というようなログが出ていればOKだ。

他の方法でログを見る

Xcode 13 でのログ確認の方法

Xcodeメニュー > Window > Devises and Simulators > iPhoneを選択 > Open Console

コンソールアプリを起動する方法

Mac メニューより "コンソール" を起動 > デバイスを iPhone に指定


その後、検索に "ログ" として 開始 を押す。

すると、このコンソールでのログに、出力されるようになる。これによって、どんな状態でもログを確認することができるようになった。

"ログ" と日本語のカタカナで指定した理由はここで、"log" とかで指定すると大量のiPhoneのログが出てきてしまうことになる。

終わりに

アプリのログだけだとどうしても見れなくて困ったケースがあり、同じように悩んだ方は是非試してみていただければと思う。

引き続きiOSアプリ開発を続けていこう。

iOS CallKit と Twilio Voice iOS 徹底攻略

ども、@kimihom です。

先日の TwilioJP-UG Online Vol.6 はモバイル特集。私からは iOS CallKit と Twilio Voice iOS 徹底攻略として話をしたので、こちらの内容をブログに記しておこう。

なお、今回のスライドはかなりコアな iOS CallKit 内容となっており、実際に CallKit x Twilio Voice iOS を開発した人をターゲットとしている。

資料

speakerdeck.com

CallKit の役割

CallKit は iPhone/iPad における通話(電話/ビデオ)のやりとりを管理するプラットフォームと言える。CallKit を用いた実装をすると、他のCallKitアプリとうまく連携して、ユーザー体験をより良くさせることが可能だ。例えば LINE 通話中に自分のiOSアプリから着信が来た時、その着信に出るか、今のLINE通話を続けるかの選択などが可能になる。

そして CallKit UI により、UIの実装はiOS側で共通のものが使えるようになる。ユーザーはスリープ状態でキーロックをかけていても、着信が来た時に着信を鳴らすことが可能になる。iOS側でキーロックをかけていても音声をミュートする、音量を上げる、キーパッドを入力するなどの動作を動かすことができる。CallKit UI で通話中の右下のエリアには、呼び出し元のアプリボタンが出てきているので、それをタップしてパスコードを入力すると、自前アプリを起動させることができる。

Twilio Voice SDK

Twilio Voice SDK の構成についてスライドに掲示している。

自前アプリの中に Twilio Voice SDK を埋め込み、そのTwilio Voice SDK には実際に通話した時のオブジェクト(TVOCall)と着信が来た時(TVOCallInvite)、着信にキャンセルした時(TVOCanceledCall)Inviteのオブジェクトがそれぞれ作られる。Delegate が大事で、着信が来た時(TVONotificationDelegate)と通話している時(TVOCallDelegate)でのイベントをそれぞれ適切に処理するコードを書く必要がある。

Delegate に関して Twilio Voice の実装だけではなく、先ほど表示した CallKit UI での各種イベント(CXProviderDelegate)と、実際に着信が来た時(PKPishRegistryDelegate)の実装が必要だ。

これらのイベントで適切にUI処理をさせることが、CallKitアプリを作る上で一番のメインポイントとなる。

実装してわかりづらかった部分

サンプルアプリを読み解いていく中で、最初見た時よくわからない部分を紹介した。

この if分にて最初のケースは着信がきて、その着信に対してアプリ側で切断 or 発信元が切断したときのイベントとして理解しやすい。この2つ目がいつ呼ばれるのかというと、通話中に着信が来て、CallKit UI にて切断して通話を選んだ時に呼ばれる。つまり1台のスマホだけでは見つけることのできないものとなる。この時の CallKit UI は以下。この3つボタンが出てくるCallKit イレギュラーパターンも、CallKit では考えられている。


サンプルアプリを見ていると、"Voice Bot" という名前の指定がある。これが最初何のためにあるのだろう?と考えることになる。実はこの名前、iPhone標準の電話アプリの通話履歴に勝手に書き出される項目となる。


CallKit の設定項目の中に supportedHandleType があり、その設定の選択肢として "generic", "phoneNumber", "emailAddress" の3つがある。通話アプリとなると phoneNumber が一般的だ。phoneNumber を登録して着信時の値をちゃんと電話番号を表示させると、CallKit 側で電話帳を検索して該当するものがあれば、勝手にその表示を出すことをしてくれたり、先ほどのiPhone標準の通話アプリの通話履歴に、正しく通話した形式として保存されるようになる。

それ以外の、自分で着信内容をセットしたい場合には、generic を使うことになる。自分の場合だと、iPhone電話帳にある電話番号ではなく、自前API側のコンタクト情報を着信に出したかったため、generic にする必要があった。


特に注意したいポイントとして、着信が来た時の対応を挙げた。着信の通知が来たら、必ずそれ(reportIncomingCall)をCallKitに通知しなければならない。これをやらないと、クラッシュが発生してしまう。

このクラッシュ、数回アプリで発生させてしまうと、 iOS 側でこのアプリの着信は動作させなくさせる仕組みがある。この状態になると、アプリを一度アンインストールして再インストールしないと動作しなくなってしまう。

だが着信が来た時、以下のようなことをしたくなったりする。

1. 着信が来ても、相手に応じて着信通知させないようにしたい

これは、そもそもTwilioからWebサーバー側へ着信通知が来た時点で判別する必要がある。

2. アプリ側で着信をON/OFF させる設定をしたい

自前アプリ側の設定で、着信OFF にしたら着信をさせないような機能を作りたいケースがある。この時は OFF にした時点でCallKit への登録自体を消す必要がある。PKPushRegistry 自体を null にすることで対応する。

3. 着信が来たら、その電話番号をAPIで相手名を検索させたい

着信が来た時点でHTTP経由で相手の情報を取得して、その後 reportIncomingCall をしたくなる。この非同期にしてもクラッシュが発生してしまう。この場合の最適な対応は、Twilio custom parameter を利用し、その情報から着信相手をセットすることとなる。


アプリがバックグラウンドにあるときに UI操作をするとクラッシュする。

先ほどの CallKit UI での通話状態は、アプリはあくまでバックグラウンドにある状態に過ぎない。この状態で自前アプリの UI を操作してしまうと、その時点でクラッシュが発生してしまう。

今自前アプリがアクティブなのかバックグラウンドなのかは、Scene のイベントを管理する必要がある。

通話中に background 状態で通話開始したら、UI操作させずにデータ処理だけをさせる。もし通話中に アプリがactive になったら、現在のデータ情報をもとに、UI を組み立てるという対応が必要となる。もうこれはクラッシュと電話テストを繰り返して改善していくしかない。


ここまで対応しても、現状の iOS だとどうしてもクラッシュが起こるケースがいくつかある。Apple 側に修正を依頼しているが、今後対応してくれるかどうかは未定の状態である。iOS 16 でどうなるか、先端を追い続けよう。

終わりに

Twilio Voice iOS 自体はある程度 Voice JavaScript を使ったことのある経験があれば問題ないのだけど、CallKit と SwiftUI に関してはかなりクセのある実装が必要となる。

iOS アプリ開発の経験を持っている方が 本記事にトライするよりも、私のように Twilioのことをよく理解した人が CallKit に手を入れた方が、より早く実装できそうに見えた。

「iOS はいいけど、Android は?」

そう慌てるでない。ただしっかりとAndroidアプリに向けて進んでいることだけは伝えておこう。

詳解 Swift 第5版

詳解 Swift 第5版

Amazon

SwiftUI で快適なログインUIを作ろう

ども、@kimihom です。

f:id:cevid_cpp:20220326144057j:plain

SwiftUI を使ってログインをより快適にする対応をしたので、その実施内容についてまとめておこう。

テキストフォーカス

ログインをしようとするページの場合、ほぼ必ず最初はメールアドレスなどの入力から始まることだろう。iOS の場合、フォーカスが当たってない状態でタイプしても反映されない形となる。

最初からフォーカスが当たっているのは、利用ユーザーのことを考えた改善と言える。

さて、このフォーカスだけど SwiftUI でできるようになったのは iOS 15.0 からである。focused) が登場した。

struct SigninView: View {
    enum Field: Hashable {
        case email
        case password
    }

    @State private var email = ""
    @State private var password = ""
    @FocusState private var focusedField: Field?

    var body: some View {
        ZStack {
            TextField("メールアドレス", text: $email)
                .keyboardType(.emailAddress)
                .focused($focusedField, equals: .email)
                .onAppear(perform: {
                    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                        self.focusedField = .email
                    }
                })
        }
    }
}

onAppear でUIが表示がされた際の実行として、focusedField をセットする。ここで 少し時間を置いているのは、現状の iOS だとロードされた後すぐにフォーカスを当てることはできないためである。 アプリを起動した瞬間にログイン画面を表示させる場合に、この対応が必要となる。起動直後はフォーカスが当たらず、0.5秒たった後にキーボードが開くようになる。

その後、パスワード入力もして"ログイン"ボタンを押した後、仮にパスワードミスなどでフォーカスを戻したい時は、

self.focusedField = .email

を指定するだけで、メールアドレスにフォーカスが当たるようになり、キーボードが勝手に出てくるようになる。

パスワード表示

iPhone のような小さな端末だと、今パスワードが正しく入力できているかを確認したい時があるようだ。私としては生でパスワードを見せるユーザー経験はあまりいいとは思わないが、長いパスワードを入力し直すのが面倒という方はパスワード内容を確認しながら入力した方が良いようである。多くのメジャーアプリでも実装されているようだ。

さて、この実装はパスワード入力のフォーマットSecureField に加えて、TextField で必要な時に書き換えることで実現が可能だ。

struct SigninView: View {

    @State private var password = ""
    @State private var secured: Bool = true

    var body: some View {
        ZStack {
            HStack {
                if secured {
                    SecureField("パスワード", text: $password)
                        .focused($focusedField, equals: .password)
                } else {
                    TextField("パスワード", text: $password)
                        .focused($focusedField, equals: .password)
                }
                Button(action: {
                    self.secured.toggle()
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                        self.focusedField = .password
                    }
                }) {
                    if secured {
                        Image(systemName: "eye.slash")
                    } else {
                        Image(systemName: "eye")
                    }
                }
                .frame(width: 50)
        }
    }
}

デフォルトは secured = true の状態なのでSecureField が利用され、パスワード表示ボタンをタップすると、secured = false となってそのタイミングでパスワードがテキストで表示されるようになる。

パスワードの入力を SecureField から TextField に変わったタイミングで、フォーカスが外れてしまう。そのため、改めて password に対してフォーカスが当たるような実装をしている。SecureFieldとTextFieldで同じ password 指定で大丈夫かと思われるかもしれないが、 if~else でちゃんと出し分けているので問題ない。

最終的にログインボタンを押した際には、self.secured = true に戻して表示を隠しておいた方が、ユーザー体験は良さそうである。

終わりに

最初慣れるまでは違和感の多い SwiftUI。慣れてくるとその便利さに気づくことができよう。

私の実装の場合、よりセキュアに2段階認証の実装も加わっている。さらに、新しい実装方法として顔認証や指紋認証での実装も増えてきている。今後はよりセキュアにログインできるように、これらの実装も検討していきたい。

Twilio Meetup Vol.4 の LT 補足

ども、@kimihom です。

先日の TwilioJP-UG Online Vol.4 のイベントで、久々に話をさせてもらったので、その内容と所感について記す。

SIGNAL での Twilio Live

私は早めに切り上げさせてもらったのだが、序盤の話の中では 今回の SIGNAL で新しく出てきた内容のシェアがされた。Twilio Live は かつて wellcast がある時に欲しかったサービスだった。

www.twilio.com

www.bokukoko.info

"多人数への配信" というのは今までも多くの Web サービスがあるのだけど、この領域はまだまだ未来がある。単に自分のビデオを配信するだけではなく、画像を加工したり音声だけの配信をしたり、特定ニーズでのビデオ配信などに応用ができる。今 Twilio で最も熱いサービスである(と思う)ので、今のうちから 何ができるのか考えておくと良さそうだ。

はてさて、Zoom SDK とどう違ってどちらが適切なのか?是非調べていただければと思う。

登壇資料

speakerdeck.com

所感

今年は、iOS アプリ開発の日々であった。Swift の基本から学び、SwiftUI、そして Twilio Voice for iOS での実装まで、全てをやり切ることができた。

CallConnect

CallConnect

  • selfree LLC
  • ビジネス
  • 無料
apps.apple.com

現状でできることは以下の機能となる。

  • アカウントでのログイン. ログアウト
  • 外線の発信・着信
  • 発信時の通知する電話番号の選択
  • 内線の発信・着信
  • キューイング着信への応答
  • 通話中の保留とその再開
  • 着信時に相手の名前、会社名の表示
  • 通話後の後処理メモ保存
  • 通話後に外部サービスへの通知

1st リリースでは機能を抑えようと考え抜いた結果、上記の機能に絞ることにした。そうだ、これは最初のリリースがに過ぎない。今後も iOS の技術を追い続け、さらなる改善を続けていこう。

この中でも特に苦戦したのは、CallKit を使った通話周りの実装だ。Twilio Voice iOS にサンプルアプリがあるのだけど、そのアプリは画面遷移のない最もシンプルな構成であるため、その先にある CallKit の本当の難しさに直面することがない。

特に通話のステータス管理が重要で、着信がきた時に正しく CallKit へ着信が受けられる状態を報告する必要がある。その後通話が始まったら CallKit へ報告するし、当然ながら通話が終わった後にも報告する必要がある。このタイミングがズレたり、報告し終わってないのに着信がきたら〜・・といった状態になるとすぐに Crash! するのである。

ここまで ステータスに厳しいのは、CallKit の iOS 間での共有にある。この CallKit はあらゆる電話のアプリ (電話, LINE など) で共有で使われており、例えば LINE で通話中の時に 自前の iOS アプリで着信が来ると、今の LINE 通話を切断して 自前 iOS アプリに出る といった選択ができるようになる。

これらは iOS 側での共通 UI となっており、これこそが CallKit となるのである。CallKit を使えば、通話アプリとして iOS 内で "共有" できる便利さはありつつ、CallKit のルールに必ず従わなければならない厳しさがある。

終わりに

今回の Twilio Meetup は忘年会も兼ねてということで、今年やったことを報告させてもらった。

私は引き続き Voice 電話 を極めていく次第である。今月中には 資料に記した 録音セキュリティ 周りに関する記事を頑張って書くとしよう。

iOS の Launch Screen の伸び対応

ども、@kimihom です。

f:id:cevid_cpp:20211009154214j:plain

iOS 開発をしてて、Launch Screen の対応が案外うまくいかなかったので、同じ思いをする人が減ることを祈りつつ記事として残す。

問題

2021 年時点で iOS アプリの起動時のスクリーン(Launch Screen) は、iOS プロジェクト内にある Info.plist にある、 Launch Screen を設定することで対応が可能である。

f:id:cevid_cpp:20211009152741p:plain

上記では、バックグラウンドの色と、その上に画像を乗っける形で、アプリ起動時にはそれを表示させるように指定している。

しかし!実際に iPhone で起動してみると、以下のように画像が伸びてしまう。

f:id:cevid_cpp:20211009152847p:plain

上記と全く同じ報告が、Stackoverflow でも投稿されている。この 問題に対する Best Answer はまだ存在していない。

対応

調べると、Launch Screen を Storyboards を使って対応した記事を発見した。

Guest Post: The Fix to stretched launch Images in SwiftUI » Daniel Bernal

私が探していた解決方法は、まさにこれだった。Info.plist にある Launch Screen を設定するのではなく、Storyboards で作成した Launch screen interface file base name を指定することで、無事解決できた。

Storyboards を全く学ばずに 最初から SwiftUI で全て作ってきたのだけど、まさか最後の最後で Storyboards を使うことになるとは・・。

Storyboards の基本がなかったため、以下のページを参考に、アプリの中心に画像を表示させることができた。

Swift3 Storyboardで画面の中心にViewを配置する - Qiita

ここで Storyboard をいじったわけだけど、やはり SwiftUI の方が明らかにわかりやすい(今までずっとやってきたってのもある)。そのうちこの起動画像の問題が治った Swift or Xcode のバージョンが出てくれば、すぐにでも Storyboard のファイルは削除するとしよう。

終わりに

地味に半日くらいは苦戦された、iOS アプリの Launch Screen 設定。

起動した際にホンの一瞬しか表示されないものだけど、基本的にこの Launch Screen はセットしておくべきというのが Apple の回答のようである。

アプリ公開に向けたラストスパート、今月も頑張っていこう!

iOS CallKit と Twilio Voice iOS 実装

ども、@kimihom です。

f:id:cevid_cpp:20210912142640j:plain

ここ半年くらいずっとやってきた CallKit を使った開発について、1つ記事にしてみよう。

参考

この4つが圧倒的に詳しく書かれているので、ほとんどは上記を読むだけで済むんだけど、少し追加した情報を紹介してみる。

CallKit の魅力

ブラウザでの WebRTC 実装が普通となりつつある今、なぜネイティブの CallKit で電話の発着信を実装すべきなのか。 これは、ズバリ "スマホをスリープさせても、着信が来たら通知が届く" という当たり前であるけど、実装が一番難しいことが 公式で提供されているからだ。スマート "フォン" として一般的な実装を iPhone 内でしたい場合には、現状 CallKit の利用が必須になっている。

仮に発信程度だけの用途で iPhone 利用する場合には、Twilio Voice JavaScript SDK を利用した方がいい。それなら PC・スマホどちらでも利用できるし、iOS CallKit のような複雑な設定や実装がいらなくなる。去年、Twilio Voice JavaScript SDK で Safari でまともに使えるようなアップデートがされている。

CXProvider

Call の管理をする CXProvider の設定で、CXProviderConfiguration を設定することで、着信時の表示を変えることができる。

    var audioDevice = DefaultAudioDevice()
    var voipRegistry: PKPushRegistry?

    func initClient() {
        let configuration = CXProviderConfiguration()
        configuration.maximumCallGroups = 1
        configuration.maximumCallsPerCallGroup = 1
        configuration.supportedHandleTypes = [.generic]
        configuration.supportsVideo = false
        configuration.iconTemplateImageData = UIImage(named:"IncomingIcon")?.pngData()
        configuration.ringtoneSound = "Ringtone.wav"
        
        callKitProvider = CXProvider(configuration: configuration)
        callKitProvider?.setDelegate(self, queue: nil)

        TwilioVoiceSDK.audioDevice = audioDevice
        
        self.voipRegistry = PKPushRegistry.init(queue: DispatchQueue.main)
        self.voipRegistry?.delegate = self
        self.voipRegistry?.desiredPushTypes = Set([PKPushType.voIP])
    }

ここで、執筆時点で最新の ドキュメントでは、CXProviderConfiguration(localizedName: "Hello") のような設定が Deprecated になっている。じゃあ着信の時の名前表示はどこでするのかというと、iOS アプリ自体の TARGETS > Identity > Display Name がその表示となる。現状のドキュメントではこのことが一切書かれておらず、Stackoverflow で回答をゲットできた。

上記で 2つの delegate を実装する必要があることがおわかりだろう。CXProviderDelegate と、 PKPushRegistryDelegate だ。しっかりとドキュメントを読んでその通りに実装する必要がある。

他にどんなこと書こうかを考えたけど、一般的な内容は上記リンク先に全て書いてあるし、それ以外は独自実装の話になってくるので一般的な記事にもしづらかったm

保留やキューイング処理実装

「通話を保留して、他の方のスマホや PC で取り次ぎたい」これは PC であっても スマホであっても確実に電話で欲しくなる機能だ。

これを実装するには、Twilio Voice iOS SDK だけでは難しい。私の場合、Twilio が提供している Twilio Sync iOS を使うことで、現在 保留にいる相手情報やコール詳細の共有を実現している。基本的な考え方は JavaScript や REST API をいじってたので、それを iOS で動くように翻訳することで、実装することができた。Twilio Sync 周りは Web でかなり経験を積んできての iPhone アプリ実装だったので問題にはならなかった。

ここで言えるのは、PC で実現できることはスマホでも実現できるよ。それだけだ。

終わりに

CallKit と Twilio Voice を組み合わせることで、電話回線との契約が不要で、誰もがアプリをインストールするだけで iPhone 上で電話ができる。一般的な携帯電話会社との契約(SIM)が不要で、しかもどの iPhone でも情報を共有できる。

リモートワークが一般になった今、そんな共有アプリが必要とされているだろう。ビジネス利用での共有電話(コールセンター) iPhone アプリを楽しみにしておいてほしい。

SwiftUI と Combine による REST API 通信

ども、@kimihom です。

f:id:cevid_cpp:20161031193103j:plain

SwiftUI を使って REST API 通信をしてアプリを作っていく上で、一通りまとまってる方法がなかなか見つからなかったので、現在の実装をまとめておく。

Combine を使った定義

Combine はあくまで 非同期イベントをハンドリングするフレームワーク であるだけなので、HTTP リクエストの詳細までは実装されていない。以下に3つの役割がある。

  • Publisher: 値を送信
  • Subscriber: 値を受信。値の型が一致している必要がある。
  • Operator: Publisher と Subscriber の間に入り、値を変更する処理をする。

以下、実際にコードを掲示するが、記事としてだいぶシンプルに書いていることだけ、ご了承いただければ幸いだ。

Publisher

import Foundation
import Combine

protocol APIServiceType {
    func request<Request>(with request: Request) -> AnyPublisher<Request.Response, APIServiceError> where Request: APIRequestType
}

final class APIService: APIServiceType {

    private let baseURLString: String
    init(baseURLString: String = "https://api.my-awesome-app.com") {
        self.baseURLString = baseURLString
    }

   func request<Request>(with request: Request) -> AnyPublisher<Request.Response, APIServiceError> where Request: APIRequestType {

    guard let pathURL = URL(string: request.path, relativeTo: URL(string: baseURLString)) else {
        return Fail(error: APIServiceError.invalidURL).eraseToAnyPublisher()
    }

    var urlComponents = URLComponents(url: pathURL, resolvingAgainstBaseURL: true)!
    urlComponents.queryItems = request.queryItems
    
    var request = URLRequest(url: urlComponents.url!)
    request.httpMethod = "POST"
    request.addValue("application/json", forHTTPHeaderField: "Content-Type")
    
    if let token = /* 取得した api token*/ {
        request.addValue(token, forHTTPHeaderField: "X-Mobile-Token")
    }

    let decorder = JSONDecoder()
    decorder.keyDecodingStrategy = .convertFromSnakeCase
    return URLSession.shared.dataTaskPublisher(for: request)
        .map { data, urlResponse in data }
        .mapError { _ in APIServiceError.responseError }
        .decode(type: Request.Response.self, decoder: decorder)
        .mapError(APIServiceError.parseError)
        .receive(on: RunLoop.main)
        .eraseToAnyPublisher()
    }
}

enum APIServiceError: Error {
    case invalidURL
    case responseError
    case parseError(Error)
}

ここで AnyPublisher<Request.Response, APIServiceError> が Combine での Publisher 役となる。うまくいけば リクエストで定義した レスポンスを返し、失敗したら APIServiceError を返す。

Subscriber

JSON が返ってきたときに Swift でのオブジェクトとマッチすれば、その値がセットされた状態で返ってくる。以下の SigninResponse における statusmessage がそれにあたる。

protocol APIRequestType {
    associatedtype Response: Decodable

    var path: String { get }
    var queryItems: [URLQueryItem]? { get }
}

struct SigninRequest: APIRequestType {
    typealias Response = SigninResponse

    var path: String { return "/api/signin" }
    var queryItems: [URLQueryItem]? {
        return [
            .init(name: "email", value: email),
            .init(name: "password", value: password)
        ]
    }

    public let email: String
    public let password: String

    init(email: String, password: String) {
        self.email = email
        self.password = password
    }
}

struct SigninResponse: Decodable {
    let status: String
    let message: String?
}

ここでは API のリクエストで https://api.my-awesome-app.com/api/signin を POST で email, password を送って、その返ってきた JSON で {"status": "OK"}{"status": "NG", message: "Invalid password"} などを返す API がある想定だ。Decodable で定義したのと一致しないと、エラーとなる。? で定義しておけば、Swift 側で あるかないか、任意として定義できる。この時は Swift コード側でオプショナル型として入ってくる。

実装

これでようやくフロント側へ行ける。

import Combine

final class MyViewModel: NSObject, ObservableObject {
    @Published var isLoggedin = false

    private let apiService = APIService()
    private let errorSubject = PassthroughSubject<APIServiceError, Never>()
    private let onSigninSubject = PassthroughSubject<SigninRequest, Never>()
    private var cancellables: [AnyCancellable] = []

    override init() {
        super.init()
        bind()

        self.isLoggedin = isLogin() // 既にログイン中かどうか確認
    }

    private func bind() {
        cancellables += [
            onSigninSubject
                .flatMap { [apiService] (request) in
                    apiService.request(with: SigninRequest(email: request.email, password: request.password))
                        .catch { [weak self] error -> Empty<SigninResponse, Never> in
                            self?.errorSubject.send(error)
                            return .init()
                        }
                }
                .sink(receiveValue: { [weak self] (response) in
                    guard let self = self else { return }
                    if (response.status == "ok") {
                        self.isLoggedin = true // ログイン後の View 表示
                    }
                }),
            errorSubject
                .sink(receiveValue: { [weak self] (error) in
                    guard let self = self else { return }
                    print("api error")
                })
        ]
    }

    // ログインボタン押した際に呼ぶ
    func applyLogin(email: String, password: String) {
        onSigninSubject.send(SigninRequest(email: email, password: password))
    }
}

そしていよいよ最後、SwiftUI で View に埋め込み。

import SwiftUI

struct RootView: View {
    @StateObject var myViewModel = MyViewModel()
    @State private var email = ""
    @State private var password = ""

    var body: some View {
        VStack {
          if !myViewModel.isLoggedin {

            TextField("メールアドレス", text: $email)
            SecureField("パスワード", text: $password)

            Button(action: {
                myViewModel.applyLogin(email: email, password: password)
            }) {
                Text("ログイン")
            }
          } else {
              LoggedinView()
          }
        }
    }
}

終わりに

SwiftUI での ネット通信処理は 最初の大きなハードルの一つだろう。一度問題なく動くように実装できれば、あとは他の API を呼び出しながらアプリの UI を変えていけば良くなる。

SwiftUI の魅力は 上記の中の isLoggedin の更新だろう。 isLoggedin を true にセットするだけで、 View が勝手に切り替わる!最初は頭がぐちゃぐちゃになるけど、慣れてくるとコードをよりシンプルに書ける SwiftUI の魅力を知ることができよう。

本記事は以下の本がベースとなってます。より詳細を知りたい方はぜひ。

SwiftUI での プロパティオブサーバの使いどき

ども、@kimihom です。

f:id:cevid_cpp:20210821161803j:plain

引き続き iOS の開発をしているけども、その中でプロパティオブサーバについて実際の利用例を示しながら便利さについて記してみる。

fullScreenCover での画面切り替え

特定のタイミングで特定の別画面を表示させたい時に、fullScreenCover で表示することがある。

struct MyView: View {
    @EnvironmentObject var myViewModel: MyViewModel
    
    var body: some View {
        VStack {
            // ~~
        }
        .fullScreenCover(isPresented: $myViewModel.showScreen) {
            ScreenView()
        }
    }
}

上記の場合、MyViewModel の ViewModel 側で定義した showScreentrue, false によって、 ScreenView を表示させるような実装になっている。

この fullScreenCover を出すか出さないかは Bool である必要があって、そのためにわざわざ必要な時に true を指定するのが面倒なケースがある。

 class MyViewModel: NSObject, ObservableObject {
    @Published var showScreen = false
    var hoge: String = "test"

    func some1() {
        if hoge == "something" {
           showScreen = true
        }
    }

    func some2() {
        if hoge == "test" {
           showScreen = false
        }
    }
}

showScreenhoge 変数の結果に常に左右されている場合、hoge の変更のたびに showScreen をセットする必要が出てくる。

そこでプロパティオブサーバーの登場だ。以下のように書ける。

class MyViewModel: NSObject, ObservableObject {
    @Published var showScreen = false
    var hoge: String = "test" {
        didSet { // Swift プロパティオブサーバ。hoge の値が変わるたびに呼ばれる
            self.showScreen = (self.hoge == "something")
        }
    }

    func some1() {
    }

    func some2() {
    }
}

showScreen の値は hoge の設定によって左右されるようになり、UI 表示のためだけの無駄なコードを減らすことができる。

終わりに

SwiftUI での 変数と ビューの 同期によって、とても簡単に UI の切り替えができるようになっている。 ただ、そのために変数の型と SwiftUI で求められる型 (今回だと Bool) に合わせる必要が出てくる。 SwiftUI ではそこもしっかりと対応するために、今回の プロパティオブサーバ など、便利なものがあるなと感じることができた。

本を読んでるだけだと 「ふ〜ん、でもそれ知らなくてもよくね?」 な文法や記法がたくさんあるんだけど、それぞれ学びながら最適なところで最適に使っていけるよう 精進を続けるとしよう。

SwiftUI 徹底入門

SwiftUI 徹底入門

Amazon

SwiftUI での オプショナル(Optional)型の対応

ども、@kimihom です。

f:id:cevid_cpp:20210814150213j:plain

ここ最近は Swift をいじっている。実装してみると、最初はこの Optional 型のコード意識の面倒さを感じていたが、慣れてくると便利だなと思えるようになってきた。

自分の認識をまとめる上でも、記事として記しておこう。

Optional 型の利用

まず、Swift では他の言語と同様に、Int, String などのような型定義がある。ただこれをそのまま使うには、最初から数字や文字列を入れておかないといけない。つまり nil を入れられない。

let year: int = 2021
let name: String = "kimihom"

基本的には上記にように 数字や文字列だけを入れるような実装となる。ただ、実際に iOS アプリを作ろうとすると、クラス内のオブジェクトを用意し、最初 nil定義することがよくある。

var appClient: HogeClient?

appClient = HogeClient.new
appClient.hello() // => エラー

上記エラーは、appClientnil の可能性がある状態のため、エラーとなる。以下のような選択肢がある。

1. if 文で埋め込む

if let client = appClient {
  client.hello()
}

2. ? Optional Chaining

appClient?.hello()

3. Guard 条件式

func someFunc() {
  guard let client = appClient else {
     print("appClient は nil")
     return
  }

  client.hello()
}

使い分け

感想だけど、上記3つ全部使いどきがある。 SwiftUI でコード書いているときに、どれ使うかの考えは基本的に以下だ。

1行だけで済む時: 2. ? Optional Chaining を使う。一行で全て済まされるコードのシンプルさが良い。

メソッド内で使う時: 3. Guard 条件式 を使う。1の if 文でも良いけども、guard の方が hello() メソッド実行時の タブを減らすことができる。また、if 文だと if {} else {} で囲った範囲でのみ利用となるので、 if の外では引き続き 使えない問題が起きる。 Guard ならそのメソッド内 guard より下のコードでは全て guard で定義した変数を使うことができる。余計なことを考えなくて済むのが guard のメリットだ。

上記 guard でのメソッドを if で書き換えるとこうなる。

func someFunc() {
  if let client = appClient { 
    client.hello()
  } else {
    print("appClient は nil")
  }

  // ここで `client` は使えない
}

client.hello() がタブ一つ奥で実行する必要があることがお分かりだろう。個人的に Optional型のためだけに タブが1つできてしまうのが全くもって気に入らない。ということで、実は 1. if 文で埋め込む は3つの中で最も使わないことが多い。

終わりに

とりあえず 「String? など ? 型定義した変数を使いたい時は、関数内で guard で新しい変数定義しておいて使う」というだけで、SwiftUI 開発は一気にスピードアップする気がしている。

さぁ、共に学んでいこう!

詳解 Swift 第5版

詳解 Swift 第5版

Amazon

Twilio VoIP iOS SDK への入門

ども、@kimihom です。

f:id:cevid_cpp:20210228144914j:plain

Twilio には iOS で電話ができる iOS SDK が存在する。これの魅力と触り始めについて記しておく。

Twilio VoIP iOS SDK 概要

公式サイトは以下にある。

iOS SDK - Twilio

では上記 Twilio VoIP iOS SDK を使うと何が嬉しいのか。大きな特徴として 2つあると考えている。

  • iPhone で 電話SIM を持たずとも、データSIM だけで Twilio 電話ができる
  • 電話着信時に、iPhone がスリープでも 電話着信を受け取って通話ができる

1つ目の項目は、ほとんどの人は何でそんなことができるか疑問に思うことだろう。090,080 などの電話番号を持つ電話 SIM と契約し、スマホ内にある公式の電話アプリを使って電話するもんだと思っている。この電話SIM を持たなくても、自分で電話番号(050, 0120, 0800)を買って、その電話番号で電話が可能だ。不要になったら捨てることも簡単にできる。

エンジニアにとっては特に2つ目のはかなり大きな特徴(強み)だと考える。電話なら当たり前のことなんだけど、着信が来たら iPhone がスリープしてても着信が来たことを通知し、電話にすぐ出られる仕組みが必要だ。この通知は、普通の iPhone アプリからの通知だけでは実装ができない。新しく出た iOS の CallKit と呼ばれるものを使えば、実現が可能である。

iPhone ブラウザ Chrome の JavaScript WebRTC で通話することとの違い

現在の iPhone では、ブラウザ(Safari か Chrome) で、JavaScript の Twilio Client SDK を使って実装が可能である。発信する電話番号をブラウザ上で入力し、相手に電話をかけることができる。

発信だけならいいんだけど、ブラウザ版 の Twilio Client で電話の着信を受けるには、常にブラウザでTwilio Client を使った特定のページを開き続ける必要がある。 当然ながら、iPhone ユーザーは他のアプリも使っているので、特定の Web ページだけを開き続けるなんてことはできないだろう。だからこそ、iOS 専用の Twilio VoIP iOS SDK を使う必要があるわけである。

実装

まずは、Twilio の提供するサンプルアプリを動かしてみることから始まる。

はじめよう - Twilio

さっとみてもらえればわかる通り、かなりの長い手順が必要だ。最終的には実機 iPhone を使って "VoIP Service Certificate" で認証を得た サンプルアプリで電話が必要だ。単に iPhone アプリを作るってだけでなく、iPhone からのHTTPリクエストで Twilio トークンを返す Web サーバーの用意実装が必要である。

なかなか高い壁だ。でもその高い壁を乗り越えた先に得られるメリットは大きい。

独自アプリに組み込む

大体実装の流れがわかったところで、実際に自分の作っている iPhone アプリに Twilio Client iOS SDK を組み込むことになる。

Twilio のページ内では、入れる方法が 4つ紹介されている

  • Swift Package Manager
  • CocoaPods
  • 手動インストール
  • 静的ライブラリー

数年前までは iPhone アプリ開発で入れるライブラリ管理は CocoaPods が一般的であったろう。情報の多さからも 最初 CocoaPods で入れようとしたら、Twilio Client iOS SDK が全く動かなかった。framework not found TwilioVoice などのエラーが出るだろう。

CocoaPods の次に出てきたのが Carthage というものらしい。しかし、現在の Twilio Client iOS SDK では Carthage は未サポートのようである。

Swift Package Manager は新しいライブラリの取り入れ方のようだけど、手順が無駄に複雑で、現状の Swift Package Manager でバイナリフレームワークである Twilio Client iOS SDK を入れることは危険だろう。

結局、手動インストールで対応することで、無事に Twilio Client iOS SDKを入れることができた。

終わりに

本記事はまだ Twilio Client iOS SDK を取り入れただけの状態である。こっから SDK の内容を詳しく理解し、プログラミングを進めていく必要がある。

でも組み込むことさえできれば、実装は サンプルアプリを読みながら取り入れていけばいいだけなので、大きなハードルは少ないと信じたい。

未来の電話の世界を実現すべく、引き続きプログラミングに注いでいこう。

SwiftUI で print が動作しない理由

ども、@kimihom です。

f:id:cevid_cpp:20210223221609j:plain

SwiftUI で開発してて、最も大きな謎であった print の出力が動かない理由がようやく分かったのでまとめておく。

SwiftUI の基本記法

単にテキスト表示させる SwiftUI は

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello World")
    }
}

となる。さて、ここで変数を持って print で検証したいってケースは当然のように出てくるだろう。

import SwiftUI

struct ContentView: View {
    var date = "2021/2/23"
    var body: some View {
        print(date)
        Text("Hello World \(date)")
    }
}

これだけで、 body の { の部分でエラーが発生する。

Type '()' cannot conform to 'View'; only struct/enum/class types can conform to protocols

何が原因なのか、全くわからない。しかもエラー記述場所は { の部分から出てくる。この体験は SwiftUI プログラマーにとって大きな課題の一つになるであろう。

View 内は、そもそも Swift 文を書くところではない

SwiftUI の View 内では、 if文 も書けるし、ForEach で繰り返し処理も書けるから、Swift 文を同じように書ける、と考えがちだろう。しかし、それは No である。

SwiftUI では ファンクションビルダと呼ばれる Swift 新構文によって、1行ずつ書いたコードが、自動で組み合わされる @_functionBuilder という機能がある。

この ファンクションビルダを使うと、、衝撃のコードが実現できるようになる。

// @_functionBuilder で 文字を組み合わせる 自前 `StringBuilder の ab()` を定義

// `ab()` の実行 !!!
@StringBuilder func ab() -> String {
  "こんにちは"
  "テスト"
}
print(ab()) // こんにちはテスト

// 以下と同じ
func ab() -> String {
  let a = "こんにちは"
  let b = "テスト"
  return StringBuilder.buildBlock(a, b)
}
print(ab()) // こんにちはテスト

今回の場合、String を組み足しただけのコードとなるが、なんと "こんにちは" "テスト" を文字列として各行に書くだけで、文字が足される処理を実装できる。ここは、Swift 構文ではなく、ファンクションビルダで定義した構文を書くことになるわけである。

そもそも SwiftUI ではなぜ for文ではなく、ForEachで書かなければならないのかもこれで理解できた。ForEach は ファンクションビルダないで使える特殊な書き方であり、通常の for文では ファンクションビルダとして書けないという理由なのだろう。

ただ if文 は普通の Swift コードを書いてるのと同じように書けてしまうので、最初は この var body: some View 内は普通の Swift 文をかけると誰もが思ってしまうことだろう。

終わりに

Swift を勉強してて もっと大きな謎だった "print 文が SwiftUI 内で書けない" 問題を理解することができた。 この問題の理由は、以下の本だけで知ることができた。

SwiftUI の本を4,5 冊くらい読んだけど, some の定義は一旦なんなんだ? なんで print が SwiftUI 内で書けないんだ? といった SwiftUI を最初に触る人が必ず思う疑問を、徹底して解説してくれている唯一の本だった。レベルが1上がった。

SwiftUI 徹底入門

SwiftUI 徹底入門

iOS / Swift 開発のハードル

ども、@kimihom です。

f:id:cevid_cpp:20210221133807j:plain

Swift での iOS アプリ開発で苦戦している最中だ。少しずつ進んでいるけど、そこで起きている現在のハードルについて記してみよう。

バージョンアップが激しい

まず、2年前以上の Swift 関連記事をググって見つけても、ほとんどは役に立たない。現在では UI は SwiftUI で作ることが一般となりつつある中、1~2年前まで主流だったオートレイアウトでの記事しかないためである。

オートレイアウトの状態のままでも動かし続けられるため、前からオートレイアウトを使っていた方がわざわざ SwiftUI に書き換えるってのも無いようで、新しい記事でも SwiftUI を使われていない記事もいくつかある。

SwiftUI での最新記事のほとんどは、初心者向けの単純なものか、逆に記事が複雑すぎて理解困難なものが多かった。もちろん私の理解力不足ってのもあるだろうけど、初心者向け SwiftUI の本3冊読んでもまだスッキリとしない状態である。中級者向けの本とかがあればいいのか、あとは開発で苦戦しながらやるしかないのだろうか。

// ログイン状態の保存
@AppStorage("isLogin") private var isLogin = false

上記は ユーザーがログインしている状態なのかどうかを管理する変数である。この実装を見ると、ログインのトークン管理も AppStorage でやりたくなる。だが詳しく調べると、トークンなどの貴重なデータは AppStorage 保存先である UserDefaults に保存してはダメなようだ。UserDefaults はオープンな保存場所のようで、AppStorage は公開されても問題ないようなデータだけ 使うとされている。じゃあどうすれば良いかってなると、Keychain を使う方法がネット上に書かれている。

SwiftUI から始めた勢にとっては、SwiftUI で全てやり切るのは不可能で、昔? の書き方を結局は学ぶ必要がありそうである。

エラーメッセージの対応

Swift はコンパイル型の言語である。エラーが起きそう な変数の扱い方をするだけで、エラー発生としてアプリを動かせなくなる。 このエラーメッセージが全部英語で、かつどれもわかりづらい。ググってもパッと解決が出てこないようなエラーがあまりにも頻発するので、結局コードの書き方を戻して、ゼロから考え直すってことが繰り返されている。

この厳しいコンパイル要素は、複数人チームで Swift で開発する際には 統一されるという意味で強力な制約ではある。しかし初心者にとってのハードルがどんどん高くなって、しかも変化が激しいからずっとついていかなければならない厳しさがある。とりあえず 現状の Swift でアプリを作り切ったとしても、そのあとの頻繁な iOS/Swift の更新追従をちゃんとできるのか。難しいところである。

ソースの理解

例えば先程の AppStorage をもっと詳しく知りたいとなった時、定義元のソースを読みたくなる。

f:id:cevid_cpp:20210221131353p:plain

そうすると、SwiftUI のコードの1,331行目のコードに飛ばされる。SwiftUI ファイル、なんと全体で23,817行もあるファイルである。もちろん全て英語ってことで、問題が起きた時にはなんとか読み進んでいくしかない。

この2万を超えるソースコードを読むたびに、初めてみるクラスが出てきて、理解するってところまで行けない状態だ。これがパッと理解できるようになってきたら、ようやくスラスラ開発ができるようになるのだろう。

f:id:cevid_cpp:20210221132008p:plain

私に必要なもの

今の私に必要なのは、Swift コードの奥にまで理解を進められるような環境だろう。しかし、サービスを運営して多くの問い合わせやタスクが来るようになると、この理解時間の確保が難しくなる。途中で他のタスクが入ってくる頃には、今までどんなことを学んできたのかを忘れてしまう。それに伴い、どんどん奥にまで読み進めるというモチベーションも、集中できない環境では やろうという気になれなくなってしまう。

あぁ、3~5年前の私には、それができていた。東京から離れた地である熱海にて、少ないユーザー数であったサービスの開発で、プログラミングだけに集中できる環境があったのである。

現在、その代わりの場所として、長野上諏訪の地がある。体調が回復しつつある今、私に必要なのは本当の意味で iOS アプリ開発だけに集中できる環境なのではないか。

まだまだ iOS / Swift の完全理解には時間がかかるけど、続けていこうではないか。その先に見える世界を信じてね。