Mirrativ tech blog

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

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

【MPTCP】ライブ配信の通信安定化に向けて MultiPath TCP を試験導入している話

こんにちは ハタ です。
今回は Mirrativ の本番サーバの一部に試験導入している
MultiPath TCP (MPTCP) について紹介させていただきたいなと思います。

MultiPath TCP といえば、iOSの Siri で利用していることなどで一時有名になりました
今回紹介するMPTCPも同じ技術を使っており、通信の安定化に向けて取り組んでいる事項の紹介になります

MPTCP の概要と各OSの実装について

f:id:octu0:20191126175452p:plain
MPTCPのイメージ

MultiPath TCP (以降 MPTCP)は、複数の経路を通じて同じホストに対して通信が行えるTCP拡張です。
従来のTCP通信では、単一の通信パスしか使えなかったものが、複数の通信パスを利用できるようになります。
例えばスマートフォンでは 4G 回線と WiFi ネットワークが用意されているため、それぞれから同一のコネクション張り、どちらか片方の経路でコネクションが切れた場合でも、もう片方の経路で通信が維持できる仕組みです。

MPTCPはクライアント側では iOS で利用可能となっていて、残念ながらAndroid での利用はできないようです(いちおう ルート化されたものであればいけそうですがAndroid 4.x なので少し古い)
また、サーバ側では 各種Linuxディストロ 用のKernelを使うことで利用可能です(BSDは FreeBSD のものがありそうです)

今回は iOS 中心に紹介させていただきます。

MultiPath TCP 導入の目的

f:id:octu0:20191125145612g:plain
WiFi <-> 4G の切り替わり

ミラティブではライブ配信の安定化向上を日頃から行っており、MPTCPの試験導入も通信の安定化を目的に検証を進めています。

これまで、「配信が行えない」ことや「配信が止まってしまう」という声を頂いており、様々な理由で配信が正常に行えない事象があることは確認しているのですが
その中の一部の状況を調査してみると、 WiFi と 4G の頻繁な切り替えが発生し配信が不安定になっているという状況などが確認できてきました。

MPTCPの特性上、複数の経路で通信が行えるようになるため、副次的な効果として

  • WiFi <-> 4G が混在する環境のにおいてMPTCPを利用することでスムーズに通信ネットワークの切り替えが行えるようになり、ネットワーク切り替え時による通信断がなくなり配信を継続できるようになるのではないか
  • 上記に伴い WiFi <-> 4G の切替時に発生する通信の切断・接続がなくなり視聴プレーヤー側のリロードがなくなるため視聴体験の向上があるのではないか

などの効果が見込めるものとして検証を進めました。

MPTCPに対応したサーバ

ミラティブでの既存の配信サーバはTCP通信でライブ映像が配信されているため、配信サーバの前段にMPTCPサーバを設置し配信された映像/音声データを中継するように実装しました。

f:id:octu0:20191126175518p:plain
MPTCP サーバの概要

MPTCP は特殊なプロトコルではなく通常の TCP 通信も利用できるため、既存のサーバを MPTCP に対応した kernel 置き換えることも可能だったのですが、下記理由で既存のサーバとは分けてMPTCPのサーバを用意しています。

  • クライアント・サーバ間の通信よりもサーバ・サーバ間の通信は安定しているので、よりユーザさんに近い前段に設置して通信をリレーする
  • 問題がおきたときの切り分けのため
  • 今後の拡張性のため

サーバのOSについては、当時比較的新しい linux kernel 4.19.55 の Ubuntu での実装が利用できたため、Ubuntu 18.04 を選択しています
通信のリレー部分は、アプリケーションレベルでリレーの実装を行いました。(アプリケーションは難しいものではないため今回は割愛します)

また、利用している MPTCP のバージョンは v0.95 または v0.94.0(kernel 4.14.24) を利用しました。

$ sudo dmesg | grep MPTCP
[    0.582045] MPTCP: Stable release v0.95

ちなみに、MPTCPが有効であれば www.multipath-tcp.orgcurl でアクセスすると下記のようなメッセージが表示され、有効であることがわかります

$ curl www.multipath-tcp.org
Yay, you are MPTCP-capable! You can now rest in peace.

逆に MPTCP が利用できない場合は下記のように表示されます

$ curl www.multipath-tcp.org
Nay, Nay, Nay, your have an old computer that does not speak MPTCP. Shame on you!

iOS での実装について

iOS の実装ですが、iOS11 から API が公開され利用できるようになりました(WWDC 2017、iOS 12からはURLSession以外でも Network Framework で利用可能となっています)
また、MPTCPの動作確認をする際は Multipath entitlements が有効である必要があります。

Network frameworkを使っているため iOS12 以降となってしまいますが、multipathの通信が行えているかの確認は下記のように動作確認することができます
(www.multipath-tcp.orgcurl の User-Agent に偽装することで、サーバ上から確認したときと同じく Yay, you are MPTCP-capable! かどうかがわかるメッセージが取得できます)

import Network

let queue = DispatchQueue.global()
let port = NWEndpoint.Port(rawValue: 80)
let parameter = NWParameters.tcp
parameter.multipathServiceType = .handover
let connection = NWConnection(host: "www.multipath-tcp.org", port: port!, using: parameter)
connection.stateUpdateHandler = { state in
  if state == .ready {
    let message = "GET / HTTP/1.1\nHost: www.multipath-tcp.org\nUser-Agent: curl/7.54.0\n\n"
    let data = message.data(using: .utf8)!
    connection.send(content: data, completion: .contentProcessed { error in
      if let error = error {
        print("\(error)")
      }
    })
  }
}
connection.receive(minimumIncompleteLength: 0, maximumLength: 1024 * 1024 * 10) { (data, _, _, error) in
  if let data = data {
    let text = String(data: data, encoding: .utf8)!
    print("message = \(text)")
  } else {
    print("\(#function), err")
  }
}
connection.start(queue: queue)

