Mirrativ tech blog

株式会社ミラティブの開発者(サーバサイドエンジニア,iOSエンジニア,Androidエンジニア,機械学習エンジニア,インフラエンジニア, etc...)によるブログです

ミラティブのサーバサイドをGo + Clean Architectureに再設計した話

こんにちは、テックリードの夏です。

今年4月にCTOからテックリードに肩書が変わり、ガリガリコードを書くようになりました。 背景については、こちらをご覧ください。

www.wantedly.com

普段はプロダクト側の機能開発と、サーバ側の基盤開発を半々ぐらいの割合で仕事しています。 一口にサーバ側の基盤開発といっても定義が曖昧なのですが、基本的にはこんな感じのタスクをやっています。

  • インフラコストの最適化
  • 不正なアクセスからの防御
  • 障害の再発防止
  • 新技術の導入やアーキテクチャの整備

今回はこのうち「新技術の導入やアーキテクチャの整備」の中で、サーバサイドをGo + Clean Architectureで再設計したことについてお話したいと思います。

背景

ミラティブは2015年春頃に開発が始まり、同年8月にサービスがリリースされ、2020年8月で5周年を迎えました。 その過程で組織やプロダクトが成長するにつれ、サーバサイドには以下のような負債が溜まっていました。

  • アーキテクチャが崩れかけている
    • MVC + Serviceだが、依存関係がスパゲッティ
      • Service = Controllerの処理を共通化したレイヤー
    • 膨れ上がるModel
      • データ型 + 永続化の両方を担当
      • 場合によってはPresenter的な仕事も
    • Contextという名のもとにあらゆるレイヤーから密結合を黙認されているGodなクラス
  • query digestやslow queryなどで危険なSQLが洗い出されても、どこで発行されているのか調査に時間がかかる
    • method chainによる柔軟なSQLの組み立てのメリットがもはや負債
    • Modelが永続化も内包しているせいで、様々なレイヤーから実行時にSQLが発行される恐れアリ
      • 果てはViewからも。。。
  • 負債が溜まったテーブルを再設計しづらい
  • テストがすべてシナリオテストで書かれている
    • テストの実行時間が長い
    • シナリオテストは開発者によって書き方に差異が出やすい
    • エッジケースのテストを書くためのコストが大きい

また、ミラティブのサーバサイドは開発当時の事情によりPerlで書かれているのですが、OSSコミュニティでのプレゼンス低下なども踏まえてGoへの移行を検討し始めました。

そこで、サービス固有の歴史的経緯やインフラ構成に即したコードを表現できるか確認するために、Go言語とアーキテクチャの整備を同時に行うのではなく、 既存のPerl側のコードで上記の課題を改善し得るアーキテクチャを整備してから、Go移行を進めることになりました。 これにより、標準的なDBの負荷分散手法を抽象化できるか、トランザクションやロギングをどう表現するのか、チームに受け入れられるかどうかなどもGo移行に先んじて検証することができました。

半年くらいかけてPerl側のClean Architectureのプロトタイプを完成させ、1年かけてサーバチーム全体に浸透させました。現時点では、既存アーキテクチャのコードはフリーズしています。 また、Perl側のアーキテクチャ刷新と並走しながら、Goのプロトタイプ実装を進めてきました。

Go移行に関しては正直まだDaemonやBatchなど、移行しやすいコンポーネントしか本番投入出来ていません。しかし、テックブログを書くことで逆説的に社内への普及を加速させるためにも、 ミラティブのサーバサイドのGoコードのアーキテクチャをまとめてみようと思います。

Clean Architecture

アーキテクチャを再設計する上でClean Architectureを参考にすることにしました。 世の中のClean Architectureの文献を色々漁ってみても、コアとなる考え方は同じなのですが、細部に関してはいろいろな流派があるように見受けられます。 そこで、Clean Architectureとして正解を追うのではなく、過去の実装上の経緯を背負った上で、辛みポイントを解消できるようなアーキテクチャを設計しました。 Clean Architectureがどういうものなのかは参考記事に譲るとして、本記事ではミラティブで利用されているコードに近い形で、設計の詳細に入りたいと思います。 (本家本元のClean Architectureとは異なる場合がありますが、ご了承ください)

qiita.com qiita.com qiita.com www.m3tech.blog (「なぜ書くのか」に激しく同意)

再設計する上で大事にしたポイントは、「コンポーネントの依存性を一方向にする」の一点に集中するかなと思います。 これはなにも、ミドルウェアを差し替えた場合でも、内側のビジネスレイヤーを1行も変更したくないレベルの抽象化を目指したいわけではなく、 負債が溜まったMySQLのtableを再設計する際の影響範囲を最小限に留めようとか、外側の依存性を内側に注入することで、外部APIに依存する処理をテスト時だけモックを差し込みやすくすることなどが目的です。

f:id:mirrativ:20201127114557j:plain
あまりにも有名な例のあの図 The Clean Code Blog

ディレクトリ構造

├── entities
├── usecases
│   ├── inputport
│   ├── interactor
│   │   └── user
│   └── repository
├── gateways
│   ├── repository
│   │   ├── user
│   │   └── datasource
│   │       ├── dsmemcached
│   │       └── dsmysql
│   ├── datasource
│   │   ├── dsmemcachedimpl
│   │   └── dsmysqlimpl
│   └── infra
│       ├── infradns
│       ├── infralogger
│       ├── inframemcached
│       └── inframysql
├── controllers
│   ├── daemon
│   └── web
├── frameworks
│   ├── daemon
│   └── web
├── assets     // ここ以下のファイルはstatikによってバイナリに埋め込む
├── cmd        // アプリケーションの起動コマンドや、各種lint/generator/migrationコマンドが存在
│   └── wire   // DIライブラリ google/wire の定義ファイル
└── utils      // インフラレイヤーにもビジネスレイヤーにも該当しないutility群

Entities

オブジェクトでビジネスロジックを表現する責務を負っています。 ここでいうEntityは、DDDなどでのEntityとは違い、一意な識別子が存在しないものも定義しています。

これにより、Loggerのように全レイヤーから参照されるinterfaceなどもEntitiesに存在しています(実装はInfra層)。

package entity

type UserID uint64

type User struct {
    UserID UserID
    Name   string
}

type Logger interface {
    Error(ctx context.Context, err error)
    Log(ctx context.Context, level LogLevel, label string, payload ...interface{})
}

UseCases

Entity・Repositoryを使い、ユースケースを達成する責務を負っています。

└── usecases
     ├── inputport
     │   └── user.go
     ├── interactor
     │   └── user
     │       └── interactor.go
     └── repository
         └── user.go

ここでは usecases/inputport ディレクトリにinterfaceを定義し、

package inputport

import (
    "context"
    "time"
)

type User interface {
    UpdateRecommend(ctx context.Context, now time.Time) error
}

usecases/interactor ディレクトリにその実装を配置しています。 また、トランザクションのスコープを管理するのもInteractorのお仕事です。

package user

import (
    "context"
    "time"

    "server/entities/entity"
    "server/usecases/inputport"
    "server/usecases/repository"
)

type interactor struct {
    txm      repository.TransactionManager
    repoUser repository.User
}

func New(txm repository.TransactionManager, repoUser repository.User) inputport.User {
    return &interactor{
        txm:      txm,
        repoUser: repoUser,
    }
}

func (i interactor) UpdateRecommend(ctx context.Context, now time.Time) error {
    var recommendUserIDs []entity.UserID

    // おすすめユーザを計算

    return i.txm.Do(ctx, func(txns repository.Transactions) error {
        return i.repoUser.UpdateRecommend(ctx, txns, recommendUserIDs)
    })
}

usecases/repository ディレクトリにはInteractorが要求するRepositoryのinterfaceが定義されます。

package repository

import (
    "context"

    "server/entities/entity"
)

type User interface {
    ReadRecommend(ctx context.Context) ([]entity.User, error)
    UpdateRecommend(ctx context.Context, txns Transactions, recommendUserIDs []entity.UserID) error
}

Repository

データの集約、永続化の責務を負っています。 対応するDataSourceを活用し、UseCasesレイヤーが実際のテーブル構造などを把握しなくてもEntityの永続化を行える責務を負っています。

  • データの整合性が取れる最小単位
    • 例)MySQL側のDataSourceを更新したら、Memcached側のDataSourceも更新
  • DataSourceで取得したデータをEntityに変換
  • CRUDなinterfaceを提供
    • 命名規則もCreate/Read/Update/Deleteを強制
└── gateways
     └── repository
         ├── datasource
         │   ├── dsmemcached
         │   │   └── recommend_users.go
         │   └── dsmysql
         │        └── user.go
         ├── transaction
         │   └── repo.go
         └── user
             └── repo.go

usecases/inputport で定義されたRepositoryのinterfaceの実装が配置されています。

package user

import (
    "context"

    "server/entities/entity"
    "server/gateways/repository/datasource/dsmemcached"
    "server/gateways/repository/datasource/dsmysql"
    "server/usecases/repository"
)

type user struct {
    dsmemcachedRecommendUsers dsmemcached.RecommendUsers
    dsmysqlRecommendUser      dsmysql.RecommendUser
}

func New(dsmemcachedRecommendUsers dsmemcached.RecommendUsers, dsmysqlUser dsmysql.User) repository.User {
    return &user{
        dsmemcachedRecommendUsers: dsmemcachedRecommendUsers,
        dsmysqlRecommendUser:      dsmysqlRecommendUser,
    }
}

func (r user) ReadRecommend(ctx context.Context) ([]entity.User, error) {
    // dsmemcachedRecommendUsersからおすすめユーザを取得
    // なければdsmysqlUserから問い合わせ
    // 取得したDataSource固有の構造体をEntityへ変換
}

func (r user) UpdateRecommend(ctx context.Context, txns repository.Transactions, recommendUserIDs []entity.UserID) error {
    // dsmysqlRecommendUserで更新してから、dsmemcachedRecommendUsersを更新
}

gateways/repository 以下には、Repositoryが期待するDataSourceのinterfaceを定義しています。

package dsmysql

import (
    "context"

    "server/entities/entity"
)

type RecommendUsers interface {
    Update(ctx context.Context, txns repository.Transactions, users []*RecommendUserRow) error
    Select(ctx context.Context) ([]*RecommendUserRow, error)
}

Transaction

複数のInfra・DataSourceへのトランザクションを管理する責務を追っています。 (トランザクションスコープはInteractorで管理)

ミドルウェアを跨った厳密なトランザクションはサポートされていませんが、複数のMySQLのデータベースへの書き込みがある場合は、 すべての処理が完了してからのcommitやエラー時にすべてのsql.Txのrollbackなどを抽象化しています。

package repository

import "context"

// commitとrollbackができるものをTransactionと定義
type Transaction interface {
    Commit(ctx context.Context) error
    Rollback(ctx context.Context) error
}

// 複数のTransactionを抽象化し、同一データベースへのTransactionはキャッシュする
type Transactions interface {
    Get(key string, builder func() (Transaction, error)) (Transaction, error)
    Succeeded(f func() error) // cache更新などrollbackできない(厳密な整合性を担保しなくていい)処理などを登録し、全てのcommitが成功した場合のみ実行する
}

