スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

単一アプリでユーザに応じた機能切り替えを実現するために

はじめに

iOSエンジニアのkomajiです。2023年9月に「スタディサプリ 小学講座」をリニューアルし、既存の「スタディサプリ 中学講座」アプリに組み込む形でリリースしました。

この小学講座は既存の中学講座とは機能が全く異なっていますが、単一アプリで両方の機能を提供しています。これらはユーザの学年に応じて切り替わるようになっているのですが、本記事では、単一アプリでこの機能切り替えをどのようにして実現してきたのかについて紹介します。

小学講座について

小学講座では、子どもが自ら学び、その子に一番合った最高の学習体験が提供されることを目指しています*1。これを実現する仕掛けを組み込むために、小学講座では以下の画像のように既存の中学講座とは全く異なる機能を提供しています*2

小学講座 中学講座
小学講座
中学講座

機能が全く異なるため、別アプリとしてリリースすることも方法の一つですが、学習体験や運用・実装コストなどを総合的に判断し、同一のアプリとしてリリースしています。

単一アプリでの機能切り替えの実現

マルチモジュール

単純に2種類の機能を提供するとなるとコード量が2倍になります。小学講座は中学講座に比べてシンプルな機能構成となっていたり、会員登録やログインといった共通部分があったりするため2倍ではないですが、それ相応のコード量となります。

両方の機能を単一のモジュールで管理すると、それぞれ独立すべき実装の境界が曖昧になり疎結合を維持しづらくなってしまい、保守性が低下してしまいます。そのため、それぞれが干渉せず独立して開発できるように、小学講座・中学講座をそれぞれのモジュールで管理することにしました。いわゆるマルチモジュール化です。これを小学講座の開発前の段階で事前に整備しました。

モジュールはSwift Package Mangerで管理しており、Packageを複数作り、その中に機能ごとのTargetを作ることでモジュール化を実現しています。現在の主なモジュールの構成は以下のようになっています。

主なモジュールの構成
主なモジュールの構成

モジュール化の大まかな方針は以下のとおりです。

  • モジュール内で完結するように実装する
    • 同様の実装を複数箇所で利用する場合のみ共通化モジュール(CoreやUIComponent)にて共通化する
  • 画像などのリソースも各モジュール内で保持する
  • JuniorHighSchool/LowerElementarySchool Package内のTargetにはPackageを示すPrefixを付与する
    • 同じ機能のTargetがそれぞれのPackageに存在するので認知負荷の観点から付与する

それぞれ独立したモジュールで管理することで、小学講座の機能を開発する際にはLowerElementarySchool Packageを触るだけでよく、中学講座への影響がほとんどない状態で開発できるようになりました。加えて、モジュール単位でのビルドが可能となることで、ビルド時間が削減されたりXcode PreviewでUIを確認しながらの実装が可能になったりと、開発効率・開発体験が大きく向上したと感じています。

学年に応じた機能の切り替え

小学・中学講座の機能を学年に応じて切り替える必要がありますが、この切り替えの実装をどのように実現しているかを紹介します。

アプリ起動から対象講座のホーム画面が表示されるまでのフローは大まかに以下のようになっています。

学年に応じた機能表示フロー
学年に応じた機能表示フロー

まずアプリを起動するとログイン画面が表示されます。そこからログインまたは会員登録してログイン状態になると、ユーザの学年に応じて小学講座または中学講座が表示されます。これらの切り替えはRootScreenというビューで管理しています。

学年に応じた機能の選択ロジックはバックエンドで管理しているため、RootScreenはユーザがログインした際にどちらの機能を表示すべきかをAPIから取得し、それに応じて対応する機能のビューを子ビューとして表示しています。具体的な実装は以下のようになっています(一部わかりやすくするために省略・変更しています)。

enum StartupScreen {
  case loading // ローディング画面の表示
  case login // ログイン画面の表示
  case lowerElementrySchoolHome // 小学講座のルートビューの表示
  case juniorHighSchoolHome // 中学講座のルートビューの表示
}

struct RootScreen {
  @StateObject var viewModel: RootScreenViewModel

  var body: some View {
    contents
      .onAppear {
        viewModel.fetch()
      }
  }

  @ViewBuilder
  var contents: some View {
    switch viewModel.screen {
    case .loading:
      LoadingView()
    case .login:
      NavigationView {
        LoginScreen()
      }
    case .lowerElementrySchoolHome:
      NavigationView {
        LowerElementrySchool.HomeScreen()
      }
    case .juniorHighSchoolHome:
      NavigationView {
        JuniorHighSchool.HomeScreen()
      }
    }
  }
}

final class RootScreenViewModel: ObservableObject {
  @Published private(set) var screen: StartupScreen = .loading

  func fetch() {
    // ログイン状態やバックエンドで選択された機能に基づいてscreenを更新する
  }

このように機能の切り替えをRootScreenに集約したり、前述したマルチモジュール化によって機能をモジュールとして切り出したりすることで、それぞれの機能の実装をそれぞれ閉じた状態で行えるなり、保守性の低下を防いでいます。

機能ごとの画面回転制御

iPadで利用した場合、中学講座では画面回転を許容していますが小学講座は横方向のみをサポートしています(小学講座はiPadでのみの提供)。そのため、許容する画面方向を動的に(コードで)変更する必要があります。そこで、以下による画面方向制御を組み合わせて実施した場合にどのように機能するのかを検証しました。

検証結果は以下のようになりました。

  • Info.plist で有効化→コードで無効化
    • ❌ できない
  • Info.plist で無効化→コードで有効化
    • ✅ できる

この結果から以下の対応を実施し、中学講座では両方向の許可・小学講座では横方向のみの許可を実現できました。

  • Info.plistでは横方向のみ許可する
  • ログイン・会員登録を終えて中学講座へ遷移した場合にコードで縦方向を許可する

なお、ログイン・会員登録画面においては、もともと許容されていた縦方向に回転ができなくなるという既存動作との差分が生じてしまいましたが、今回は許容しています。

おわりに

本記事では、全く機能の異なる小学講座と中学講座を単一のアプリとして提供する上で、機能切り替えをどのようにして実現してきたのかについて紹介しました。単一アプリとすることでシンプルには対応できない点もありましたが、保守性を考慮しながら適切に実装できたと感じています。

小学講座のリリースを無事終えたのでひと段落ではありますが、デザインシステムに基づいたコンポーネントの実装やモジュール化の展開など課題はまだまだ残っているため、より良い学習体験を提供できるように引き続き改善していきたいです。

*1:https://www.recruit.co.jp/newsroom/pressrelease/2023/0919_12620.html

*2:2023/11/08時点では小学生は1年生のみが利用できますが、他学年も順次サポートしていく予定です。