はじめに
LINEアプリ開発本部モバイル・ディベロッパーエクスペリエンスチームのまつじです。
WWDC25が開催され、新しいデザイン言語として"Liquid Glass"が登場し、大きな話題になりました。他にもWWDC25ではいろいろな新情報が明らかになり、その1つがscene-basedライフサイクルの必須化です。セッションビデオの中で明言されていますが、scene-basedライフサイクルをサポートしていないアプリは、iOS 26の次のメジャーアップデート(iOS 27?)から起動しなくなります。
LINE iOSは数年前にこのscene-basedライフサイクルへの移行を完了し ています。この記事では、その移行をどのように進めたのか、どのような注意点があったのかを紹介します。
scene-basedライフサイクルとは?
iOS 12まではUIApplicationDelegateがアプリのライフサイクル及びウインドウのライフサイクルの管理をしていました。
しかしiOS 13 (iPadOS 13) からマルチウインドウが導入され、1つのアプリが複数のシーン(ウインドウ)を持つようになりました。これに伴って、iOS 13からはUIApplicationDelegateがアプリのライフサイクルを管理し、UISceneDelegateがアプリのシーン(多くの場合はウインドウ)のライフサイクルを管理するようになりました。この管理の方法をscene-basedライフサイクルと呼びます。一方で、UISceneDelegateを使わず、UIApplicationDelegateのみでアプリとシーンのライフサイクルを管理する方法をapp-basedライフサイクルと呼びます。(ドキュメントによってこの表記は揺れており、UISceneライフサイクル / UIApplicationライフサイクルとも呼ばれたりしています)
SwiftUI.Appを使ったSwiftUIのアプリであったり、UIWindowSceneDelegateを使ったアプリの場合、そのアプリはscene-basedライフサイクルで管理されているアプリといえます。一方、UIWindowSceneDelegateを使っていないUIKitのアプリの場合、そのアプリはapp-basedライフサイクルで管理されているアプリといえます。
今回のアナウンスにより、iOS 26の次のメジ ャーアップデートから、app-basedライフサイクルのアプリは起動しなくなるので、この移行は避けて通れなくなりました。
当時のLINE iOSの状況
LINE iOSはiOS 13のscene-basedライフサイクルが登場するより前から存在していたアプリのため、長年app-basedライフサイクルで管理されていました。
しかし、少しずつscene-basedライフサイクルでしか使えない機能が増えていき「app-basedライフサイクルを維持し続けることはリスクになるのでは」「もしかしたら将来的にサポートが終わるのではないか」という見方が強くなり、scene-basedライフサイクルへの移行を開始することにしました。
LINE iOSでの対応
LINE iOSでは、scene-basedライフサイクルへの移行にあたり、主に以下を行いました。
- 起動時の処理のリファクタリング
- Feature Flagを用いたライフサイクルの切り替え
- UIApplication.StateではなくUIScene.ActivationStateを使用
- UIWindow.init()やUIWindow.init(frame:)の禁止
起動時の処理のリファクタリング
app-basedライフサイクルとscene-basedライフサイクルの一番の違いは、その起動の流れにあります。app-basedライフサイクルの場合、UIApplicationDelegateのapplication(_:didFinishLaunchingWithOptions:)でデータベースの初期化といった「アプリの初期設定」とUIViewControllerの作成/表示といった「UIのセットアップ」を行います。
一方でscene-basedライフサイクルの場合、アプリの初期設定はUIApplicationDelegateのapplication(_:didFinishLaunchingWithOptions:)
で行い、UIのセットアップはUIWindowSceneDelegateのscene(_:willConnectTo:options:)で行います。
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// app-basedライフサイクル: アプリの初期設定とUIのセットアップを行う
// scene-basedライフサイクル: アプリの初期設定のみを行う
}
}
final class SceneDelegate: NSObject, UIWindowSceneDelegate {
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// app-basedライフサイクル: 呼ばれない
// scene-basedライフサイクル: UIのセットアップを行う
}
}
そのため、移行するには、application(_:didFinishLaunchingWithOptions:)
からUIのセットアップをscene(_:willConnectTo:options:)
に移動させる必要があるのですが、アプリの初期設定とUIのセットアップがapplication(_:didFinishLaunchingWithOptions:)
の中で密結合だった場合、かなり難しくなります。実際LINE iOSの場合、起動時のロジックはとても複雑で、それらが密に依存している状態でした。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 「アプリの初期設定」と「UIのセットアップ」が密に依存しあっているコード
return true
}
そのため、LINE iOSではいきなりscene-basedライフサイクルに移行するのではなく、まずは起動時に行っている アプリの初期設定とUIのセットアップを分離することに取り組みました。そして最終的には、アプリの初期設定が完全に終わった後、その結果を見て必要なUIをセットアップするようにしました。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// ここでは「アプリの初期設定」 のみを行う (UIは触らない)
...
// ここからは「UIのセットアップ」のみを行う
...
return true
}
こうすることで、scene-basedライフサイクルに移行する時も、UIのセットアップだけをUISceneDelegateに移動させたらいいので、移行が簡単になります。
Feature Flagを用いたライフサイクルの切り替え
アプリがapp-basedライフサイクルなのか、それともscene-basedライフサイクルなのかは、以下の2つの方法を用いて判断されます。
- Info.plistにUIApplicationSceneManifestが正しく設定されているかどうか
- application(_:configurationForConnecting:options:)がAppDelegateに実装されているかどうか
上記のどちらかを満たした時、scene-basedライフサイクルとして管理されるようになります。LINE iOSの場合はapplication(_:configurationForConnecting:options:)
が実装されているかどうかで切り替える仕組みを採用しました。というのも、LINE iOSに"Feature Flag"という仕組みがあり、条件付きコンパイルブロ ック(#if XXXのような構文)を用いて、特定のコードをコンパイル対象に含めたり外したりすることができ、これと組み合わせることで開発者の環境で手軽にライフサイクルの切り替えができたからです。
final class AppDelegate: NSObject, UIApplicationDelegate {
#if ENABLE_SCENE_DELEGATE
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// ...
}
#endif
}
この仕組みのおかげで、通常の機能開発と移行作業を並行して進めることができました。
UIApplication.StateではなくUIScene.ActivationStateを使用
app-basedライフサイクルでは1つのアプリに1つのシーンしかなかったため「アプリの状態 = シーンの状態」でしたが、複数のシーン(≒マルチウインドウ)があり得るscene-basedライフサイクルではこの前提は成り立ちません。アプリの状態はUIApplication.Stateとして表され、シーンの状態はUIScene.ActivationStateとして表されます。
マルチウインドウをサポートしていなければ、結局「アプリの状態 = シーンの状態」という前提に大きな影響はないのですが、APIが持つ意味としては異なります。LINE iOSではこのタイミングで、UIApplication.Stateを使用している既存のコードに対して、「これはUIApplication.Stateのままでいいのだろうか?それともUIScene.ActivationStateを使うべきなのだろうか」というのを見直しました。実際LINE iOSでは、UIと独立した一部のコードを除き、多くはUIScene.ActivationStateの方が適していました。
UIWindow.init()やUIWindow.init(frame:)の禁止
scene-basedライフサイクルでは、UIWindowはどのUIWindowSceneで表示するべきかを決定するために、UIWindowに対してUIWindowSceneを渡す必要があります。そのため、UIWindow.initやUIWindow.init(frame:)で作成されたUIWindowは表示されません。代わりにUIWindow.init(windowScene:)が用意されているので、LINE iOSではそちらに置き換えました。実際、UIWindow.init()
やUIWindow.init(frame:)
はiOS 26から非推奨となります。
ハマったこと
scene-basedライフサイクルへの移行をする上で、実際にハマったことを紹介します。
起動処理中のUIApplication.Stateの値が変わる
UIApplicationDelegateのapplication(_:didFinishLaunchingWithOptions:)
が呼ばれたタイミングで取得できるUIApplication.Stateの値がライフサイクルによって異なります。アプリの起動の仕方にもよりますが、アプリのアイコンをタップしてアプリを開いた場合、application(_:didFinishLaunchingWithOptions:)
で取得できる値が、app-basedライフサイクルならinactive、scene-basedライフサイクルならbackgroundと異なります。一方CallKitなどで着信を受けてアプリが起動する場合、app-basedライフサイクルでもscene-basedライフサイクルでもbackgroundになります。つまり、起動経路によらずscene-basedライフサイクルなら常にbackgroundが渡されるので、結果的にはシンプルになったわけですが、同じプロパティでも値が変わってしまうので、その使われ方やタイミングは見直しが必要です。
外部ディスプレイ対応
上で説明したようにLINE iOSでは、application(_:configurationForConnecting:options:)
を実装しています。この関数にはUISceneに対してどのUISceneDelegateを使うのか決定するロジックを実装します。特に深く考えなければ、以下のような実装になるかと思います。
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
configuration.delegateClass = SceneDelegate.self
return configuration
}
これで通常の起動時には正しく動くのですが、iPhoneやiPadを他のディスプレイにミラーリングした状態でアプリを起動すると、挙動が変わっていることに気づきました。今までは手元のiPhoneの画面と同じサイズの画面が表示され、手元と同じUIが表示され、手元のUIをただ大きい画面で映しているだけでした。ですが、上のコードを適用した後は以下のように、UIは画面いっぱいに引き伸ばされ、どれだけiPhoneを操作してもこのディスプレイに表示されている内容は全く動きません。
これは手元のiPhoneと外部ディスプレイで表示する内容を変える機能です。例えばスライドアプリのように、外部ディスプレイにはスライドを表示して、手元のiPhoneには原稿を表示するといったことができます。Appleのアプリだと写真アプリで写真を開いた時に体験できます。
多くの場合、この機能は必要ないので無効化しておくべきなのですが、この機能が上の変更によって意図せず有効化されていました。
その原因はUISceneSession.Roleの値を確認していないことです。外部ディスプレイに接続した時、別のシーンが作成され、UISceneSession.Roleの値としてwindowExternalDisplayNonInteractiveが渡されますが、この時にSceneDelegateを設定してしまうと、このアプリは外部ディスプレイに対応しているとシステムが誤解し、有効化されてしまいます。
よって正しく実装するには connectingSceneSession.role
の値がwindowApplicationの時だけSceneDelegateを設定する必要があります。
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self
}
return configuration
}
UIWindowSceneの取得
UIWindowのinitがUIWindowSceneを要求するようになるなど、UIWindowSceneが必要なシチュエーションが増えますが、その取得方法には注意が必要です。よく色々な記事で以下のような方法が紹介されていますが、この方法ではマルチウインドウ環境などでは正しくUIWindowSceneを取得できないので、可能な限りこの方法は避けてください。
let windowScene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first
正しく対応するには、UIWindowSceneDelegateの中でscene(_:willConnectTo:options:)
が呼ばれたタイミングでUIWindowSceneを保持するか、以下のようにUIViewからUIWindowSceneを取得する2つの方法があります。
let windowScene = view.window?.windowScene
なお、UIViewから値を取得する場合、そのUIViewが表示された後でないとnil
になることに注意が必要です。UIViewControllerのviewWillAppearが呼ばれた後にアクセスすると適切なUIWindowSceneが取得できます。
おわりに
LINE iOSでscene-basedライフサイクルに移行する上で行ったこととハマったことを紹介しました。
scene-basedライフサイクルへの移行はアプリ全体に影響を与えるので、通常の機能開発以上に注意が必要です。例えばLINE iOSでは上で説明したCallKitの着信や外部ディスプレイ以外にも、Background fetchの起動がうまくいかない、クイックアクション(アプリのアイコン を長押しして表示されるアクション)の挙動がおかしい、など移行中は多くのバグがありました。みなさんのアプリにはどういう起動経路があるのか、事前に洗い出しをしておくとスムーズにテストができます。
また、LINE iOSは関係者も多く複雑で巨大なアプリなため、各画面を担当している開発者への説明やQAチームとの連携など、実際の作業以外も多く必要で、移行を決定してからリリースまでに2年弱という時間が必要でした。ただ、時間をかけてできるだけ丁寧に移行したため、影響範囲の大きい変更にもかかわらず、把握している範囲ではバグを出さずにリリースすることができました。他のアプリではそこまで時間がかかる作業ではないかもしれませんが、scene-basedライフサイクルの必須化まであと1年程ですので、できるだけ早く動き出すことをおすすめします。
その時にこの記事が役に立つことを願っています。
なお、Appleの公式のドキュメントや移行ガイドも公開されているので、そちらも併せてご確認ください。