// トランザクションのスコープを管理するオブジェクト(dry-run時は最後に全てrollbackする)
type TransactionManager interface {
    Do(ctx context.Context, runner func(txns Transactions) error) error
}

DataSource

Infraを活用し、Repositoryが要求するデータの取得、永続化を達成する責務を負っています。

  • MySQLのtableや、Memcachedのkey、ElasticSearchのtypeと1:1の関係
  • 該当するミドルウェア固有の操作名に沿った命名規則
    • SQLであればSelect/Insert/Update/Delete
    • CacheであればGet/Set
└── gateways
     └── datasource
          ├── dsmemcachedimpl
          │   └── recommend_users.go
          └── dsmysqlimpl
               └── recommend_user.go

gateways/repository 以下で定義されたDataSourceのinterfaceの実装が配置されています。

package dsmysqlimpl

import (
    "context"

    "server/entities/entity"
    "server/gateways/infra"
    "server/gateways/repository/datasource/dsmysql"
    "server/usecases/repository"
)

type recommendUser struct {
    infraMySQL infra.MySQL
}

func NewRecommendUser(infraMySQL infra.MySQL) dsmysql.RecommendUser {
    return &recommendUser{infraMySQL: infraMySQL}
}

func (ds recommendUser) Update(ctx context.Context, txns repository.Transactions, users []*dsmysql.RecommendUserRow) error {
    txn, err := ds.infraMySQL.GetTxn(ctx, txns, "BASE_W") // BASE_W はデータベース系統の名前
    if err != nil {
        return nil
    }

    _, err = txn.ExecContext(ctx, "delete from recommend_user")
    if err != nil {
        return err
    }

    _, err = txn.ExecContext(ctx, "insert into recommend_user ...")
    return err
}

func (ds recommendUser) Select(ctx context.Context) ([]*dsmysql.RecommendUserRow, error) {
    return SelectRecommendUser(ctx, ds.infraMySQL, repository.DB_R)
}

このうち、 dsmysql.RecommendUserRow の構造体や SelectRecommendUser の処理などは、以下のような内製のテーブル定義から自動生成しています

kyleconroy/sqlc をオマージュしました)

recommend_user:
  columns:
    - name: user_id
      type: uint64
      foreign_key: user.user_id
    - name: name
      type: string
      collation: utf8mb4_bin
  primary_keys:
    - user_id
  queries:
    - sql: select * from recommend_user

このテーブル定義からDDLを生成し、 k0kubun/sqldef に食べさせることで、MySQLのマイグレーションなども行っています。

Infra

ミドルウェアとの実際の接続や入出力などを担当するレイヤーです。 内側のレイヤーが各ミドルウェアのI/Fを把握せずとも利用できる状態にする責務を負っています。

└── gateways
     └── infra
          ├── cache.go
          ├── config.go
          ├── db.go
          ├── dns.go
          ├── infradns
          │   └── infradnstest
          ├── infrahttp
          │   └── infrahttptest
          ├── infralogger
          │   └── infraloggertest
          ├── inframemcached
          │   └── inframemcachedtest
          ├── inframemorycache
          └── inframysql
               └── inframysqltest

gateways/infra 直下には各種ミドルウェアの入出力のinterfaceが定義しています。

package infra

import (
    "context"
    "database/sql"

    "server/go/usecases/repository"
)

type Transaction interface {
    repository.Transaction
    DB
}

type MySQL interface {
    Get(ctx context.Context, name string) (DB, error) // 複数系統のデータベースが存在するのでnameで指定する
    GetTxn(ctx context.Context, txns repository.Transactions, name string) (Transaction, error)
}

type DB interface {
    ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
    QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
}

そして、実際の実装はさらに一階層掘って定義しています。 そして、さらに一階層掘ったディレクトリにはtest用の実装が存在しています。 (loggerであればファイルに書き出さずに出力内容を変数として保持しておくとか)

Frameworks

外界からの入力をControllerへルーティングする責務を負っています。 ここでは時刻情報も外界の一部としてみなし、このレイヤー以外では現在時刻を取得しないように制限しています。 これにより特別なライブラリを用いずともテストを決定的にしたり、動作確認する際に任意の時刻への変更を行いやすくなります。

  • Web
    • HTTP RequestのURLなどを参照し、該当するControllerへRequestを渡す
      • Sessionの解決などもこのレイヤー
    • RequestやResponseがOpenAPIの定義通りかどうかを検証する
      • 実行速度が犠牲になるので開発環境のみ
    • 内部に引き回す時刻情報はリクエストを受け取った時刻
  • Daemon
    • queueベースで動作している非同期処理の場合は、該当のqueueからdequeue処理とControllerをつなげる
      • 内部に引き回す時刻情報はenqueueされた時刻
    • それ以外の非同期処理の場合は、実行間隔だけが指定されるので、指定された頻度でControllerを実行する
      • 内部に引き回す時刻情報はControllerが実行された時刻

Controllers

外界からの入力を、達成するユースケースが求めるインタフェースに変換する責務を負っています。 HTTP Request内のパラメータを取り出したり、queueの中から必要な情報を取り出して適切なInteractorに渡したりします。

また、ミラティブではPresenterを呼び出すのはInteractorではなくController内なので、 Interactorから返ってきたEntityをPresenterで変換し、外界が求める出力フォーマットに変更するのもControllerの責務です。

package user

import (
    "context"

    "server/controllers/web"
    "server/presenters/user"
    "server/usecases/inputport"
)

type Controller struct {
    user   inputport.User
}

func New(user inputport.User) *Controller {
    return &Controller{user: user}
}

func (c Controller) RecommendUsers(ctx context.Context, webCtx *web.Context) error {
    recommendUsers, err := c.user.ReadRecommendUsers(ctx)
    if err != nil {
        return err
    }

    return webCtx.RenderJSON(ctx, map[string]interface{}{
        "users": user.PresentUsers(recommendUsers),
    })
}

テスト戦略

もともとはすべてシナリオテストでカバーしていたのですが、アーキテクチャを再設計したタイミングで、 基本的なカバレッジ率の達成にはユニットテストを用い、統合テストでは正常系のユースケースのみ検証しています。

(ここに登場する話が身につまされたので、今後はTesting Pyramidに従おうと思います) testing.googleblog.com

テストのカバレッジ率は90%以上を目指しており、それ以下の場合はCIに怒られるように設定しています。 高めの数字を置いてはいますが、実は go tool cover で出力されるカバレッジ率は使っていません。 以下のようなテストを書くコストが見合わなさそうなブロックを除いてカバレッジ率を計算しているので、思ったほど酷な数字ではないと思います。

  • errorが存在していたら後続処理を行わずに、呼び出し元にreturnするだけのif文
  • panic
    • そもそも初期化時やアプリケーション実行中にどう足掻いても復旧できない場合のみpanicを使用しているため
  • delegateのように引数を一切加工せずにフィールドに渡すだけの関数

また、テストの実行速度は生産性に直結するため、すべてのテストにt.Parallelを指定することをLintで強制したり、 データベース名にユニークなsuffixを差し込むことでテスト同士で衝突しないようにしたり、Fixtureのロードを該当するtableへのSQLが実行された場合のみ行ったりと、小技を駆使しています。

今後の展望

現在、DaemonやBatchは本番投入済みで、Web側のいくつかエンドポイントもGo実装が完了しています。 しかし、モノリシックなPerlのWebサーバを移行していくのはそう簡単ではなく、 現在インフラチームと協力しながら、前段にProxyを挟みながら、特定のエンドポイントだけGo + Dockerコンテナで受けられるような仕組みを開発中です。

来期中にWebの本番投入と新機能の開発を全てGo化、半年後にはPerlコードのフリーズまでできると最高だなあと妄想しつつ、きっと色々ハマると思いますし、 アーキテクチャの整備に完成などないので、面白いことがあったらまたテックブログネタにしようかなと思います。

We are hiring!

ミラティブでは サービスや組織の成長に合わせて、生産性を最大化するためのより良いアーキテクチャを模索し続けられるサーバエンジニアを募集中です! meetup も開催しているため気軽にご参加ください!

www.mirrativ.co.jp

speakerdeck.com

Mirrativ の iOS アプリで使っているライブラリを紹介する!

こんにちは、iOSエンジニアのちぎらです。今回は Mirrativ の iOS アプリで使っているライブラリをご紹介します。

Mirrativ ではどんなライブラリを使用していますか?と質問されることが時々あります。設定画面のライセンス情報に一覧で表示はされているものの、ライブラリ名だけでは用途が分かりにくいものもあるので、説明を添えて一覧で確認できるようにしようというのが今回の趣旨です。

ライブラリ管理には CocoaPods、Carthage を使用しています。最新のライブラリに追従できるように、一部のライブラリでは CI(Bitrise)上で定期的にバージョン更新のためのプルリクを作成しています。Swift Package Manager はまだ導入していませんが、タイミングを見て集約していけたらいいですね。

続きを読む

【Unity】MirrativのEmbedding Unityを更新した話: 実践 Unity as a Library

こんにちは皆様いかがお過ごしでしょうか、10ヶ月ぶりくらいのポストになります、よこてです。今日は「Mirrativ の Unity は進化してるんだぞ」という記事を書いていきます。

tech.mirrativ.stream

Mirrativ は Swift/Kotlin によるネイティブアプリですが、3D/アバター部分は Unity で実現しています。いわゆる embedding unity で、 Unity 2018.3 からは Unity as a Library として公式サポートされています。前回記事で触れたように、Unity をネイティブアプリに組み込むこと自体は公式サポート以前にもできて、ミラティブでは Unity 2018.2(2018年8月頃)から使っています。

f:id:n0mimono:20201015194824p:plain

Mirrativ では今 Unity 2019.4 LTS を使っていて、8月から Mirrativ の機能としてリリースした「エモモRUN」(3Dアバター × ゲーム × ライブ配信)もこれを利用しています。公式としてサポートされたといってもハマりどころがあったりするので今日はそのあたりを中心に話をします。

Unity as a Library

Unity as a Library は読んで字のごとく「Unity を(アプリケーションでなく)ライブラリとしてつかう」方法です。Mirrativ がアバター機能を最初にリリースした2018年時点では、ググっても情報量皆無の認知度でしたが、今はそれなりにヒットする感じで1ミリずつ広がりを見せているんじゃないかと思います。

公式の説明から引用すると

Unity では、ランタイムライブラリの読み込み、アクティベーション、アンロードの方法とタイミングをネイティブアプリケーション内で管理するための制御機能を用意しています。その上、モバイルアプリの構築プロセスはほぼ同じです。Unity では iOS Xcode と Android Gradle のプロジェクトを制作できます。

もともと Unity は昔から、エンジン部分をライブラリとしてアプリケーション本体から切り離すような構成をしていました。具体的に Android では、エンジンのエントリーは libmain.so 、ビューとして Surface View (本体はVulkanあるいはGLSL)、ラッパーとしての UnityPlayerextends FrameLayout)があり、これを使うためのアプリケーションとして MainActivity がある、という構成です。

