LoginSignup
6
3

More than 1 year has passed since last update.

iOSアプリのクラッシュを減らしアプリを安定運用(したい)[Carefully Unwrapping]

Last updated at Posted at 2021-12-01

はじめに

本記事は with Advent Calendar 2021 2日目の記事です。

こんにちは。withでアプリ開発をしている @zrn-ns です。
withアドベントカレンダーの2日目を担当させていただきます。

注意

この記事で取り上げた内容はまだ検証中です。
最後の方に課題も記載していますので、もし採用する際は十分にご検討いただくようお願いします。

Optional型のアンラップ、どうしてますか?

早速ですが、Optional型をアンラップするとき、どのような方針をとっていますか?
もちろん場合によって様々だと思いますが、たまにどうアンラップするか悩むことってないですか。

たとえば下記のような、ほぼ確実にアンラップに成功する気がするけど、どういうパターンでアンラップに失敗するのか分からない...っていうパターンです。

// stringにどんな値を入れればnilになる...? 強制アンラップして大丈夫...?
let url: URL = .init(string: "https://google.com")!

// じゃあOptional Bindingを使う...?
guard let url: URL = .init(string: "https://google.com") else { return }

上記の例では固定の文字列を使っているので、一度成功すればその後もほぼ確実にアンラップに成功するはずなので、Forced Unwrappingを使用することが多いと思います。
しかし、ライブラリ側の更新やロジックの変更などにより、突然nilになる可能性もあります。

ではOptional Binding(やnil結合演算子)を使用すべきなのでしょうか... ?

Forced UnwrappingとOptional Bindingの比較

ここでForced UnwrappingとOptional Bindingを比較してみます。

メリット デメリット
Forced Unwrapping - 記述が簡略
- 万が一nilになった場合、クラッシュが発生するので、Crashlytics等でロジックエラーの発生を検知できる
- 意図しない状態でプログラムが進行するのを防ぐことができる
- 万が一nilになった場合、ユーザ環境でクラッシュしてしまう
Optional Unwrapping - 万が一nilになった場合に、ユーザ環境でのクラッシュを防げる - 基本的に発生しないパターンに対する記述が冗長になる
- 万が一nilになった場合、ロジックエラーに気づけない(回避コードの内容による)
- 意図しない状態でプログラムが進行してしまう

表を見てみると分かるように、それぞれメリット・デメリットがあります。

Forced Unwrappingは記述も容易ですし、Crashlyticsを使用していればアプリのクラッシュをCrashlyticsで検知可能になるので、基本的にはForce Unwrappingを選択すべきだと思います。
しかしこの場合、ユーザ環境でクラッシュする可能性を残してしまうことになります。
AppStoreで配信しているアプリでクラッシュが起きた場合、修正から審査を経てストアに公開されるまで、少なくとも丸一日はかかります。
この際の緊急対応にかかるコストやユーザへの補償を考えると、頭が痛くなりますね。

一方、Optional Binding(やnil結合演算子)を使えばクラッシュを防ぐことはできますが、想定されないパターンに関する回避コードを追加しないといけなくなりますし、ユーザ環境でロジックエラーが発生していることに気付きにくくなります。

そこで今回、Forced Unwrapping、Optional Bindingに次ぐ第3の選択肢として Carefully Unwrapping を提案します。

Carefully Unwrapping

何を実現したいのか

今回Carefully Unwrappingを作成して実現したいのは、

  1. 記述が楽
  2. アンラップに失敗した場合にクラッシュさせない
  3. アンラップに失敗したことを開発者側が検知できるようにする

の3点です。
Forced Unwrappingのメリットである「記述が楽」「アンラップの失敗を検知できる」、Optional Bindingのメリットである「アンラップ失敗時にクラッシュしない」の良いとこ取りになっています。

ソースコード

Carefully Unwrappingのソースコードは下記のような感じです。

// エラー情報をCrashlyticsに送信したいため必要
import FirebaseCrashlytics

import Foundation

public extension Optional {
    /// デフォルト値つきでアンラップする
    ///
    /// - 値が存在する場合はそれを返す
    /// - 値がnilの場合はデフォルト値を返し、Crashlyticsにエラー情報を送信する
    func carefullyUnwrapped(defaultValue: Wrapped, originFilePath: String = #file, originFileLine: Int = #line, originMethodName: String = #function) -> Wrapped {
        guard let _self = self else {
            // エラー情報をCrashlyticsに送信する
            let error = CarefullyUnwrapFailure(originFilePath: originFilePath, originFileLine: originFileLine, originMethodName: originMethodName)
            Crashlytics.crashlytics().record(error: error)
            return defaultValue
        }
        return _self
    }
}

public extension Optional where Wrapped: DefaultValuePresentable {
    /// デフォルト値つきでアンラップする
    ///
    /// - 値が存在する場合はそれを返す
    /// - 値がnilの場合はデフォルト値を返し、Crashlyticsにエラー情報を送信する
    ///   - またデバッグ環境の場合は、気づきやすいようクラッシュさせる
    func carefullyUnwrapped(originFilePath: String = #file, originFileLine: Int = #line, originMethodName: String = #function) -> Wrapped {
        carefullyUnwrapped(defaultValue: Wrapped.defaultValue(), originFilePath: originFilePath, originFileLine: originFileLine, originMethodName: originMethodName)
    }
}