Network Framework でのTCP通信のプログラムについては今回は割愛しますが、ライブ配信の通信部分はNetwork.Frameworkに切り替えて実装しました

NWParameters.MultipathServiceType

MPTCP の挙動を設定するパラメータとして NWParameters.MultipathServiceType には、handover / interactive / aggregate / disabled の4つの挙動を設定することができるようです

disabled を除くそれぞれのモードでの挙動は、ドキュメントを確認していただきたいのですが、MPTCP を利用して複数のインタフェースで通信できるモードは aggregate だけとなっていて、開発者モードでしか使えないようになっているようです

f:id:octu0:20191126001716p:plain
設定 → デベロッパ → NETWORKINGにあるチェックを有効にしないと aggregate は使えなかった

ミラティブでは、QAを通じて 一番違和感が少なくネットワークの切り替えができた handover モードを利用しています、
環境や状況によるかと思いますが interactive モードではどうしても 4G/LTE の接続にコネクションが残りがちのようで、WiFi 復帰時の体感がより違和感の少ない handover モード を利用することにしました。

また、iOS9 から導入された WiFi アシストの挙動によっては MPTCP が無効になるパターンもあるようです、こちらも今回は細かく取り上げないのでドキュメントを参照してください

MPTCPの通信の中身

実際に MPTCP の通信が行えているかは、MPTCPサーバ上で tcpdump を使い TCP オプションフィールドで確認できます。

07:27:20.757072 IP C > S: Flags [S], seq 3481042045, win 65535,
                          options [mss 1460,nop,wscale 6,mptcp capable {0x19fc1a796a193012},nop,nop,TS val 134990811 ecr 0,sackOK,eol], length 0
07:27:20.757150 IP S > C: Flags [S.], seq 999693183, ack 3481042046, win 27760,
                          options [mss 1420,sackOK,TS val 1195317422 ecr 134990811,nop,wscale 7,mptcp capable csum {0xbec75f1aee94529}], length 0

(CはClient、SはServerになっています、また一部見やすいように改行しています)

SYN および SYN/ACK 時に mptcp capable のオプションが付いていて通信が開始されることがわかります。
MPTCPの通信は通信の中身そのものよりも、実際に通信の切り替えの挙動を見ていただくのがわかりやすいので、MPTCPを有効にした配信の録画を見ていただければなと思います。

f:id:octu0:20191126170347g:plain
配信画面の録画(アーカイブ)のため画面そのものを20倍速にしたもので一部カットしています(画質悪くすいません...)ちなみに表示しているページは こちら です

ステータスが変化した場合でも配信が止まらずに映像が届いていることがわかり、視聴しているプレーヤーでも映像の再取得がされることなく、途切れずに配信がみれました

導入結果

その後さらに色々なパターンでの配信の検証を進めたところ

  • iOS 12/iOS 13における配信中のリトライ(再接続処理)回数が0になる ことがわかりました
  • 一方で視聴体験の向上はMPTCPあり/なしに関わらず解像度を上げる必要があることがわかりました

MPTCPを使うことで通常の配信に比べて通信の安定化に寄与していることがわかったものの 視聴体験の向上については、リロード回数を指標値にしようと思っていたものの有意な差があるわけではなさそうで、体験が悪化している減少について解像度を上げる必要がありそうでした。

とはいえ、MPTCPには期待していたどおりの効果があり、評価できそうで今後の全体に展開するかの検討材料にできそうです

その他今後の展望など

MPTCP の実装は Network.Framework の登場により実装が楽になったものの、インフラ基盤での高可用性をもった構成はもう少しノウハウが必要そうです、特にLBとの相性を考えたときに こちらの記事 にあるようにひと工夫加えてあげるとより可用性を高められそうです
今回のバージョンではまだここまでは出来ていないため今後バージョンアップを重ねたいと思っています

他にも配信だけではなく、視聴側でも MPTCP を利用することで、より途切れにくく配信を見ることができるのでより良いUXを作ることができる可能性もありそうなので、調査を深めても良さそうだなと思っています

We are hiring!

MPTCP などの技術を使ってライブ配信の安定化を一緒に作ってくれるエンジニアを募集中です! 体験入社という制度もあるのでお気軽にどうぞ!

www.mirrativ.co.jp

【保存版】スタートアップがユーザビリティテストを3日ですべて終わらす方法

こんにちは、分析チームの坂本です。 テックブログ3回めの登場です。

ミラティブの分析チームは、もちろんBigQueryや機械学習を用いた定量的なデータ分析も行いますが、ユーザーを理解するための定性分析も行う「分析チーム」であることを掲げています。

※最近では、データアナリストとUXリサーチャーの境界が溶けつつあるという議論があると思いますが、弊社もその流れを採用しています。

突然ですが、ユーザビリティテスト = お金と時間がかかる という認識の方もいらっしゃると思います。 ミラティブでは、ユーザビリティテストをやろう!と言ってから3日間&謝礼実費のみでユーザビリティテストが完了しました(被験者3名)。 どんなことをやったのかという内容を書きながら、ハマりどころををシェアしようと思います。

ユーザビリティテストの一般論