前回記事(Unity 2018.2)時点では、UnityPlayerを Unity が用意する MainActivity から切り離して使いました。ビルドという視点では、もともとアプリ用に準備されたプロジェクトをライブラリ用の設定にして、ライブラリとしてビルドするということをやっています。

  • アプリ用プロジェクト ← これをライブラリ用に書き換えてビルドする

Unity 2018.3 以降の Unity as a Library では、Unity 上で iOS/Android ビルドをした時点で Unity エンジン部分がプロジェクトとして初めから分離しています。Android の場合には、アプリケーションのプロジェクトの中に unityLibrary というサブプロジェクトが出力され、アプリのプロジェクトがこの unityLibrary に依存する構成になっています。このため unityLibrary をそのままビルドすれば他プロジェクトで利用するための .aar が取得できます。

  • アプリ用プロジェクト
    • ライブラリ用プロジェクト ← これをビルドする

出力されたプロジェクトをそのままネイティブ側のプロジェクトに組み込んでもよいですが、Mirrativ では一度ライブラリ(iOS の場合は .framework)としてビルドしています。

フレームワークのビルド(2020版)

そのままビルドすればいいといったものの、そのままビルドできません。。いくつかの hack を入れます。

iOS

Mirrativ では次の3つの処理を行っています。

  1. Xcodeプロジェクトの修正
  2. Info.plistの修正
  3. ネイティブコードの修正

すべてポストプロセスで処理するようにしています。コードの一部を抜粋すると

    public void OnPostprocessBuild(BuildReport report)
    {
        var outputPath = summary.outputPath;
        var overridePath = Application.dataPath + "/../Framework/iOS/build";
        EditProject(outputPath); // 1
        EditPList(outputPath); // 2
        Utility.RSync(overridePath, outputPath); // 3
    }

Utility.RSyncrsync -av overridePath outputPath と同じ処理を行うメソッドで、overridePath 以下にある全ファイルを outputPath 以下のファイルに上書きします。

Xcodeプロジェクトの修正は必須の処理になります。

    static void EditProject(string outputPath)
    {
        var projectPath = outputPath + "/Unity-iPhone.xcodeproj/project.pbxproj";

        var pbx = new PBXProject();
        pbx.ReadFromFile(projectPath);

        // Get UnityFramework Target
        var guidTarget = pbx.GetUnityFrameworkTargetGuid();

        // Add Public Header
        var guidHeader = pbx.FindFileGuidByProjectPath("Libraries/Plugins/iOS/UnityPlayerToIOS.h");
        pbx.AddPublicHeaderToBuild(guidTarget, guidHeader);

        // Add Data to Resources Build Phase.
        var guidData = pbx.FindFileGuidByProjectPath("Data");
        var guidResPhase = pbx.GetResourcesBuildPhaseByTarget(guidTarget);
        pbx.AddFileToBuildSection(guidTarget, guidResPhase, guidData);

        // Add BITCODE_GENERATION_MODE
        pbx.SetBuildProperty(guidTarget, "BITCODE_GENERATION_MODE", "bitcode");

        pbx.WriteToFile(projectPath);
    }

Unity が出力する Xcode のプロジェクトにはアプリ用の Target とフレームワーク用の Target が含まれます。フレームワーク用の Target に対して次の3つを行います。

  • iOS 用プラグインをプロジェクトに含める
  • Data(バンドルされるリソース郡)をプロジェクトに含める
  • bitcode を生成するようにする

この中で Data フォルダをリソースに指定するのは必須で、これがないと Unity が正常に動きません。Data フォルダはアプリ用 Target に含まれますが、フレームワーク用の Target には含まれないため、これを追加する処理を行います。プラグインと bitcode の対応は必須ではありませんが、Mirrativ では両方とも使用しているためこれを入れています。

Info.plist の修正は optional な処理になります。

    static void EditPList(string outputPath)
    {
        var plistPath = outputPath + "/UnityFramework/Info.plist";

        var plist = new PlistDocument();
        plist.ReadFromFile(plistPath);

        var root = plist.root;
        root.SetString("CFBundleShortVersionString", Application.version);

        plist.WriteToFile(plistPath);
    }

バージョンをフレームワークに入れています。Unity エディタ上で指定するバージョンはアプリの Info.plist に書かれますが、フレームワークの Info.plist には書かれないためこの処理を入れています。

ネイティブコードの修正は optional な処理になります。 Xcode が出力するコードを適当に書き変えたいときに Utility.RSync を利用して書き換えます。例えば、Mirrativ では UnityFramework.h とその周辺のファイルを書き換えていて

__attribute__ ((visibility("default")))
@interface UnityFramework : NSObject
{
}

...

- (void)setAudioSessionActiveUnsafe:(bool)active;
@end

main.mm で

- (void)setAudioSessionActiveUnsafe:(bool)active
{
    UnitySetAudioSessionActive(active ? 1 : 0);
}

という風にしています(この例だと絶対に必要というわけではありませんが。。)。

Android

ライブラリビルド用にポストプロセスを用意します。コードの一部を抜粋すると

    public void OnPostprocessBuild(BuildReport report)
    {
        var outputPath = summary.outputPath + "/unityLibrary";
        var overridePath = Application.dataPath + "/../Framework/Android/unityLibrary";

        ProcessorUtility.Rsync(overridePath, outputPath);
    }

Mirrativでは次のファイルを書き換えています。

  • unityLibrary
    • build.gradle
    • src
      • main
        • AndroidManifest.xml
        • jniLibs
          • x86
            • libmain.so
        • res
          • values
            • ids.xml
            • strings.xml

Unity には AndroidManifest.xml を上書きする仕組みがありますが、ライブラリ用には動いてくれないためポストプロセスで上書きします。

AndroidManifest.xml から application タグを消します。

<?xml version="1.0" encoding="utf-8"?>
<!-- GENERATED BY UNITY. REMOVE THIS COMMENT TO PREVENT OVERWRITING WHEN EXPORTING AGAIN-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.unity3d.player" xmlns:tools="http://schemas.android.com/tools">
  <uses-feature android:glEsVersion="0x00020000" />
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
  <uses-feature android:name="android.hardware.touchscreen" android:required="false" />
  <uses-feature android:name="android.hardware.touchscreen.multitouch" android:required="false" />
  <uses-feature android:name="android.hardware.touchscreen.multitouch.distinct" android:required="false" />
</manifest>

さらに UnityPlayer

    private static String a(Context var0) {
        return var0.getResources().getString(var0.getResources().getIdentifier("game_view_content_description", "string", var0.getPackageName()));
    }

というようにリソースにアクセスしているため、アプリのプロジェクトに含まれる strings.xml をライブラリに含むようにします。

<string name="game_view_content_description">Game view</string>

また必須の処理ではありませんが、x86 用の libmain.so を用意しています。UnityPlayer は static initializer で libmain.so を読み込みますが、Unity は x86 の端末(たとえばエミュレータ)をサポートしないため(libmain.soがないため)クラッシュを起こします。

    static {
        (new m()).a();

        try {
            System.loadLibrary("main");
        } catch (UnsatisfiedLinkError var1) {
            com.unity3d.player.g.Log(6, "Failed to load 'libmain.so', the application will terminate.");
            throw var1;
        }
    }

動作する x86 ビルドは用意できないしする必要もないので読み込めるだけのダミーの libmain.so を用意します。

フレームワークの利用

iOS

基本的には公式のサンプルコードを Swift で書き直すだけです。実際に使っているコードから一部抜粋します。

import UnityFramework

extension Unity {
    final class Framework {
        #if arch(i386) || arch(x86_64)
        public func load(argc: Int32, argv: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>!, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
        }
        #else
        public func load(argc: Int32, argv: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>!, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
            func unityFramewokLoad() -> UnityFramework? {
                let bundlePath = "\(Bundle.main.bundlePath)/Frameworks/UnityFramework.framework"
                let bundle = Bundle(path: bundlePath)
                if let bundle = bundle, !bundle.isLoaded {
                    bundle.load()
                }

                let ufw = bundle?.principalClass?.getInstance()
                if ufw?.appController() == nil {
                    var header = _mh_execute_header
                    ufw?.setExecuteHeader(&header)
                }
                return ufw
            }

            let ufw = unityFramewokLoad()
            ufw?.setDataBundleId("com.unity3d.framework")
            ufw?.runEmbedded(withArgc: argc, argv: argv, appLaunchOpts: launchOptions)
        }
        #endif
    }
}

シミュレータ用にダミーの関数を用意しています。

Android

こちらも同じく公式にサンプルがありますが、Mirrativ では OverrideUnityActivity を使わずに UnityPlayer を直接使っています。

class AnyUnityViewFragment : Fragment() {
    private val unityPlayer: UnityPlayer by inject()
}

UnityPlayer は子に SurfaceView をもつ FrameLayout ですが かなり問題児で 適当に扱うと割とクラッシュします。SurfaceView のサイズがなんらかの理由で変更されたときにフレームバッファを作り直す処理が走るため、Unity の処理がハングするのと、さらに処理が終わる前にサイズをさらに変更すると容易にクラッシュします。処理の完了を上手く拾えなかったため( onSurfaceChanged もあまり当てにならず、、)、アプリの方にサイズ変更を連発させないような処理を入れています。

おわりに

Unity as a Library を使うと Unity とネイティブのいいとこどりができるという側面もある一方制約も増えます。例えば、開発中のイテレーションを考えると、Unity ビルド → Xcode ビルド(Unity フレームワーク) → Xcode ビルド(iOS ビルド)となり単純にビルド時間が伸びます。また、アプリから利用する場合はアセットバンドルのビルドも考慮する必要が出てきます。

このような事情のため、Mirrativ の開発方針としては可能な限り Unity/iOS/Android が各々独立して動作可能になるように設計、運用しています。アセットフローという視点では、Unity 側では CI によってフレームワークをビルドし、アウトプット先として GitHub のリポジトリにフレームワークを push、iOS/Android 側のプロジェクトでは git submodule として扱う、という形で運用しています。

未解決の問題はそれなりにあって、例えばネイティブ/Unity間の通信と設計というトピックがあります。現状はネイティブ(iOS/Android)から Unity に情報を伝えるとき SendMessage を使って request を投げるような構成になっているのですが、iOS/Android のアプリ側はFluxなアーキテクチャになっているため、ネイティブ側としては state を Unity にわたして、ネイティブからは Unity が状態を持っていないように見える、、というのが良さげな設計かな、と考えています。今年もあと3ヶ月程度ですが、このあたりは年末までにやっつけていきたいですね。

We are hiring!

ミラティブでは ゲーム×ライブ配信のアプリを一緒に作ってくれる iOS/Android エンジニアを募集中です!meetup も開催しているため気軽にご参加ください!

www.mirrativ.co.jp

speakerdeck.com

MirrativのiOSアプリリリースを支える自動化技術

 こん○○は。エンジニアのshogo4405です。普段は、ミラティブで開発しながら、余暇にOSSのHaishinKit*1をつくっています。

はじめに

 MirrativのiOSアプリは、git-flow で開発を行なっています。git-flow や日々の開発を運用する中で、次のような考慮すべきタイミングがありました。今回は、これらを自動化した際の話をコード付き*2で紹介します。

  • ライブラリーのアップデート
  • releaseブランチ作成
  • tag付けの実施
  • releaseブランチのmasterへのマージとdevelopへのマージ

 なお、リリース頻度は、1週間に1回以上。毎週火曜日に通常版の申請を実施しています。手動で運用していた時には、文字通り、気を付け ながら運用していました。