public protocol DefaultValuePresentable {
    static func defaultValue() -> Self
}

/// Optional.carefullyUnwrapped(defaultValue:) に失敗した
private struct CarefullyUnwrapFailure: Error {
    /// originMethodName: 呼び出し元メソッド名
    init(originFilePath: String, originFileLine: Int, originMethodName: String) {
        let originFileName: String = originFilePath.components(separatedBy: "/").last ?? originFilePath
        _domain = "\(String(describing: Self.self))(\(originFileName):\(originFileLine) >>> \(originMethodName))"
    }

    /// エラードメイン設定(_domain:String と _code:Int の組み合わせでCrashlytics上のエラーが分類される)
    public let _domain: String
}

// MARK: - 型ごとのデフォルト値設定(ここに設定を追加すれば、carefullyUnwrappedのdefaultValue引数を省略できる)

extension Date: DefaultValuePresentable {
    public static func defaultValue() -> Date {
        .init()
    }
}
extension URL: DefaultValuePresentable {
    public static func defaultValue() -> URL {
        // NOTE: 存在しないURLではあるが、クラッシュ回避用としては十分
        return .init(string: "https://example.com/image.jpg")!
    }
}
extension String: DefaultValuePresentable {
    public static func defaultValue() -> String {
        ""
    }
}
extension Int: DefaultValuePresentable {
    public static func defaultValue() -> Int {
        0
    }
}
extension Double: DefaultValuePresentable {
    public static func defaultValue() -> Double {
        0
    }
}
extension Data: DefaultValuePresentable {
    public static func defaultValue() -> Data {
        .init()
    }
}

Optional型に対して、carefullyUnwrapped(defaultValue:) を呼べるようになります。
その際、引数のdefaultValueは、Optional.Wrappedの型がDefaultValuePresentableに適合していれば省略可能です。

またアンラップに失敗してデフォルト値が返ったとき、↓のようにエラーの詳細がCrashlyticsに記録されます。

使い方

Carefully UnwrappingでOptional型をアンラップするコードを見てみましょう。

// (比較用) Forced Unwrapping
let url: URL = .init(string: "https://google.com")!

// (New!) Carefully Unwrapping
let url: URL = .init(string: "https://google.com").carefullyUnwrapped()

シンプルな記述で、Forced Unwrappingで発生する可能性のあるクラッシュの発生を抑制することができ、かつエラー情報はFirebaseに通知されるため、ロジックエラーの発生を検知する事ができます。

課題

Carefully Unwrappingを先程の表に追加してみます。

メリット デメリット
Forced Unwrapping - 記述が簡略
- 万が一nilになった場合、クラッシュが発生するので、Crashlytics等でロジックエラーの発生を検知できる
- 意図しない状態でプログラムが進行するのを防ぐことができる
- 万が一nilになった場合、ユーザ環境でクラッシュしてしまう
Optional Unwrapping - 万が一nilになった場合に、ユーザ環境でのクラッシュを防げる - 基本的に発生しないパターンに対する記述が冗長になる
- 万が一nilになった場合、ロジックエラーに気づけない(回避コードの内容による)
- 意図しない状態でプログラムが進行してしまう
Carefully Unwrapping - 記述が簡略
- アンラップに失敗した場合でもクラッシュせず、Crashlytics等でロジックエラーの発生を検知できる
- 意図しない状態でプログラムが進行してしまう

Forced Unwrappingの問題であったクラッシュの発生を抑制でき、かつシンプルな記述にすることができたので、めでたしめでたし...のように思えますが、Carefully Wrappingにはいくつか課題があります

課題1. デフォルト値が返された場合に、後続の処理が正常に実行される保証はない

アンラップに失敗したとき、Forced Unwrappingのように即時でクラッシュすることは回避できましたが、代わりにデフォルト値が返され、その値で後続の処理が実行されてしまいます。
デフォルト値でちゃんと動作する保障が無い以上、結局後続の処理がうまく動かずクラッシュしてしまう可能性があります。

課題2. protocol DefaultValuePresentable をfinalでない型に対して適合するのが難しい

defaultValue引数を省略するためには、型をDefaultValuePresentableに適合させる必要があるのですが、そのためには static func defaultValue() -> Self {} の実装が必要です。
finalでない型でこのメソッドを実装しようとした場合、対象の型のdesignated initializerを使って初期化する必要があります(convenience initializerを使用することはできません)。
型によっては簡単に使えるようなdesignated initializerが提供されていない場合もあるので(CGImage等)、その場合は苦労することになります。

課題3. Carefully Unwrappingを使いたい場面はそもそもそんなに多くない

どのようなパターンでnilになるかわからない場合は便利に使えますが、そういう箇所についてはしっかりテストを書くべきだと思います。
またそのようなパターンは実際そこまで多くないような気がしているので、Carefully Unwrappingを定義するよりはOptional Bindingを使用してCrashlyticsへのエラー送信はその場その場で実装したほうがよいような気もしています。

さいごに

というわけで結論としては、残念ながらちょっと微妙かも、ということになります。
もしCarefully Unwrappingを採用する場合は、上記の課題を知った上でご利用いただくことをおすすめします。

6
3
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
6
3