まずは、ユーザビリティテストってなんやねんという話を書いて行きます。といいつつ、ここは巨人の肩に立たせていただきます(笑)。

ユーザビリティテストとはなんなのか?だったり、なんの目的で実施するのかなどは、グッドパッチさんの下記のブログが詳しいので、ぜひ読んでみてください。

goodpatch.com

ミラティブでは、ユーザビリティテストを実施する前に、ミラティブで言うとどういうことなの?的な記事をコンフルにしたためました。

f:id:sakamoto10423:20190714183346p:plain
社内に投稿したユーザビリティテストの記事

ミラティブでユーザビリティテストが必要だった背景

ユーザビリティテストってどれくらいの頻度で実施したら良いのか?という質問がよくあります。理想的には週一くらいのスパンで定期的にやるのが良いと思います。

しかし、スタートアップにはそんな時間も余裕もないわけです。オススメとしては、

  • 大きなリニューアル前に改善の方向性を確かめるために実施
  • 大きなリニューアルのプロトができたときに、プロトタイプで実施

です。反対に、小さな変更であれば、ABテストやデータ分析で検証することが望ましいと考えています。大きな変更は、画面の構造や印象が大きく変わると同時に、コードの変更も大きく入ります。なので、小さく失敗するためにユーザビリティテストを行うのがおすすめです。大きくリリースして、「やっぱり仮説が間違っていた」となると、相当のコードが無駄になります。

Mirrativ は、直近1年間で様々な機能追加をしてきました。エモモ・ガチャ・ギフト・コイン・オーブ・ジェスチャーなどなど、は直近1年間で追加された機能です。それらがスモールテストとともに導入されたこともあり、UI/UX上わかりづらいところが散見されるようになってきました。

f:id:sakamoto10423:20190714200502p:plain
UI/UX上わかりづらいメニューの例

たとえば、上記であればヘッダメニューの中に

  • 配信一覧のフィルタを変更する、タブ切り替え
  • 自身のエモモ(アバター)を切り替える

という機能が混在しています。 現在ミラティブでは、このような使いづらい箇所を大きく手直しするプロジェクトが始まっています。そのインプットの一環として、ユーザビリティテストを行ったというのが背景です。

事前準備のTips

ここからがこのブログの本編パートになります。もっといいやり方あるよーってあれば、ぜひTwitterやはてブなどでコメントいただけると嬉しいです。

中継用のカメラはMacのカメラで十分

まず、ユーザビリティテストでほぼ必須なのが中継用のカメラです。被験者の手元を写しながら、その映像を別室に中継するためです。

PCサービスの場合は、テレビ会議システムの画面共有機能で十分だと思います。しかし、Mirrativ はモバイルアプリなので、被験者の手の動きを撮影することが必須です。そのため、何かしらの撮影機材が必要でした。

f:id:sakamoto10423:20190722111801p:plain
Macのカメラでユーザビリティテストを行う

いろいろと試した結果、上記のようにMacを後ろから抱きかかえるようなスタイルで撮影したところ、うまく撮ることができました。その映像をテレビに映したものを写真に撮ったものが下記です。もろもろ経由しているので、下記の写真の解像度はそんなに高くないのですが、テストを行うのに必要十分な画質でした。

f:id:sakamoto10423:20190721130748p:plain
Macで動画撮影した映像をテレビに映す様子

カメラのセッティング時に注意すること

カメラが安価で済んだぞ!と意気揚々としていたのですが、実際のテストを行ったときにちょっとしたトラブルがありました。それは、「部屋の調光によっては、蛍光灯の光が画面に反射してしまう」ということです。

f:id:sakamoto10423:20190717014737p:plain
蛍光灯が反射してしまう

我々は最近、自然光あふれる目黒のオフィスに引っ越しを行いましたが、ユーザビリティテストを行ったのは渋谷の旧オフィスの窓がない会議室でした。そのため、蛍光灯の明かりがばっちりスマートフォンに映ってしまいました。我々は一度、被験者さんに承諾をとった上で、(電気を消して)真っ暗な部屋で実施していただきました。

今後の学びとして、蛍光灯にカバーがついている部屋でテストを実施する・自然光が入ってくる部屋で実施するなどの工夫が必要だと感じました。

配信はGoogleHangoutがよかった

では、次はその映像を中継する方法です。Macのカメラで撮影しているので、ビデオ会議システムに通すと楽ですね。そこで下記の条件を満たすビデオ会議システムを探しました。

  • Macのカメラから受けた映像を共有(配信)することができる
  • できれば高画質
  • 40分以上共有(配信)を行うことができる
  • 録画をすることができる
  • 追加料金なし

zoomなどのオンラインミーティングなどをいろいろと検討しましたが、最終的にはGoogleHangout(Google Meet)を選択しました。ただ、Google Meetで録画をするには、G Suite Enterprise (1ユーザー3,000円/月) のプランの契約が必要だったので、Google Meetで録画することは諦めました。

ちなみに今回は、Google Meetで共有した映像をQuickTimeで画面録画するという手法を行ってみましたが、1ファイルが20GBくらいになり、あとあと管理に困りました。これは要改善ポイントです。

被験者の集め方

では、被験者はどうやって集めるのが早いでしょうか?民間のリサーチ会社さんにお願いすると、やはり(やり取りも含めて)1週間程度かかります。そこで、ミラティブでは社員の友人の方に協力いただくことにしました。

そこで、「募集をするなら黄色に黒!」という定番をリスペクトさせていただき、募集画像をつくり社内のSlackに投下しました。するとその日中に目的にあった被験者の方を3名集めることが出来ました。