週の業務の流れ

 MirrativのiOSチームの業務の流れと共に、曜日ベースで📝自動化した内容を紹介していきます。

月曜日

 ミラティブ社の週始めは、10:30に全体で集合する朝会から始まります。朝会は、当週の方針や社の方向性を共有する大事な会です。

📝ライブラリーの更新

 開発は、ライブラリーの更新から始まります。10:00の定時バッチで、自動的に更新を行っています。以下の作業を行ったPRが作成されて、朝会後にレビューをしてからマージをしています。

f:id:shogo4405:20200214192147p:plain

  1. bundleの更新
  2. CocoaPodsの更新
  3. Carthageの更新
  4. SwiftLintの自動実行
  5. ライセンスの更新
lane :update_dependencies do |options|
  date = Date.today.to_s # 現在日付を取得
  token = options[:token] || ENV["GITHUB_API_TOKEN"]
  branch_name = "feature/update-dependencies-" + date # branch名

  sh('cd ../ && find ./mirrorman -name "*.storyboard" -or -name "*.xib" | xargs -IFILE xcrun ibtool --upgrade FILE --write FILE')

  # bundleの更新 
  sh("cd ../ && bundle update")
  # CocoaPodsの更新
  sh("cd ../ && bundle exec pod update")
  # Carthageの更新
  sh("cd ../ && carthage update --platform iOS --use-ssh --no-use-binaries --cache-builds")
  # SwiftLintの自動実行
  sh("cd ../ && ./Pods/SwiftLint/swiftlint autocorrect")
  # ライセンスの更新 LicensePlist 感謝
  sh("cd ../ && ./Pods/LicensePlist/license-plist --output-path mirrorman/Settings.bundle --github-token #{token}")
  sh("git checkout -b #{branch_name}")

  git_add
  git_commit(
    path: "./",
    message: "定期のライブラリーの更新(#{date})"
  )

  push_to_git_remote(
    remote: "origin",
    local_branch: branch_name,
    remote_branch: branch_name,
    tags: false
  )

  create_pull_request(
    repo: "path_to/repo",
    title: "[定期] ライブラリーの更新(#{date})",
    head: branch_name,
    base: "develop",
    body: "定期のライブラリーの更新です"
  )

end

火曜日

 前週の金曜日に締めたソースコードを、AppStoreへ申請する日です。

📝 AppStoreへの定期アップロード

 月・火曜日では、前週に締めたアプリのリグレッションテストを行っています。このテストで確認した不具合を修正してAppStoreへ申請することになります。申請作業の短縮化のために、Bitrise上で、tag付けをフックとして、AppStoreへアップロードするようにしています。

 tag付け*3と同時に、AppStoreへアップロードすることにより、git-flow上でのtag付け忘れに役に立ちました f:id:shogo4405:20200214191623p:plain

desc "Releaseビルドを作成する"
lane :release do |options|
  v = get_info_plist_value(path: "./mirrorman/Supporting Files/mirrativ-prod-Info.plist", key: "CFBundleVersion")

  slack(
    message: "iOSのReleaseビルド(#{v})を作成中",
    payload: { "Build by": `whoami` },
    channel: "#github_mr-ios"
  )

  # build your iOS app
  gym(
    scheme: "mirrativ",
    configuration: "Release",
    include_bitcode: true,
    export_method: "app-store"
  )

  upload_to_testflight(
    skip_submission: true,
    skip_waiting_for_build_processing: true
  )

  slack(
    message: "iOSのReleaseビルド(#{v})をAppStoreにアップロードしました。",
    default_payloads: [],
    channel: "#github_mr-ios"
  )

  clear_derived_data()

end

📝 releaseブランチからmasterへのマージ作業

 GitHub上で、masterへマージすると同時に、developにもマージしています。現在は、別のスクリプトエラーで止まっています😭

 GitHub上のWebhookをトリガーで実現しています。Webhookの受け口側で、以下のようなRubyのコードを実行すれば master へマージ時と同時に、developへ戻すこと可能です。

octkit.merge("レポジトリー名", 'develop', pull_request['head']['ref'])

水曜日

 火曜日に、申請したアプリのリリース日です。

📝GitHubのリリースノートの作成

 リリース内容を後から見返すために、GitHubのリリース情報の更新を行います。AppStoreから届くメールをフックにして、リリース情報を更新しています。iOSチームではマイルストーン管理しており、リリース内容は、該当のマイルストーンからのプルリクエストのタイトルを元に作成しています。

f:id:shogo4405:20200214180926p:plain

desc 'リリースノートを作成する'
desc '    version: バージョン番号(フォーマットは、7.5.1)'
desc '    target: Slackのチャンネル名(デフォルトは、#bot_test)'
desc '    preview: ストア文言作成用の文言か否か(デフォルトは、false)'
desc '    code: githubのリリースノート更新に必要なコード. iOSだったら:7.5.1(7.5.1.0), Androidだったら:7.5.1(232)みたいな、()の部分'
desc "    ENV['GITHUB_API_TOKEN']: https://github.com/settings/tokens で取得してして Shell に定義しておいてください"
lane :release_note do |options|
  target = options[:target] || '#bot_test'
  version = options[:version] || ENV['VERSION']
  preview = options[:preview] || false
  code = options[:code] || ENV['CODE']

  token = ENV['GITHUB_API_TOKEN']
  UI.user_error!('Required version and token. ex: fastlane release_note version:7.5.1') unless version || token

  if target == 'github'

    value = github_release_note(
      version: version,
      format: 'md',
      repository_name: ENV['GITHUB_PATH']
    )

    set_github_release(
      repository_name: ENV['GITHUB_PATH'],
      name: version,
      tag_name: "#{version}(#{code})",
      description: value,
      commitish: 'master'
    )

    github_close_milestone(
      repository_name: ENV['GITHUB_PATH'],
      title: version
    )

  else

    value = github_release_note(
      version: version,
      repository_name: ENV['GITHUB_PATH']
    )

    pretext = preview ?
      "#{ENV['NAME']}(#{version})のリリースが近づいてきたよ!ストア文言作成お願いします。 " :
      "#{ENV['NAME']}(#{version})を審査にだすよ!リリース内容は以下の通りだよ! "

    slack(
      default_payloads: [],
      channel: target,
      attachment_properties: {
        text: [value].join("\n"),
        pretext: pretext,
        title: ":notebook: リリースノート(#{version})",
        mrkdwn_in: ['text']
      }
    )
  end
end

github_release_note

 GitHubから該当マイルストーンのPRからタイトルを抽出するために、fastlaneにて、次のような、カスタムアクションを作成しています。

module Fastlane
  module Actions
    module SharedValues
      GITHUB_RELEASE_NOTE_CUSTOM_VALUE = :GITHUB_RELEASE_NOTE_CUSTOM_VALUE
    end

    class GithubReleaseNoteAction < Action
      def self.run(params)
        version = params[:version]
        repository_name = params[:repository_name]
        @client = Octokit::Client.new(access_token: ENV['GITHUB_API_TOKEN'])
        params[:format] == 'md' ? output_md(repository_name, version) : output_slack(repository_name, version)
      end

      #####################################################
      # @!group Documentation
      #####################################################

      def self.description
        'A short description with <= 80 characters of what this action does'
      end

      def self.details
        'You can use this action to do cool things...'
      end

      def self.available_options
        [
          FastlaneCore::ConfigItem.new(key: :version,
                                       env_name: 'FL_GITHUB_RELEASE_NOTE_API_TOKEN',
                                       description: 'API Token for GithubReleaseNoteAction',
                                       verify_block: proc do |value|
                                         UI.user_error!("No API token for GithubReleaseNoteAction given, pass using `api_token: 'token'`") unless value && !value.empty?
                                       end),
          FastlaneCore::ConfigItem.new(key: :format,
                                       env_name: 'FL_GITHUB_RELEASE_NOTE_FORMAT',
                                       description: 'Create a development certificate instead of a distribution one',
                                       is_string: false,
                                       default_value: 'slack'),
          FastlaneCore::ConfigItem.new(key: :repository_name,
                                       env_name: 'FL_GITHUB_RELEASE_NOTE_GITHUB_PATH',
                                       description: 'Create a development certificate instead of a distribution one',
                                       is_string: true,
                                       default_value: '')
        ]
      end

      def self.output
        [
        ]
      end

      def self.return_value
        # If your method provides a return value, you can describe here what it does
      end

      def self.authors
        ['shogo4405']
      end

      def self.is_supported?(platform)
        platform == :ios
      end

      def self.milestone(repo, name)
        number = @client.list_milestones(repo).select do |milestone|
          milestone.title == name
        end.first.number
        @client.list_issues(repo, milestone: number, state: 'all')
      end

      def self.group(issue)
        return 'プロダクト' if issue.title.start_with?('[MIP-')
        return 'CS' if issue.title.start_with?('[CS-')
        return '不具合管理' if issue.title.start_with?('[MIR-')
        '開発'
      end

      def self.issues(repo, name)
        result = {}
        issues = milestone(repo, name)

        issues.each do |issue|
          result[group(issue)] = [] unless result[group(issue)].instance_of?(Array)
          result[group(issue)].push(issue)
        end

        result
      end

      def self.output_md(repo, name)
        message = ''
        issues = issues(repo, name)
        issues.sort.each do |key, value|
          message += "## #{key}\n"
          i = 1
          value.each do |issue|
            title = issue.title
                         .sub(/\[(MIP-\d+)\]/, '[[\1]](https://mirrativ.backlog.com/view/\1)')
                         .sub(/\[(CS-\d+)\]/, '[[\1]](https://mirrativ.backlog.com/view/\1)')
                         .sub(/\[(MIR-\d+)\]/, '[[\1]](https://mirrativ.atlassian.net/browse/\1)')
            message += "#{i}. #{title} (##{issue.number})\n"
            i += 1
          end
          message += "\n"
        end
        message
      end

      def self.output_slack(repo, name)
        message = ''
        issues = issues(repo, name)
        issues.sort.each do |key, value|
          message += ":mira1: *#{key}*\n"
          i = 1
          value.each do |issue|
            title = issue.title
                         .sub(/\[(MIP-\d+)\]/, '<https://mirrativ.backlog.com/view/\1|[\1]>')
                         .sub(/\[(CS-\d+)\]/, '<https://mirrativ.backlog.com/view/\1|[\1]>')
                         .sub(/\[(MIR-\d+)\]/, '<https://mirrativ.atlassian.net/browse/\1|[\1]>')
            message += "#{i}. #{title} (<#{issue.html_url}|##{issue.number}>)\n"
            i += 1
          end
          message += "\n"
        end
        message
      end
    end
  end
end

金曜日

 お疲れ様でした。週終わりの日です。ミラティブ社では、金曜日の18:30開始の夕会でその週の振り返りを全体で共有しています。

 金曜日までに、developへマージしたコードをコードフリーズして翌週にリリースする運用を行っています。