f:id:sakamoto10423:20190721132735p:plain
ユーザビリティテストの被験者を社内募集

ミラティブ社では、ユーザビリティテストを行うのがはじめて(少なくとも会社独立後ははじめて)だったので、ユーザビリティテストの意義や成果をメンバーが熟知している状態ではありませんでした。そのため、なるべくみなさまに協力いただけるように、目立つ方法で集客しました。

当日の事前注意 & アイスブレイクの工夫

さて、ここからは当日の話を書いていきます。

ユーザビリティテストは、被験者の方にプロダクトを操作していただくテストです。被験者の方は「自分がテストされている」気持ちになってしまうことがある、と事前に知っていたため、下記のようなスライドを用意し、ご自身のテストではないということをかなり強調しました。

f:id:sakamoto10423:20190721135228p:plain
ユーザビリティテストスライド:はじめに#1

f:id:sakamoto10423:20190721135331p:plain
ユーザビリティテストスライド:はじめに#2

また、アイスブレイクでは「普段どんなゲームをされていますか?」というMirrativ と関係ある内容でアイスブレイク出来たのはよかったです。※例えば、ウイルス対策ソフトなどのユーザビリティテストでは「ウイルス対策って普段されますか?」ってのは本題に近すぎて、アイスブレイクには使いづらい質問だと思います。そういうときは、「オフィスまで迷わずに来れました?」みたいなアイスブレイクが一般的なようです。

当日あたふたしたこと

当日のトラブルというか、あわわわってなったことをいくつか紹介します。Mirrativ ならではのこともありますが、「個々のアプリで考えることがある」という意味で共有しておきます。

配信をして頂く際、やめ時が難しい

f:id:sakamoto10423:20190721140811p:plain
ユーザビリティテストスライド:シナリオ#3

3つ目のタスクとして、上記のようなタスクを行ってもらいました。もう少し具体的に言うと、普段プレイしているゲームを配信してもらうというタスクです。

これはインタビュワーが「では終了してください」と声を掛けるタイミングが難しかったです。というのは、ゲームによっては途中で辞めるとスコアが下がったり、たまたまその時間にオンしているマルチフレンドと協力プレイが始まったり 、そもそもの1試合が長かったり。様々な理由で、ゲームのやめ時が読めません。

パズドラやバンドリ!などは比較的短いほうかなと思いますが、マルチ対戦TPSゲームや麻雀ゲームは1試合が30分程度かかってしまうので切り時が難しいなと思います。

ということで、次回はゲームをプレイされる前に、「今回はテストなので、10分程度で一区切りできるゲームを選択いただけませんか?」とアナウンスするのがよいかなと思いました。

想定外の箇所で詰みが発生する

事前の想定外のところで詰みが発生することがあり、インタビュワーが被験者さんにお声がけすることがありました。その一例を紹介します。

例えばiOSで配信する場合、Mirrativ のコメントや情報(「視聴者が来ました」や「ギフトをもらいました」)はPUSH通知を通じて配信者に知らされます。

f:id:sakamoto10423:20190721143948p:plain
配信者はPUSH通知でMirrativ のコメントなどを知る

ということは、PUSH通知を切っていると、視聴者さんが来たとか、視聴者さんがコメントしたことがゲーム中に全くわからないわけです。そのことにはじめて気づきました。なので、2人めの被験者以降は、配信を始める前にPUSH通知をONにしていただく設定をしていただくことにしました。

当日の様子

f:id:sakamoto10423:20190714182759j:plain
ユーザビリティテスト当日の様子

上記はユーザビリティテスト当日のエンジニアルームです。エンジニアルームにある大きなテレビに大きくテスト中の内容を映し、エンジニア・デザイナー・PM全員で注目して見ていました。やはり、自分たちが作成したプロダクトなので、注目度は高かったです。このように、エンジニアチームを巻き込んでテストを行うのが大切ですね。

得られた成果

3名で実施したユーザビリティテストの結果を資料にまとめました。一部公開しておきます。

f:id:sakamoto10423:20190721150119p:plain
得られた成果スライド

上のような資料にまとめるのも重要ですが、それ以上にエンジニアチーム全員がテストの内容を見ていたことのほうが成果としては大きかったかなと思います。やはり、百聞は一見に如かずですね。

さいごに

ミラティブでは分析チームを募集しています。BigQueryや機械学習を使った分析をすることもあれば、今回のような定性的な分析をしていることもあります!プロダクトに近い分析チームです!

◆アナリストポジション www.wantedly.com

◆分析基盤ポジション www.wantedly.com

業務内容が多岐にわたるので、定性分析やったことないわー、とかだったり、定量分析やったことないわーという方も全然OKです!気軽にポチッとエントリーください。

reviewdog x perlcritic x Jenkins で最高の GitHub レビューライフ

ミラティブのサーバーサイドエンジニア、ハトネコエです!

今日は、GitHub の自動レビューとして reviewdog を導入した話をします。

1. 動機

すでに CTO の夏さんによって、Perl 用の linter である perlcritic が導入されていました。
そして、テストが走る際に perlcritic のチェックもおこなわれ、
指摘箇所があればテストが落ちるようになっていました。

まずは緩めの設定で導入したけれど、perlcritic のチェックをもっと厳しくしたい!
だけど厳しくすると、すでに存在するコード(つまり、プルリクで変更していない部分)が原因で
テストが落ちるようになってしまいます。

残念ながら perlcritic には autofix の機能も無いようで、
設定を変更した後は、人力での修正が終わるまでテストは落ち続けてしまいます。