日曜日

 金曜日に締めたコードからgit-flowのreleaseブランチを作成する日です。

📝リリースブランチの作成

 毎時18:00に、自動的にreleaseブランチを作成しています。また、Slack上に、GitHubのPR一覧を投稿しています。この投稿情報は、AppStoreへのアップデート文言作成に利用したり、今週リリースする内容のサマリーとして社内全体で共有しています。

f:id:shogo4405:20200214060601p:plain

lane :release_branch do |options|
  token = options[:token] || ENV["GITHUB_API_TOKEN"]
  UI.user_error!("Required release token. ex: fastlane release_branch version:7.5.1 token:${github_api_token}") unless token

  unless options[:version]
    github = Octokit::Client.new access_token: token
    milestone = github.milestones("path_to/repo").sort { |a, b| a["due_on"] <=> b["due_on"] }.first
    UI.user_error!("直近リリースはないようです") if 5 < (milestone.due_on.to_date - Date.today).to_i
    UI.user_error!("マイルストーンが見つからないです") unless milestone
    options[:version] = milestone.title
  end

  branch = "release/#{options[:version]}"

  sh("git checkout develop && git pull origin develop")
  sh("git checkout -b #{branch}")

  version(v: options[:version] + ".0")

  File.open(".timestamp", "w") do |f|
    f.puts(Time.now.to_s)
  end

  short_version = get_info_plist_value(path: "./mirrorman/Supporting Files/mirrativ-prod-Info.plist", key: "CFBundleShortVersionString")

  # release_note作成のlane
  release_note(
    target: "#release_note",
    version: short_version,
    token: token,
    preview: true
  )

  git_commit(path: "*", message: "Bump to #{options[:version]}")
  add_git_tag(tag: "#{short_version}(#{short_version}.0)")

  push_to_git_remote

  create_pull_request(
    repo: "path_to/repo",
    title: "v#{short_version}",
    body: "release v#{short_version}"
  )

  deliver(
    submit_for_review: false,
    skip_binary_upload: true,
    skip_screenshots: true,
    force: true
  )
end

自動化によって得られたもの

 年間7人日程度*4の工数の削減に寄与と推定しています。2019年の実績として57回のリリースを行いました。1回のリリースに関わる作業として週あたり概算累計1時間程度と計算しました。

 また、ついうっかり作業を忘れてしまって、同僚につつかれるといったことが無くなりました。 リリースに関する一連の作業を円滑に行うという心理面のプレッシャーが減り、結果としてコードに集中して向き合う時間が増えました。

We are hiring!

 ミラティブでは、このように開発フローの効率化なども得意な、エンジニアを募集しています!副業や体験入社も行っておりますので是非遊びに来てください。

www.mirrativ.co.jp

 うちの会社では、こういうことをやっているぞーということをはてブのコメントで残してもらえると嬉しいです!

*1:iOS向けのRTMPストリーミングライブラリー

*2:こちらのコードは、ご自由にお使いください

*3:git-flow上では、masterブランチへマージした時点でtag付けを行いますが、releaseブランチへtag付けを行う運用を行なっています。

*4:57 * 1 = 57時間 = 約7人日

【iOS】ReactorKitの導入とアプリのFlux化

こんにちは。 iOS エンジニアの千吉良(ちぎら)です。

今回は iOS アプリの設計をサポートするフレームワークとして ReactorKit を導入した話をします。

動機

Mirrativ の iOS アプリは元々「1ViewControllerあたり1Storyboard」という構成で作られていました。

ViewController内の実装に関しては特に実装方針を定めていませんでしたが、多くの方が実装に関わっていくにつれて、実装方針が決まっていないことは、新しく開発に参加される方の戸惑いや、特殊なケースに特化した独自の設計が導入されていく危険性につながるのではないかという懸念が出てきました。メインの課題はそれらの懸念を解消することなのですが、設計を決めるにあたって、いくつかの前提がありました。

  • 機能開発を並行で進めたいので、部分的に適用できるものにしたい
  • モデル層は型安全な形式に移行しているので、モデル層の構成に密に依存しないようにしたい
  • AndroidにFluxを導入しそうな機運があり、思想は近いものにしたい

Clean Architecture や MVVM, VIPER など様々なアーキテクチャがありますが、上記の前提と懸念の解消のために、ReactorKit を設計をサポートするフレームワークとして選びました。

ReactorKit とは

ReactorKit

ReactorKit is a framework for a reactive and unidirectional Swift application architecture. This repository introduces the basic concept of ReactorKit and describes how to build an application using ReactorKit.

...

ReactorKit is a combination of Flux and Reactive Programming.

上記の README から引用した文章から分かるように、ReactorKit はリアクティブかつ単一方向のデータフローの為のフレームワークです。Design Goal として、以下の3つが挙げられています。

  • Testability
  • Start Small
  • Less Typing

Small Start, Less Typing は、機能開発と並行で設計を導入していく上で魅力的な要素でした。特に既存のプロダクトに新たに設計を導入する場合は、適度な記述量で精神的な負担が少ないことも、無理なく開発を進めていく上で大事な要素だと思います。

以下に ReactorKit のコンセプトの概要図を引用します。

f:id:naru-jpn:20200131142801p:plain
Basic Concept of ReactorKit

ReactorKit は大きく View と、対となる Reactor という層から構成されます。

図に書かれている View は、プログラム上では UIViewControllerUITableViewCell に相当する単位です。既存のプログラムが「1ViewControllerあたり1Storyboard」という粒度で構成されている為、その粒度も変更する必要はなく、新しく作る ViewController から ReactorKit に準拠していけばいいという非常にお手軽な状況を作ることができました。

ActionState は、ViewReactor のコミュニケーションの為に使用される要素ですが、それらは Reactor ごとに定義することになるので、既存のモデル層の作りにそれほど依存せずに実装していくことができます。

つまり、上に挙げた

  • 機能開発を並行で進めたいので、部分的に適用できるものにしたい
  • モデル層は型安全な形式に移行しているので、モデル層の構成に密に依存しないようにしたい
  • AndroidにFluxを導入しそうな機運があり、思想は近いものにしたい

を全て満たしてくれます。

ReactorKit と Flux

f:id:naru-jpn:20200131145826p:plain
Communication between Reactor and View

ReactorKit には Reactor という UI とは独立した層が存在しています。 ViewAction を発行し、 Reactor が内部で処理をして State を変化させ、 State の変化を View が受け取るという流れをフレームワークでサポートすることで、単一方向のデータフローを実現しています。

ActionMutation は enum で定義され、簡素で扱いやすい形式で表現ができます。下記のコードは README にあるサンプルコードから持ってきたものですが、シンプルな定義のReactorでうまく単一方向のデータフローがサポートされていることがわかると思います。

class ProfileViewReactor: Reactor {
  // Viewから渡されるActionはenumで定義する. Associated Valueをパラメータとして利用している.
  enum Action {
    case refreshFollowingStatus(Int)
    case follow(Int)
  }

  // Stateを変化させるMutationもenumで定義する.
  enum Mutation {
    case setFollowing(Bool)
  }

  // View側でstateの変化に応じてUIを更新している.
  struct State {
    var isFollowing: Bool = false
  }

  let initialState: State = State()

  // Action → Mutation
  func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case let .refreshFollowingStatus(userID): // receive an action
      return UserAPI.isFollowing(userID) // create an API stream
        .map { (isFollowing: Bool) -> Mutation in
          return Mutation.setFollowing(isFollowing) // convert to Mutation stream
      }
  ...
  }

  // Mutation → State
  func reduce(state: State, mutation: Mutation) -> State {
    var state = state // create a copy of the old state
    switch mutation {
    case let .setFollowing(isFollowing):
      state.isFollowing = isFollowing // manipulate the state, creating a new state
      return state // return the new state
    }
  }
}

チームへの共有と浸透

新しい設計をいきなり導入することは難しいので、3ヶ月の猶予を持って「新規で作成する画面はすべて新しい設計に準拠している」ことを目指しました。僕自身もReactorKitを実務で使用するのは初めてだったので、まずは既存のプロジェクトに導入をしてリファレンス実装を作るところからはじめました。浸透までは、大まかには以下のような流れで行いました。

  • 1ヶ月目
    • 簡単な画面をReactorKitの新設計に書き換え、実装する際の参考にしてもらう為のリファレンス実装とする
  • 2ヶ月目
    • リファレンス実装のコードを実例として添えながら、新設計の思想やルールなどを簡単にまとめたドキュメントを作成する
  • 3ヶ月目
    • 啓蒙

結果としては、3ヶ月経過時点で「新規で作成する画面はすべて新しい設計に準拠している」状態になり、新しく関わっていただく方にも迷いなく設計方針を伝えられる環境ができました。

また、XcodeのテンプレートにReactorKitの為のテンプレートを追加して、新規クラス作成時の負担を軽減しています。

f:id:naru-jpn:20200204214918p:plain
1. ReactorKit用のテンプレートを選択して

f:id:naru-jpn:20200204214945p:plain
2. 名前を入力すると

f:id:naru-jpn:20200204215330p:plain
3. ViewControllerとReactorクラスが生成されます

頭を使わない作業をテンプレートにすることで少しだけ楽ができるようになりました。

まとめ

今回は、MirrativのiOSアプリにReactorKitを導入したという話をしました。これまではViewControllerにすべてのコードが書かれていて非常に自由度が大きく、人によって実装に差があり、実装方針もその都度考えていましたが、方針が定まったことで実装やコミュニケーションのコストを減らすことができました。今後もより楽が出るように改善を進めていきたいと思います。

We are hiring!

Mirrativ では一緒に開発してくれるエンジニアを募集しています!

体験入社や副業も大歓迎なのでお気軽にどうぞ!

www.mirrativ.co.jp

2/20 にCTOが登壇するミートアップも開催するそうです!お気軽に遊びにきてください!

https://meety.net/group_talks/158

ミラティブ エンジニアチーム四季報(創刊号)

こんにちは Mirrativ CTOの夏です。

現在、ミラティブでは事業部単位でチームや目標を管理しており、エンジニアが所属するチームとして以下の6つがあります。今回はこのうち、エンジニアチームについて、2019年度に行ってきた取り組みの振り返りをしたいと思います。

  • ライブプラットフォームチーム
    • ユーザの定着を追う
  • マーケ連携チーム
    • ユーザの新規獲得を追う
  • エモモチーム
    • 3Dアバターであるエモモを使った新体験の創出・基礎体験の向上を追う
  • ストリーミング改善チーム
    • モバイル端末でのライブストリーミングの配信・視聴の品質改善を追う
  • インフラチーム
    • クラウド上での安定したインフラ基盤の設計・構築を追う
  • エンジニアチーム
    • お問い合わせ調査、不具合・障害の再発防止、開発体験の向上を追う
  • AI技術部
    • コミュニティやストリーミングとAI活用の可能性を追う

f:id:hottestseason:20200124110555p:plain
毎週定例で振り返りを行っており、Confluenceに議事録を残しています

4~6月

全プラットフォームで負債の洗い出し

当時、まだAndroid4やiOS10系のサポートを行っており、シェアが少ない古いOSのために開発工数・QA工数が使われていました。そこで、コミュニティチームを巻き込んで、終了するとどういうことが起きるのか含めて、上手くユーザに説明できるように、コミュニケーション方法・時期について練りながら、古いOSのサポートを終了するなどの対応を行いました。

また、会社設立当初は、各プラットフォームそれぞれ1人で開発している状態で、短期的なリリーススケジュールを優先してきた結果、状態管理が複雑化し、新規機能追加時やバグフィックス時に該当箇所以外の理解や暗黙的な知識が求められていました。

その結果、副業や業務委託の方が入っても、なかなか生産性がスケールしなかったり、リモートで稼働している人にとって、キャッチアップに時間がかかる状態が続いていました。

そこで、見通しの悪さの原因の1つである、最強のBaseクラスの再設計を行ったり、不要になったライブラリの廃止(AndroidAnnotationなど)、ディレクトリ構造の見直し、Fluxの導入、分析ログの送信機構の改善、Lintの改善などを行ってきました。

tech.mirrativ.stream

これにより、開発体制がスケールする仕組みに向き合う土壌ができ、開発体験の向上戦略が整ったかなと思います。

リリースフローの整備

また、今まで、開発が完了した修正からアドホックに検証・リリース作業を行ってきたのですが、QAの日程調整やリリース作業の煩雑さを抑えつつ、毎週アプリの更新を行えるようにするために、以下のようなリリースフローを整備しました。

  1. 機能改修などのPRは基本的に金曜日までにQA済みの状態でdevelopへmerge
  2. 日曜日にbotにより自動でリリースブランチとリリースビルドを作成
  3. 月曜日・火曜日に本番環境でリグレッションテスト
  4. 火曜日の夕方にアプリを申請し、審査が通り次第(最速で水曜日の朝)にリリース

7~9月

🚑119番の開始

4~6月を通して、開発体験の向上のためのチームづくりは出来たものの、反省点としては、PMの熱量に負けてプロダクト側の開発を優先してしまい、なかなか開発体験向上のための時間をとれずにいました。

そこで7月からは新たに119番なるものを組成し、エンジニアが毎週日替わりで担当し、その日の担当者はプロダクト開発を行わない曜日としました。(カレンダー・ガントチャート上で抑えてしまいます)

f:id:hottestseason:20200124112944p:plain

行う業務としては

  1. お問い合わせ1次調査 & 対応
  2. エラー、クラッシュ、障害、その他不具合対応
  3. パフォーマンス改善、障害再発防止対応
  4. エンジニア以外からの細かい依頼タスク
  5. PRレビュー、開発体験向上、フルスタック化、その他

などがあります。

週1日プロダクト開発できないとリリースサイクルが遅くなる恐れがありましたが、もともと属人的に依頼されていたタスクを当番制にすることで、残りの4日間はメリハリを持ってプロダクト開発に専念できたり、普段扱わない領域のコードを触る癖ができたかなと思っています。

特に、お問い合わせ対応に関しては、Backlogで管理することで、1営業日以内に対応完了できているかどうかを意識し、お問い合わせが放置されない体制作りや、そもそもお問い合わせが来ない(= ユーザが困らない)ために、どう再発防止すべきか、どうアプリのUXを実現すべきかなどをエンジニア自身が意識できるようになったかなと思っています。

新アーキテクチャのリファレンス実装の整備

前期での改善をさらに進め、7~9月はiOS・Android側はFlux化、サーバ側はAPI仕様のOpenAPI(Swagger)化とClean Architecture化を進めてきました。

もともとミラティブではMarkdown形式でAPI仕様を記述できるAPI Blueprintを採用しておりました。しかし、API仕様を形骸化させず、実装との差分をゼロに維持するために、自動テスト時や開発時には自動的にパラメータやレスポンスのValidationを行おうとすると、MarkdownよりもJSONやYAML形式で記述できるOpenAPIの方が仕組み化しやすいと思い、API仕様をOpenAPI化することを決意しました。

現在では移行が済んでいないブラックリスト上APIのエンドポイント以外は全て、仕様と実装に差異があると自動テスト時や開発時にエラーになるようにしています。 (本番環境では計算コストを意識し、Validationはオフにしている)

iOS・AndroidのFlux化に関しては 千吉良morizooo から、サーバ側のClean Architecture化に関しては、次回、僕の方からまたこのブログで共有したいと思います。

10~12月

リファレンス準拠100%でレビュー工数削減

7~9月の改善でiOS・AndroidのFlux化のリファレンス実装が揃い、副業・業務委託の方々も含めた全員に共有できたこともあり、10~12月はiOSとAndroidのPRはほぼ全てFlux準拠することが出来ました。これに対し、サーバ側のClean Architecture化は、リファレンス実装の整備が12月までずれ込んだこともあり、PRの準拠100%は2020年1月以降の課題となっています。(そもそもサーバ側は自動テストの書きやすさも手伝ってか、レビュー体制が上手く整備されておらず、こちらもまだ課題として残っています)

f:id:hottestseason:20200124122658p:plain
CleanArchitecture移行のためのドキュメント

障害の再発防止

ミラティブではMVP(Minimum Viable Product)を意識した開発を行っており、できるだけ早くユーザに触ってもらってフィードバックをもらうことを大事にしています。そのため、リリース後に時間を置いてから、開発当初に想定していた以上のアクセス数やデータ量になり、障害につながるケースも存在します。

もちろん、最初からスケーラビリティを意識した開発ができるに越したことはありませんが、綿密に流量を想定し、負荷試験を行って、リリース速度を下げるよりか、開発の練度を上げつつ、障害につながる前兆に早めに気づき、放置しない体制をつくろうと思っています。とくに、障害が起きてしまうと、ユーザの体験が悪化するだけでなく、調査・対応・補填含めてエンジニアのリソースがかなり消費されるため、障害の再発防止がエンジニア組織として最優先となっております。

そこで10月からは、以下のような領域毎にそれぞれ担当者をアサインし、彼らに優先順位を付けてタスク管理をしてもらうようにしました。(実作業は担当者が119番の日を使って行いつつ、時と場合に応じて他のエンジニアへアサインします)

  • 障害の再発防止
    • 障害対応した人に振り返りのドキュメントを1週間以内にまとめてもらい、ナレッジを共有することで、チームの練度を上げていく
    • 今後joinする方のためにも、そもそも障害が起きない・前兆を早めに気付ける仕組みづくりをタスクとして優先順位付けしダッシュボード化
  • DB
    • 最重要コンポーネントであり、ここの負荷が上がると、Webも詰まり始めるので、優先順位が高い
    • tcpdumpとpt-query-digestを用いることで、MySQLへの負荷が支配的なSQLを洗い出し、そこから優先順位付けしてダッシュボード化
  • Web
    • UXの改善やインフラコスト💵の削減に貢献
    • 各エンドポイントのパフォーマンスレポート(実行時間やメモリ使用量)から、優先順位付けしてダッシュボード化
  • エラー
    • 既存のエラーが放置され続けると、障害の前兆となるようなエラーが見逃されるため、発見次第潰す体制づくり
    • 新規のエラーが発生した際に、原因となる修正を特定し、担当者に連絡

最後に

何か新しい技術を紹介できているわけではありませんが、僕の方からは当分このような形で、定期的にミラティブでの地に足がついた運用と改善と悩みを共有していこうと思うので、興味がある方や、うちはこうやってるよみたいな話があれば、SNSやはてブにコメントを残したり、オフィスに遊びに来て語らいましょう。2月20日(木)20時〜ミートアップ実施しますので、ご興味のある方は是非お待ちしております!

meety.net

ミラティブでは体験入社や副業も大歓迎なので、興味ある方はぜひ宜しくお願いします!!www.mirrativ.co.jp speakerdeck.com

f:id:hottestseason:20200124175620p:plain

追伸

「CTOからの採用候補者様への手紙」の表紙を、我らがウルトラデザイナのえいじさん に入れて頂きました。こうなると一気に読みたくなりますね。えいじさん、表紙以外も何卒宜しくお願いします🙏 (テックブログ側からも催促していくスタイル)

f:id:hottestseason:20200123214246j:plain

Androidアプリの技術的負債を返済する

Mirrativ Androidエンジニアのmorizoooです。

Mirrativのエンジニアは週4日をプロダクト開発に、週1日を開発体験の向上に時間を割いおり、CTOによる旗振りのもと、エンジニア主導で技術的負債の返済に取り組んでます。
今回は、Androidチームで取り組んだ技術的負債の返済のために行った取り組みについて紹介します。

背景

以前、2019/04に 突撃!!隣のアーキテクチャ - connpass でもお話したのですが、Androidアプリが主に以下の理由でつらい状態なっておりました。

  • ロジックが散在
  • 今ではあまり使われないライブラリへの依存
  • JavaとKotlinの共存

speakerdeck.com

これに対してAndroidチームで以下の取組みを行いました。

  • ActivityとCustomViewの再設計
  • ライブラリの最新化
  • Kotlin化の推進 それぞれのトピックについて説明していきます。

ActivityとCustomViewの再設計

上記スライドにもあるようにMirrativには最凶のBaseクラスが存在しており、全てのActivityがBase継承していました。 このBaseクラスには汎用的なロジックが多数含まれ、実装者がBaseクラス中における処理やデータの流れを理解する必要がありました。 Baseクラスが一概に悪いというわけではないのですが、MirrativのAndroidアプリではこれにより思わぬところでバグが出てしまうなど開発速度を妨げる要因になっていました。

また、CustomView、Helperなどに状態が散り、各々で状態を更新する処理が書かれており、何が正しい状態なのか判断するのが困難になっていました。

やったこと

  • Activityをあえて肥大化させて、BaseやCustomView/Helperから状態と状態を更新するロジックをActivityに集約する。
  • 状態を更新しないViewを表示するためだけのCustomViewを作りActivityから描画処理を分離する

CustomViewはViewの拡張ではなく
FrameLayoutを継承して、以下のようなViewのコンポーネントとして作成しています。

class EventBannerView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
    private val binding = DataBindingUtil.inflate<ViewEventBannerBinding>(
        LayoutInflater.from(getContext()),
        R.layout.view_event_banner,
        this,
        true
    )


   // ActivityからEventBannerを描画するための情報を渡す
    fun bind(iconUrl: String, title:String) {
        GlideApp.with(this)
            .load(iconUrl)
            .into(binding.imageView)
        binding.titleTextView.text =title
    }
}

ライブラリの最新化

MirrativのAndroidアプリはリリースから5年目のため、当時最先端だったライブラリもいまではlegacyになっているものが多数あります。 そのようなライブラリに対しては学習コストが高く、そもそもメンテされていなかったりでライブラリそのものに苦しめられることがありました。

Mirrativでは業務委託や副業の方のサポートを受けながら開発しており、Join後にすぐにバリューを出してもらえる環境を作るために、Android Architecture ComponentsとKotlinで置き換えできるライブラリを削除しました。

やったこと

以下のライブラリを書き換え

Before After
AndroidAnnotations Data Binding Library
DBFlow Room
bolts Coroutines
guava Kotlin標準API