これでは開発に支障が出るので、
テストでのチェックはやめて、プルリクで変更した部分にだけ指摘がなされる手段が求められました。

2. Danger か reviewdog か

最初に話題に上がったのは Danger でした。

f:id:nekonenene:20190706005348p:plain
CTOの夏さんとの会話(in 私のtimes)

Danger は自動レビューの手段として良いツールです。

Android, iOS, Ruby, JavaScript といった Danger のプラグインが存在するコードベースであれば良い選択でしょう。
しかし今回は Perl。Danger のプラグインは存在しません。
作ってもいいですが、あまり時間をかけたくもありません。

そこで見つけたのが reviewdog です。

reviewdogによるGoのコードレビュー - DeNA Testing Blog

こちらのブログがきっかけになりました。ありがとうございます。

3. reviewdog を選んだ理由

3-1. 比較

両者を比べると以下の違いがありました。

reviewdog Danger
スター数 (2019/07/13) 979 3573
コミット頻度 高い 高い
言語 Go Ruby
コメント 修正すべき行にコメント まとまった1つのコメント
(or 修正すべき行にコメント*1
ドキュメント README.md で充分わかる 情報が散乱していて読みにくい

ドキュメントの読みやすさは主観ですが、
Danger のドキュメントの読みにくさは伝わる……はず……!

reviewdog の方がスター数は少ないですが、
それを補うほど優れた点が複数ありました。

3-2. reviewdog は Golang

まず Go 言語であることに惹かれました。
というのも、最近は bundle install 時に起こるエラーに辟易していて、
できれば Gemfile の管理はしたくないな……と思っていたためです。
bundler のバージョンが v2 になってから、まだ落ち着いていないようにも思えますし。

Go はもう少し単純で、かつ v1.13 でよりモジュール管理が簡潔になる予定です
その上、シングルバイナリでアプリケーションを配布することが可能です。

reviewdog もバイナリを配布していますので、
使用者は Go のインストールをすることなく reviewdog を扱えます。

Danger の場合は ruby(&たいていは rbenv)のインストールが必要になりますが、それらが不要なわけです!
reviewdog のバイナリファイルをダウンロードしてくるだけでいいので、
Docker や Jenkins での実行準備がとても簡単におこなえます。

3-3. reviewdog の errorformat が最高!

もし Danger のプラグインを書くとするなら、GitHub コメントを作成するために、
以下のようにいい感じに lint エラーを整形してあげる必要があります。
https://github.com/loadsmart/danger-android_lint/blob/0.0.7/lib/android_lint/plugin.rb#L134-L157
これはなかなかに面倒です。

一方、reviewdog の場合、
errorformat が内蔵されていることにより、
lint エラーのメッセージ形式を渡してあげれば、あとはよしなに解釈して、修正すべき行にコメントを残してくれます。

reviewdog はとても簡単なのです!

perlcritic のような、Danger のプラグインがない linter を使っているのであれば、
reviewdog はとてもいい選択肢に思えます。

4. reviewdog を使ってみる

reviewdog の使い方については、前述したとおり本家の README.md で十分理解できますが、
ここでもいくらか書いておきます。

4-1. -reporter=github-pr-review

reporter オプションの選択肢は複数ありますが、GitHub を使っているのであれば
github-pr-review reporter を選ぶのが良いでしょう。

まず local reporter は、動作テスト用です。
local reporter の場合のみ、 diff オプションが必要なので注意しましょう。
linter でチェックしたうちの、 diff オプションで渡した部分の結果のみを出力します。

参考 : https://github.com/nekonenene/perlcritic_reviewdog

次に github-pr-check reporter ですが、これは見栄えとしてはいいのですが、
README にある通り、これを動作させるためのサーバーは作者の haya14busa さんのポケットマネーで動かしているので、安定した稼働は保証されていません。

また、アプリ連携によりリポジトリのコード閲覧権限を渡すので、
会社で扱う場合には良くない選択肢でしょう。

reviewdog のアプリ連携画面

というわけで、 github-pr-review reporter を使うことになります。

これの使用にあたっては、 https://github.com/settings/tokens から作成できる
GitHub の Personal access token が必要になります。

公開リポジトリなら public_repo のみにチェック、
非公開リポジトリなら大項目の repo の方にチェックを入れて Token を作成します。

f:id:nekonenene:20190713220213p:plain
公開リポジトリなら public_repo のみにチェック

reviewdog のコメントは、この Personal access token の持ち主からおこなわれる形になるので、
reviewdog 用のアカウントがあると見栄えは良いかもしれません。

reviewdog 用のアカウントを作らないと、自分のプルリクにコメントしまくってる人みたいに…

4-2. 必要な環境変数

-reporter=github-pr-review の使用にあたってはたくさんの環境変数が必要になります。

  • REVIEWDOG_GITHUB_API_TOKEN : 上記 4-1. で説明した Personal access token
  • CI_REPO_OWNER : リポジトリの管理ユーザー名(GitHub ID)
  • CI_REPO_NAME : リポジトリ名
  • CI_PULL_REQUEST : プルリクエスト番号
  • CI_COMMIT : 対象ブランチの最新コミットID( git rev-parse HEAD で取得可能)

サポートされているCI(2019/07/13 現在は Travis CI, Circle CI, GitLabCI)であれば Personal access token だけ渡してあげればいいのですが、
ローカルからの実行や Jenkins からの実行に関しては、これらを全て埋めてあげる必要があることに注意しましょう。

4-3. -efm=%f:%l:%c:%m

efm オプションは大事です。が、
perlcritic の場合は出力形式をいじれるので、どちらかと言えば perlcritic 側の出力形式をいじることが大事になるでしょう。
verbose オプションですね。

%f:%l:%c については、reviewdog がどこにコメントすればいいかを示します。
%m はコメントの内容ですので、ここが工夫のしどころです!

最終的には以下のようになりました。

perlcritic --profile .perlcriticrc --verbose '%f:%l:%c:**%m**, near <code>%r</code>.<br>(Ref: [%p](https://metacpan.org/pod/Perl::Critic::Policy::%p))\n' | reviewdog -efm=%f:%l:%c:%m -name=perlcritic -reporter=github-pr-review

Markdown を使いつつ、バッククォート ` はシェル側で解釈して処理されてしまうので、
HTML タグを使うことで避けています。

指摘内容のリファレンスに簡単に飛べるようにしたのは良い工夫だったと思います。

5. Jenkins で使う際の工夫

上の 4-2. で環境変数をたくさん埋めなければいけないと書きましたが、
Jenkins の場合はどうすればいいのかについて触れておきましょう。

5-1. REVIEWDOG_GITHUB_API_TOKEN

秘匿情報であるトークンをどう扱うか、
ここが頭を悩ませるところだと思います。

Jenkins の Credentials (認証情報)を使います。

<your_jenkins_host>/credentials/store/system/domain/_/newCredentials の URL から、
『Secret text』として API Token を追加します。

f:id:nekonenene:20190713223618p:plain
認証情報の追加

Jenkinsfile からは以下のように扱います。

withCredentials([string(credentialsId: "reviewdog", variable: "githubToken")]) {
  env.REVIEWDOG_GITHUB_API_TOKEN = githubToken
  def reviewdogResult = sh(
    // ※ブログ公開用にコマンドは実際と変えてあります
    script: "perlcritic --profile .perlcriticrc --verbose '%f:%l:%c:**%m**\n' | reviewdog -efm=%f:%l:%c:%m -name=perlcritic -reporter=github-pr-review",
    returnStdout: true
  ).trim()
  env.REVIEWDOG_GITHUB_API_TOKEN = "" // token が環境変数として残るのを避けるため、空文字を再代入(unset は効かない)
  echo "${reviewdogResult}"
}

withCredentials スコープ外では値がマスクされないため、
githubToken を代入した env.REVIEWDOG_GITHUB_API_TOKEN の値を
env.REVIEWDOG_GITHUB_API_TOKEN = "" で初期化しているのがポイントです。

f:id:nekonenene:20190713224534p:plain
Credentials の値がマスクされている様子

これをやらないと、この後ろで sh "env" なんかがあると、トークンの値が平文で表示されてしまいます。

5-2. CI_REPO_OWNER, CI_REPO_NAME

リポジトリオーナー、リポジトリ名が変わることはめったにないので直接書いても良かったのですが、
汎用性を持たせるため Jenkins の環境変数である JOB_NAME を活用しました。

def jobNameArr = env.JOB_NAME.split("/")
env.CI_REPO_OWNER = jobNameArr[0]
env.CI_REPO_NAME = jobNameArr[1]

5-3. CI_PULL_REQUEST

Jenkins の環境変数 CHANGE_ID がプルリクエストの ID ですので、それをそのまま使用しました。

env.CI_PULL_REQUEST = env.CHANGE_ID

なお、プルリクエストに紐付かない Jenkins ビルドでは env.CHANGE_ID の値は null になっています。

5-4. CI_COMMIT

以下のように取得しました。

env.CI_COMMIT = sh(returnStdout: true, script: "git rev-parse origin/${env.BRANCH_NAME}").trim()

単純に git rev-parse HEAD となっていないのには理由があります。

私達は Jenkins のビルド設定に関し、安全のために
『Discover pull requests from origin』の設定で
『Merging the pull request with the current target branch revision』を選んでいます。

f:id:nekonenene:20190713230653p:plain
Merging the pull request with the current target branch revision

これにより CI は、 checkout scm 内での作業として、
指定ブランチのリポジトリをクローンした直後、
プルリクの向き先(たいていは master)ブランチをマージします。

そのため、HEAD のコミットIDが、プルリク上の最新コミットIDと一致しなくなってしまうのです。
これを、 origin/${env.BRANCH_NAME} のコミットIDを見に行くことにより避けています。

プルリクの最新コミットIDと env.CI_COMMIT の値が一致しない場合、
422 Unprocessable Entity エラーが返ってきてしまうので注意しましょう。

ここはハマりポイントだと思います。

6. そして開発はさらなる高みへ

こうして reviewdog の導入を果たした私達は、
「自分の通った道をよりきれいに」のボーイスカウト精神を意識しながら、
より一貫性のあるコードへ既存コードを整えていくのでした。

最終的に perlcritic の severity を夏さんが 1 (一番厳しいやつ)にしたのは驚きました(笑)

整えていくぞ〜〜!!

というわけで、Perl を使いつつも、誰もが読みやすいコードを目指して日々精進している私達に加わりたい方、
大募集中です!! 一緒に輝きの向こう側を目指しましょう!

一部 Go 言語への移行を少しずつ進めている箇所もあるので、
Go 言語やアーキテクチャーについての知見をお持ちの方も大歓迎です!

ご応募はこちらから〜

www.wantedly.com

まだまだサーバーサイドのメンバーは少ないですので、
「設計も改善も任せろ〜〜!」という方、ぜひぜひご応募ください!!

*1:プラグインによりますが、たいていのプラグインは inline mode をオプションとしてサポートしています

日本最大級のプロダクトマネージャーコミュニティのオフ会#16 に潜入レポ

はじめまして。 ミラティブの坂本としふみです。

先日、日本最大級のプロダクトマネージャーコミュニティである pmjp のオフ会に行ってきました。 今回のブログではそのレポをお送りします。

pmjpとは

いきなり 公式サイト からの引用なのですが、pmjpとは下記のようなコミュニティです。

Product Managers Japan (PMJP)は、主にWeb業界のプロダクトマネジメント・オーナーシップに興味を持つ人々が集まるコミュニティです。現職のプロダクトマネジャーはもとより、エンジニア・デザイナー・プランナーなどなど、様々なバックグラウンドを持つ人々が集っています。

プロダクトマネージャーという言葉が定着するはるか前である、2015年ごろから始まっているコミュニティです。いまでこそプロダクトマネージャーのコミュニティはいくつか存在しますが、その先駆けとなったコミュニティです。年に数回オフ会が開かれており、プロダクトマネージャーやプロダクトマネージャーに興味がある人があつまり、それぞれのノウハウや悩みをシェアしています。

今日のブログは、2019/06/18(火)に行われたオフ会#16のレポートです。

オフ会レポ

オフ会の概要

今回のオフ会会場は、株式会社ジーニーさんでした。新宿のきれいなオフィスで、到着したらまず夜景が綺麗でした。まずは主催者である ninjinkun の挨拶から始まります。

f:id:sakamoto10423:20190624234942p:plain
会場のジーニー社

その後、発表が2つ、LTが3つ、そして懇親会と続きます。

発表

※資料が公開され次第、資料を追記していきます。2019/06/25時点で公開されている発表は資料へのリンクを貼っております。

「よいプロダクトをつくるためのよいチームのつくられかた」

ヌーラボ小久保さん(@yusuke_kokubo)の発表です。

タックマンモデルを使用しながら、良いチームへの変遷をナマナマしく語っていただきました。 ヌーラボ社の主力製品の一つであるBacklogに、新しい機能を実装したチームを実例にして、 得られた教訓を共有頂けました。

小久保さんより資料が公開されているので詳しくは資料を御覧ください。

speakerdeck.com

会場からの質問

トピックがチーム作りということもあり、会場からはチーム作りに関する質問がでました。

  • 1on1や振り返りで本音出せますか?個人的には、飲み会とかに頼ってしまいがちだが、1on1において工夫していることはありますか?
    • 現在は本音が出せるようになっている。秘訣は、いきなりオープンに話そうぜ!という雰囲気ではなく、まずは自分自身をオープンにすることが重要。プロダクトの熱意や個人的な事もまずは自分からオープンにしていく。
  • チームがリモートだと思うが、そこにテクニックはあるのか?
    • 本プロジェクトは全員福岡に固まっていたので該当しなかった。リモートはリモートならではチーム作りはあるとおもう。

LeSSのすすめ

Repro株式会社執行役員 林さん

f:id:sakamoto10423:20190625002512p:plain
Repro株式会社執行役員 林さん
次は、Reproの林さんより、LeSSを導入した経緯とその効果についての発表がありました。 LeSSというのは、ファイルの中身を見るコマンド.... ではなく、Large-Scale Scrumの略称です。

Reproの開発にLarge-Scale Scrumを導入したらうまくいったという話をしていただきました。 (※Repro: アプリやWEBに簡単に導入できる分析・マーケティングツール)

資料が公開されていないので、発表の内容を完結にまとめておきます。

Reproはご存知の方も多いプロダクトだと思いますが、非常に多機能で大きなプロダクトとなってきています。2018年にはアプリではなくWEBにも対応したことで、より巨大なプロダクトになってきています。そのような背景もあり、

  • 開発速度の低下
  • PMやQAがプロセス上のボトルネックになった
  • 知識が属人化した

ことが起こっていました。そこで、開発プロセスを見直し、LeSSを導入したら(最初は大変だったが)概ねうまくいったというお話でした。

ということで、林さんの言葉もお借りしながら、LeSSについてまとめておきます。 LeSSは https://less.works/ にそのプロセスがまとめられています。

私が感じたLeSSの特徴をひとつかいておきます。(全部書くとそれだけでブログがおわってしまう...) それは、LeSSは、コンポーネントチームではなく、フィーチャーチームで構成され、バックログは全チームで共通ということです。

f:id:sakamoto10423:20190625003921p:plain
Lessのフィーチャーチームのイメージ

https://less.works/less/structure/feature-teams.html から引用

上記の右のようなチームがフィーチャーチームです。例えば、フリマアプリであれば、出品チーム・落札チームみたいな分け方だったり、サーバーサイド・クライアントみたいな分け方だったりをせず、チーム1,2,3.... のような分け方で、それぞれのチームがそのチームのみで価値の出荷が可能なチームの作り方です。

たしかにこのような仕組みにしておくと、大きなプロダクトでは非常に理になかっていると思いました。コンポーネントチーム制(左)だと、チームの負荷を均一にするには、バックログもコンポーネントA,B,C均等にしておく必要があります。大規模なプロダクトになればなるほど、そんな理想的な状態は実現が難しいだろうなぁと思いつつ聞いていていました。(大規模なプロダクトで左を採用すると、リファクタリングまできれいにできているコンポーネントとそうでないコンポーネントができたりしますね...経験談)

Reproさんと状況が似ている方、是非試してみてはいかがでしょうか?ミラティブ社でも、エンジニアが増えてきたら(末尾で募集してますよ!)、チーム開発の体制やプロセスについて大きく考える時期が来るかもしれません。その日に向けて良いインプットとなりました。

LT

「PM部立ち上げから見る、組織化のメリットと育成論」

f:id:sakamoto10423:20190625010116p:plain
株式会社ジーニー 大橋さん
株式会社ジーニー 大橋弘崇さん

大橋さんからは、先程のReproさんの事例とは逆の状況で、「プロダクトの数が増えてきたのでPM部を作った」というLTをいただきました。

2019年4月にプロダクトマネジメント部を発足させ、現在は10名のプロダクトマネージャーが在籍しているとのことです。部として気をつけて運営していることとしては

  • PMとしての基礎レベルの向上
  • 知見の共有
  • 目線を上げる取り組み

を意識してやれらているとのことでした。

チーム化した結果、メリットは様々あるが目線を上げる取り組みが組織的にできたことが非常に良かったと言われていたのが印象的でした。また、プロダクトマネージャーのスキルはあとから付けれるものが多いが、プロダクトに向き合っていくスタンス自体はあとから身につくものではない。そこのスタンスが高いメンバーをPMチームに入れるというお話をされていました。

「サークルスクエア 〜資金を調達せず、プロダクトを育て続けた18年の記録」

いとうまさし(@itmsc)さん すみません、お話を聞くのに夢中で写真をとり忘れてしまい;;🙇‍♀️

www.slideshare.net

※本稿執筆後に資料が公開されていましたので資料をアップしました。

サークルスクエアは、18年の歴史があるグループウェアです。どんな思いをもって続けてきたか、という話をしていただきました。 www.c-sqr.net

その中で印象的だったお話が、プロダクトを100年続けていきたいという話でした。そのために3つの戦略を採用されているということです。かなり具体的な戦略の話だったので、キーワードだけ記載しておきます。

  • ブートストラップ作戦。資金を調達しない
  • ギャラクシー作戦
  • ラストマン・スタンディング作戦

また、新機能がでるとのことで、その告知もされていました🍦

Happy Ice Cream officeとは

ユーザーストーリーと効果的な開発、そのKPIは対話だよって話

株式会社 Fabric Tokyo 渡辺さん

f:id:sakamoto10423:20190625102819p:plain
Fabric Tokyo 渡辺さん

ユーザーストーリーをどう使うかという実践例の話をされていました。ユーザーストーリーは「つくりましょう!」「うまくいった!」という話が多いのですが、渡辺さんの発表はユーザーストーリーを細かく書いたがうまく行かなかったという実践例で、大変勉強になりました。

もう少し具体的にいうと、ユーザーストーリー(やワイヤーフレームを)PMが書きすぎて、チームのクリエイティビティが損なわれてしまった。という実体験から、より抽象度の高い「課題設定」だけをPMが行い、チームと一緒に作るものを考えるというスタイルに変更されたとの話でした。そのスタイルにするとより課題解決がうまく進んだとのことです。

つまり、開発チームとの会話を増やしたことで、課題解決の質が上がった。つまり、開発のKPIは「対話」というまとめでした。

2019/06/26 15:47追記:渡辺さんから資料が公開されましたので掲載いたしました

speakerdeck.com

懇親会

懇親会では、登壇された方とお話したり、さまざまな会社のプロダクトマネージャーと交流することができます。

f:id:sakamoto10423:20190625103746p:plain
懇親会
pmjpのオフ会は16回めということで、食事の量もドリンクの量もちょうどいいです。一人何本アルコールを開けるかというビール係数、アルコールとノンアルの割合を決めるノンアルレシオというものがあり、コミュニティ内でだいたい値のあたりが付いているという話を昔聞いたことがあります。

まとめ

いろんなフェーズの会社の色んな話を聞くことができ、大変勉強になりました。 プロダクトマネジメントの方法やミッションはそれぞれの会社で違うものの、抽象化して学べるところはたくさんあるなぁと改めて感じました。 私がいまミラティブで経験していることもプロダクトマネージャー界隈の微々たる力になればなぁと思いますので、機会があれば登壇しようかなと思っています。(次の次くらいを狙っています笑)

現役のプロダクトマネージャーの方も、プロダクトマネジメントに興味があるという人も大変勉強になる回なので一度参加されてみてはどうでしょうか。

pmjpのコミュニティには下記から参加できます。オフ会の連絡などもこちらで行われています。

Product Managers Japan (PMJP)

ミラティブはさまざまな職種のメンバーを募集しています

というわけで、オフ会(勉強会)のレポをお送りしましたが、ミラティブでは「勉強会補助制度」と「デクレア制度」というのがあります。

勉強会補助制度というのは、その名の通り勉強会の補助制度です。下記のルールで運用されています。

f:id:sakamoto10423:20190625105439p:plain
勉強会補助制度
また、技術書は基本経費で購入できますので、今回のオフ会ででてきたLeSSに関する本なども購入OKです。

また、デクレア制度というのは、自分の働き方をデクレア(宣言)する!というものです。勉強会に行くから早く帰るといったものから、集中的に新しいスキルをつけたいので今週は残業なしにしたいです。といった使い方です。プロ野球選手が、チームが試合で勝ち続けるために、自分のコンディションにあわせて負荷を調整するのに似ています。 デクレアについて詳しくは👇 note.mu

ミラティブではさまざまな職種のメンバーを募集しています。

全職種の応募はこちら👇 www.mirrativ.co.jp

ちなみに、私がPMをしているプロダクト(今後リリース予定)で、iOSエンジニアを募集しています!👇 www.wantedly.com