ライブラリの置き換えは変更量が多く検証も大変なのですが、 QAチームのおかげでユーザー影響がなくリリースすることができました。
MirrativのQAフローについて、そのうち書きたいと思ってます。

Kotlin化の推進

もともとは開発効率を考えて新規機能開発についてはKotlin化されていましたが、既存機能改修の場合はJavaのファイルを変更する流れになっていました。
Kotlin⇔Java間のコンテキストスイッチを避けたいのと、非同期の処理をCoroutinesに変更したかったため、Kotlinへの書き換えを行いました。

やったこと

昨年4月にはJavaとKotlinを比べるとKotlinの割合が3割ほどでしたが、今では8割ほどになりました。
現状では普段触るファイルでJavaがでてくることは少なくなりました。

Before(2019/4)

f:id:morizo999:20200111151220p:plain

After(2020/01)

f:id:morizo999:20200111151232p:plain

書き換えている当初は、直接内容を変更してCommitしていましたが、 Git上での変更履歴と手動で変更したところが分からなくなってしまったため、以下の流れで対応していきました。

  1. git mv でjavaからktにRenameしてCommit
  2. Convert Java to Kotlin を実行してCommit
  3. 修正内容をCommit

やってみて

やっていることに目新しいことはないのですが、開発速度は改善していると実感できています。また、開発速度だけでなくUI刷新をスムーズに行えたり、クラッシュの発生率を昨年4月と比べると1/3以下まで低下するなど、プロダクトに対しても直接ポジティブな影響を出せています。

その影響もあると信じているのですが、Google Play ベストオブ 2019で「エンターテイメント部門」大賞を受賞することができました!
技術的負債の返済は開発者のためだけではなく、ユーザーに価値を届けるために続けていきます。

f:id:morizo999:20200114102949p:plain
Google Play Best of 2019

また、上記の改善により、次のステップとしてアーキテクチャの刷新を行うことができました。 次回は現在どのようなアーキテクチャで開発しているか書きたいと思います。

We are hiring!
こんなAndroidチームです!!
チームだからできることを、もっと大きく・速くしていきたい「Androidエンジニア」もりぞーさん ―ミラティブの中の人|ミラティブ|note

一緒に開発してくれるエンジニアを募集中です!! speakerdeck.com

ミラティブにてクラロワAPIを利用したイベントをやってみた

こんにちわ。たてのです。最近ミラティブでクラッシュ・ロワイヤル(以降クラロワ)と連携したイベントを実施しました。このイベントをどのように実現したのかについてレポートします。

クラロワはAPIを公開しており、開発者サイトに登録すれば、ユーザ情報やバトル履歴などを取得することができます。

developer.clashroyale.com

他にもPUBG, CoD, FortniteなどのゲームタイトルはAPIを公開しており、 これらのAPIを使ったサービスやアプリによる独自のエコシステムが構築されています。 (2019年12月現在、クラロワ以外にモバイルプレイヤーの情報が取得できるのはFortniteだけのよう)

ミラティブでは、たくさんの配信者さんがクラロワの配信をしています。そこでクラロワAPIを使って配信中のバトル結果などを利用することでユーザさんがより楽しみながら配信できるイベントを企画し、実施しました。

クラロワAPIについて

クラロワAPIからは次の情報が取得できます。

  • プレイヤー情報
  • バトル履歴情報
  • クラン情報(クランとは複数プレイヤーが所属するチームのことです)

ミラティブはモンストで簡単にマルチをするためにID連携機能を実装しました。 それを利用して、クラロワAPIを実行するのに必要なプレイヤー情報を登録できるようにしました。

mirrativtmbr.tumblr.com

プレイヤー情報を入力してID連携すると、ミラティブのサーバはクラロワAPIから次の情報を取得し、DBに保存します。

  • クラロワのプレイヤー名
  • 所属クランのID
  • 最高トロフィー
  • キングレベル

最高トロフィーやキングレベルは、ミラティブの配信一覧でも表示され、視聴者が配信者に興味をもつきっかけとして機能しています。

f:id:hirota982:20191212114104p:plain
ミラティブトップの配信一覧

取得したプレイヤー情報からクランIDが得られるので、次のクラン情報を取得しDBに保存します。

  • クラン名
  • 所属するプレイヤー一覧

また、配信時に定期的にバトル履歴を取得し次の情報をDBに保存します。

  • 勝敗
  • バトルの種類
  • 変化したトロフィー数

クラロワバトルイベント

上記の情報を使って、個人戦とクラン戦の2種類のイベントを開催しました。

1. 個人戦でのバトルイベント

きおきおさん応援 みんなで25万トロフィー獲得を目指せ

クラロワで通常バトルであるマルチバトルの勝利数を個人単位で計測し、ランキングを競うというものです。

2. クラン戦でのバトルイベント

www.mirrativ.com

Mirrativ — クラメンを誘ってNo1クランを目指そう🔥クラン対抗ランキング開催⚔️...

クラン対抗のバトルイベントでは、配信の仕組みを考慮して、クランメンバーがそのメンバーの配信でコメントすることでスコアが伸びるという仕組みを導入しました。

f:id:hirota982:20191212114458p:plain
クランの獲得ポイント

イベントを開始すると、多くのクラロワプレイヤーの配信者さんが参加してくれて、クランのスコアを伸ばしていきました。 そうしてランキングの上位のスコアが伸びてくると、配信者さんが活発なクランに移動したり、ミラティブをやってないプレイヤーを連れてきたりして、盛り上がっていました。

API連携時の注意点とその対策

API連携する際、次のような制約および注意点がありました。

  1. APIのエラー処理と、エラー発生時のリカバリ処理が必要
  2. 応答に時間がかかることがありうるため、同期型での処理が難しい
  3. APIの単位時間あたりリクエスト数が提示されている上限を超えないよう注意が必要
  4. クラロワ側で更新された情報の同期処理
  5. クラロワAPIには認証機能はない

クラロワAPIにはプレイヤーの認証機構がありません。そのため、ミラティブユーザがクラロワプレイヤーを切り替えて配信したり、複数のミラティブユーザが特定のクラロワプレイヤーで配信するといったことが起こりえます(ミラティブユーザとクラロワプレイヤーはN:M)。またクラロワプレイヤーはクランを移動することがあります。それらを踏まえて、スコア計算のため次のルールの設定をしました。

  1. クラロワプレイヤーはいずれか1つのクランに所属する。プレイヤーのスコアはそれが所属する1つのクランのスコアとなる。クランを移動した場合、プレイヤーのスコアは移動先のクランに引き継がれる。
  2. ミラティブユーザはクラロワプレイヤーを切り替えることができる。切り替えるとスコアは引き継がれない。切り戻せばスコアは復元される。

上記を踏まえ次のような設計および実装にしました。

f:id:hirota982:20191212143632p:plain
システム・アーキテクチャ

  1. MirrativアプリからのAPI処理、Web画面表示処理などはWebサーバにて処理
  2. Webサーバは、必要に応じてクラロワのプレイヤー, バトル履歴, クランの情報の取得要求をDaemonサーバに通知
  3. Daemonサーバでクラロワ情報取得要求を処理
  4. DaemonサーバはProxyを経由してクラロワAPIにアクセス
  5. DaemonサーバはクラロワAPIから取得した情報をDBに保存、定期的にクランのスコアを算出
  6. WebサーバはDBに保存された値を適宜取得し、Mirrativアプリに返す

Webサーバはクラロワ情報取得は実行せず、Daemonサーバに取得要求を通知して処理を終了します。Daemonサーバは通知を受けてクラロワ情報を適宜取得し、DBに保存します。上記構成にて同期処理・非同期処理を行うようにしました。

次にデータ構造についてです。

f:id:hirota982:20191212124856p:plain
データ構造

  • userはミラティブユーザのマスタ、user_clashroyale_playerはクラロワプレイヤー情報を保持、clanはクラン情報を保持
  • clashroyale_battle_event がイベントマスタ、user_clashroyale_battle_event_summaryがイベントのユーザ単位のスコアを保持、clashroyale_clan_event_summaryがイベントのクラン単位のスコアを保持
  • user_clashroyale_player にて user_id, player_id の複合キーとすることで、プレイヤーIDの変更に対応
  • プレイヤーがクラン移動したことを検出したら、プレイヤー、クランをそれぞれ更新する。
  • クランに所属するプレイヤーはまるっとclanテーブルに保持。

プレイヤー毎、クラン毎にDBに値を保持しておいて、差分を検出したら情報を取得するよう実装しました。 その際、プレイヤー毎、クラン毎にDaemonサーバが同じ情報単位に対しては一定時間内に一定数以上の同期処理が走らないよう工夫しました。

結果として次の内容を実現しました。

  • クラロワAPIに安定してアクセスできる
  • リクエスト数に応じて単位時間あたりリクエスト数を調整することでクラロワAPIへのリクエスト数上限への対応が可能
  • プレイヤー情報、クラン情報が一定時間内に同期できている状態を維持
  • ユーザがプレイヤーを変更したり、複数のユーザが同じプレイヤーを登録したときに有効データを選別してスコアを算出

ゲーム開発者の皆様へ

イベント期間中はクラロワ配信数が通常時にくらべて倍増するなど、たくさんのユーザさんが楽しめるイベントとなりました。 イベントを通してユーザさん同士が繋がることで、ゲームをより深く長く楽しんでもらえるようになったのではと思っています。 次回以降のイベントでは、クラロワのDeeplinkも連携して、イベント詳細の画面でクランを選択すると、クラロワのクラン詳細の画面が表示される、クラン移動を簡単にする、といった機能も盛り込んでいく予定です。そのほか、引き続きイベントの内容を工夫したり、他のタイトルでも同様の取り組みが実施できるよう事業開発サイドと一緒に試行錯誤をしています。

というわけで、うちのタイトルだとAPIやDeeplinkあるからこういう連携してもらえればユーザさんも楽しめるし、配信も盛り上がるよ!みたいなトピックあれば、ぜひご連絡ください!お待ちしております!

f:id:hirota982:20191212143541p:plain
クラン対抗ランキング結果

We are hiring !

ミラティブでは、ゲームとも連携しながらユーザさんが楽しめるイベントや仕組みづくりを構築するエンジニアを募集中です。 ゲームとこんな風に連携すればもっと楽しめるのに!といったアイディアやパッションをお持ちの方、ご連絡ください!

www.mirrativ.co.jp

【Unity】Mirrativのアバターがなんで動いているのか誰にもわからないので説明する

こんにちは、よこてです。Mirrativ のアバターは Unity で動いているという話をします。Mirrativ は iOS/Android の ライブ配信アプリですが、機能の一つとしてエモモ(アバター)があります。

f:id:n0mimono:20191203204328p:plain

これは Unity で動いているのですが Mirrativ そのものはネイティブのモバイルアプリです。意味がわかりませんね。具体的には

f:id:n0mimono:20191203204412p:plain

オレンジの部分がネイティブで実装されていて、青い部分がUnityで実装されています。わかりにくいですね。要するに 基本的にはネイティブ実装されていて、アバターの部分だけがUnityで実装されています

このように Mirrativ は ネイティブ実装とUnity実装のハイブリッド構成 になっています。これは歴史的経緯があるのですが、Mirrativ はモバイルのライブ配信サービスとして開発され運用されてきました。最初はアバターという機能はなかったため、普通のiOS/Androidのネイティブアプリだったわけです。その後にサービスグロースの過程において Mirrativ にアバターを導入することにしたのですが、 Mirrativ の既存の資産を活かして市場検証を最速で回したいというモチベーションがありました。このような背景があり、 native app + Unity という構成の導入が検討されました。

ネイティブアプリの資産を活かしつつ Unity を利用する方法はいくつか考えられますが、その一つに WebGLを使う方法があります。アバターとしての機能は WebGL という形で出力して、アプリ側からは WebView 経由で表示させるという方法です。Mirrativ のアバター導入検討時の最初期にはこれを実験したのですが、パフォーマンス上の問題があり断念しました。

今 Mirrativ では Unity をフレームワークとして扱い、ネイティブに埋め込むという Embedded Unity を採用しています。Unity 2019.3 では Unity as a library という形でサポートされる予定ですが、実はそれ以前のバージョンでも同じことができるのです。

Embedding Unity

Unity をネイティブアプリに組み込むにあたり考えるべきことは

  1. Unity の view の切り出し
  2. フレームワーク化(iOS なら .framework 、 Android なら .aar として出力)
  3. ネイティブアプリ(Swift/Kotlin)からの利用

まず重要なのは Unity が出力するプロジェクト は single view application である、ということです。Unity は一つの view を生成して全画面表示しているだけで、この view は iOS では単なるUIView、 Android ではSurfaceViewです。つまり Unity の view はネイティブアプリがもつ他の view と全く同じように扱うことができます。

具体的には、 Unity の iOS ビルド時に export される Xcode プロジェクトは

  • main()が Unity のエンジンを起動、またUIApplicationMain()の呼び出し
  • UnityAppControllerUIApplicationDelegateを実装
  • UnityGetGLView()UIViewを取得

となっていて

  • Unity が通常実行するmain()の中身をネイティブ側のコードから実行(かつUIApplicationMain()を呼ばない)
  • UnityAppControllerをネイティブ側のapplicationに追加
  • UnityGetGLView()で得られる view をネイティブ側のもつ view にinsertSubview

とすれば Unity の view をネイティブな iOS アプリ上で動作させることができます。

ちなみに

int main(int argc, char* argv[])
{
    UnityInitStartupTime();
    @autoreleasepool
    {
        UnityInitTrampoline();
        UnityInitRuntime(argc, argv);

        RegisterMonoModules();
        RegisterFeatures();

        std::signal(SIGPIPE, SIG_IGN);

        UIApplicationMain(argc, argv, nil, [NSString stringWithUTF8String: AppControllerClassName]);
    }

    return 0;
}

Unity のmain()はこんな感じで実装されています。

iOS

ビルド

iOS のフレームワーク化はjiulongw/swift-unityの実装がほぼすべてなのですが簡単に説明していきます。

Unity が export する Xcode のプロジェクトはこのような構成になります。この中で特に重要なのは次の3つで

  • Classes
    • Unity のエンジンに相当
    • Classes/UnityAppController.mmUIApplicationDelegate相当
    • Classes/main.mmが entry point 相当
    • これを外部から利用できる形にコードを修正する
  • Libraries
    • 主に .NET の周辺
  • Data
    • アプリに embed されるデータ群

基本的には bridging header を追加してこれらを framework としてビルドするだけです。 .framework が得られるため、他の iOS アプリからフレームワークとして利用することができます。

ネイティブからの利用

ネイティブ側からの利用については、特に重要な部分を抜粋すると

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    @objc var currentUnityController: UnityAppController!

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {        
        unity_init(CommandLine.argc, CommandLine.unsafeArgv)
        
        currentUnityController = UnityAppController()
        currentUnityController.application(application, didFinishLaunchingWithOptions: launchOptions)
        
        currentUnityController.applicationDidBecomeActive(application!)
        currentUnityController.applicationWillResignActive(application!)

        return true
    }

    func applicationWillResignActive(_ application: UIApplication) {
        currentUnityController.applicationWillResignActive(application)
    }
    
    func applicationDidBecomeActive(_ application: UIApplication) {
        currentUnityController.applicationDidBecomeActive(application)
    }
}

unity_init()main()の置き換えになります。unity_init()でエンジンを起動して、UnityAppControllerUIApplicationDelegateとして登録しています。applicationDidBecomeActiveを一度呼び出しているのは、UnityAppControllerapplicationDidBecomeActive時点でグラフィックの初期化(= view の初期化)を行っているためで、明示的にこれを実行しています。

また、view の利用は

class ViewController: UIViewController {
    func showUnitySubView() {
        if let unityView = UnityGetGLView() {
            view?.insertSubview(unityView, at: 0)
            
            unityView.translatesAutoresizingMaskIntoConstraints = false
            let views = ["view": unityView]
            let w = NSLayoutConstraint.constraints(withVisualFormat: "|-0-[view]-0-|", options: [], metrics: nil, views: views)
            let h = NSLayoutConstraint.constraints(withVisualFormat: "V:|-75-[view]-0-|", options: [], metrics: nil, views: views)
            view.addConstraints(w + h)
        }
    }
}

適当なUIViewControllerinsertSubviewすることを考えます。UnityGetGLView()でもらえるUIViewinsertSubviewするだけです。実際にはapplicationDidBecomeActiveから呼び出されるグラフィックの初期化を待った上でshowUnitySubViewを呼び出す必要があります。

メッセージング

ネイティブから Unity に対してはSendMessageを利用できます。

void UnitySendMessage(const char* obj, const char* method, const char* msg);

Unity からネイティブに対しては native plugin を利用できます。文字列を渡すだけなら

#ifndef UnityPlayerToIOS_h
#define UnityPlayerToIOS_h

@protocol UnityCallback <NSObject>
@optional
@required
- (void)receiveMessage: (NSString *)msg;
@end

@interface UnityPlayerToIOS : NSObject
@property (class, nonatomic) id <UnityCallback> receiver;
+ (void)sendMessage: (NSString *)msg;

@property (class, nonatomic) id <UnityCallback> callbackReceiver;
+ (void)sendCallbackMessage: (NSString *)msg;
@end
#endif
#import "UnityPlayerToIOS.h"

static id <UnityCallback> _receiver = nil;

@implementation UnityPlayerToIOS
+ (id <UnityCallback>) receiver {
    return _receiver;
}
+ (void)setReceiver: (id <UnityCallback>)receiver {
    _receiver = receiver;
}
+ (void)sendMessage: (NSString *)msg {
    if (_receiver) {
        [_receiver receiveMessage:msg];
    }
}
@end

extern "C" {
    void sendMessage(const char *msg) {
        [UnityPlayerToIOS sendMessage:[NSString stringWithCString: msg encoding:NSUTF8StringEncoding]];
    }
}

Unity からは文字列を流し込み、ネイティブ側はコールバックを受け取るようにします。

これら2つで双方向にテキストを流せるため、ネイティブからの呼び出しという形で Unity 側のAPIを構築することができます。

Android

ビルド

こちらの記事がほぼすべてになります。Android のライブラリ作成は iOS と比べて簡単で、 AndroidManifest.xml と build.gradle を書き換えるだけでライブラリを作ることができます。

AndroidManifest.xml から applicationタグを消します。

  <!--
  <application android:icon="@mipmap/app_icon" android:label="@string/app_name" android:isGame="true" android:banner="@drawable/app_banner" android:theme="@style/UnityThemeSelector.Translucent">
    ...
  </application>
  -->

build.gradle をlibraryに対応します。

apply plugin: 'com.android.library'

これだけです。あとはメニューからビルドをポチっとするだけで .aar が作成されます。

ネイティブからの利用

Unity のライブラリにはエンジンをラップする形でUnityPlayerという view があり、UnityPlayerActivityがこれをもっています。UnityPlayerを使う最も手っ取り早い方法はUnityPlayerActivityを継承してしまうことで

class UnityActivity : UnityPlayerActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_unity)

        frame.addView(mUnityPlayer)
    }
}

これだけで Unity を埋め込んだ形で使うことができます。ただし、 iOS では view を生成する部分のコードが直接出力されるため view の不透明/半透明を切り替えるのはそれほど難しくありませんが、Android で同じことをやろうとすると多少の工夫が必要になります。UnityPlayerの実体はSurfaceViewですがこれに直接触る手段がないため

        var view = mUnityPlayer.view
        var field = view.javaClass.getDeclaredField("s")
        field.isAccessible = true
        var sf = field.get(view) as SurfaceView
        sf.setZOrderOnTop(true)
        sf.holder.setFormat(PixelFormat.TRANSPARENT)

このようにリフレクションでSurfaceViewを引っ張ってくる必要があります(フィールド名はUnityのバージョンによって変わります)。

メッセージング

iOS と同様にネイティブから Unity に渡すときはSendMessage

UnityPlayer.UnitySendMessage(gameObject, method, msg)

Unity からネイティブにわたすときはコールバックを使います。

public class UnityPlayerToAndroid {
    public interface ICallback {
        public void receiveMessage(String message);
    }

    public static ICallback receiver = null;

    public void sendMessage(String message) {
        if (receiver != null) {
            receiver.receiveMessage(message);
        }
    }

    public static ICallback callbackReceiver = null;

    public void sendCallbackMessage(String message) {
        if (callbackReceiver != null) {
            callbackReceiver.receiveMessage(message);
        }
    }
}

おわりに

だいたいこんな感じで iOS/Android のネイティブアプリに Unity を組み込むことができます。

現実の運用ではCDが必要になりますが、 Cloud Buildのような便利ツールはないので自前で作ります。 正直、ビルドする方法探すよりこっちのほうが骨が折れます。 またフレームワーク単体だとUnityエディタ上で動作確認が一切行えないため、開発用のシーンを別途作ってあるのですが、フレームワーク自体に操作するためのUIがないためこちらもそれなりに作り込む必要があります。

Unity をネイティブアプリのフレームワークとして扱うことが有用なケースとしては、アプリのコアな機能がネイティブ側にある場合で 3D を扱う必要がある場合には検討しても良いのではないでしょうか。 3D である程度リッチな機能を高速につくろうとすると Unity が圧倒的に優秀な一方、 Unity はあくまでゲームエンジンなのでネイティブアプリのような構成でつくるのは必ずしも得意というわけではないからです。 Mirrativ はこれに相当します。ただし、開発・運用は複雑になり細かいハマりポイントも多くあるためあまり安易に導入するのは現状としてはオススメできません。

今後の展望としては 2019.3 でサポートされる形への乗り換えを考えています。単純に Unity のバージョンを最新に保ちたいというのが理由ですが、現状 Unity のバージョンを更新するたびに 微妙な不具合が毎回出る (普通にビルドした場合は起きない)というのが観測されていてつらいなーと思っています。そういうわけで、公式がサポートしてくれるのは 仕事がなくなって嬉しい 心強いなーと思っています。

We are hiring!

ミラティブでは Unity を hack しながらエモモを一緒に作ってくれるエンジニアを募集中です! 体験入社という制度もあるのでお気軽にどうぞ!

www.mirrativ.co.jp

www.wantedly.com