ticktakclockの日記

技術ポエムを綴ったりします。GitHub idも同じです (@ticktakclock)

Flutter Androidのソースコードを読む(FlutterApplication編)

こんにちは、tkyです。

今日はFlutter Androidソースコードを読んで、調べた軌跡を残したいと思います。

本記事は確定的は情報はなく、tkyが右往左往しながら調べたことと感じたことをまとめたもので、仕組みを解説するような内容ではないことを予めここに明記致します。

また、リアルタイムで勉強しながら記事を書いているので誤りがある場合、随時訂正していきます。

(というか正直難しい・・・いろいろ読み進めてみて、dartdart VMのことを知らないと理解できないのではないかと感じました。)

今回読んでいくのはこれ

flutter.jar

Android Studioにはjarのclassファイルに定義ジャンプすることができるので⌘ + クリック(winの場合は ctrl + クリック)で読めますが、 ここで読めるのはあくまでjavaで書かれた領域だけなのでネイティブコード(C/C++の領域のことを指します)も見ることを考慮して flutter/engineもcloneしておきます。

github.com

目次

  • まずはAndroidManifest.xmlから
  • そもそもflutter.jarはどこにあるのか?
  • FlutterApplication
  • FlutterMain
    • startInitialization
      • VM snapshotとは
      • instrって何
      • isolateって何
      • app.soとは
      • app.flxって何

FlutterActivityについては別途調べて見ようと思います。

まずはAndroidManifest.xmlから

とにもかくにもこれから見るのが最初の足がかりとしては良いかと思います。 どうやらFlutterApplicationMainActivityによって構成されていることがわかりました。 MainActivityFlutterActivityを継承しているようですね。

AndroidManifest.xml抜粋

    <application
        android:name="io.flutter.app.FlutterApplication"
        android:label="kasikari_memo"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <meta-data
                android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
                android:value="true" />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>

MainActivity.kt抜粋

class MainActivity: FlutterActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    GeneratedPluginRegistrant.registerWith(this)
  }
}

ここで新しいワードが3つ出てきています。上から順番に調べていきますが、本記事ではFlutterApplicationだけフォーカスします。 - FlutterApplication - FlutterActivity - GeneratedPluginRegistrant

このクラス郡がflutter.jar内に格納されている、ということですね。

そもそもflutter.jarはどこにあるのか?

調べたところ、こちらにあるようでした。android-armの部分はアーキテクチャによってパスが変わりそうです。

Androidの場合:
/Users/${username}/flutter/bin/cache/artifacts/engine/android-arm

iOSの場合:
/Users/t.takenaka/flutter/bin/cache/artifacts/engine/ios

試しにJD-GUIツール(jarファイル内の.classファイルの可視化ツール)で見るとたしかに、ちゃんとFlutterActivityなどの存在が確認できました。 f:id:ticktakclock:20190105225406p:plain

FlutterApplication

onCreateの処理はこの様になっていました。何かの初期化処理をしているようです。

    @CallSuper
    public void onCreate() {
        super.onCreate();
        FlutterMain.startInitialization(this);
    }

FlutterMain

ここから一気に難しくなります。startInitialization()はstaticメソッドですね。

startInitialization()

このメソッドではざっくり4つの処理をしていました。3種類の初期化メソッドと、libflutter.soファイルのロード処理のようです。

            initConfig(applicationContext);
            initAot(applicationContext);
            initResources(applicationContext);
            System.loadLibrary("flutter");

この辺のinit処理で1つ1つコードを見ていくと、理解を超えそうでしたので、

initConfig()内に記述されていた、要所要所の単語を調べていく形で理解していこうと思います。

  • vm_snapshot_data
  • vm_snapshot_instr
  • isolate_snapshot_data
  • isolate_snapshot_instr
  • app.so
  • app.flx

libflutter.soとは

flutter engineの以下のネイティブコードのライブラリですね。BUILD.gnmakefileになります。 FlutterJNI.javaJava側の定義、 platform_view_android_jni.hC++側の定義です。

$ ls shell/platform/android/io/flutter/embedding/engine/
FlutterEngine.java      FlutterJNI.java         dart                    renderer

$ls shell/platform/android/
AndroidManifest.xml                     android_surface.cc                      io
BUILD.gn                                android_surface.h                       library_loader.cc
android_context_gl.cc                   android_surface_gl.cc                   platform_message_response_android.cc
android_context_gl.h                    android_surface_gl.h                    platform_message_response_android.h
android_environment_gl.cc               android_surface_software.cc             platform_view_android.cc
android_environment_gl.h                android_surface_software.h              platform_view_android.h
android_external_texture_gl.cc          android_surface_vulkan.cc               platform_view_android_jni.cc
android_external_texture_gl.h           android_surface_vulkan.h                platform_view_android_jni.h
android_native_window.cc                apk_asset_provider.cc                   vsync_waiter_android.cc
android_native_window.h                 apk_asset_provider.h                    vsync_waiter_android.h
android_shell_holder.cc                 flutter_main.cc
android_shell_holder.h                  flutter_main.h

VM snapshotとは

wikiにちゃんと書いてあるんですね・・・すごい。VMDart VMのことですね。

アプリの起動を高速化するため仕組みのようです。

dartコードを構文解析して得られたバイナリデータのようです。dartコードをロードしたオブジェクトが書き込まれている感じでしょうか。 そのスナップショットを実行すると短い起動時間で処理を再現できるというものみたいです。

僕らが書いたmain.dartはapkビルド時にsnapshotになって、snapshotをDart VMに食わせているのかと推測していますが、間違っていたらスミマセン。。 github.com

以下のドキュメントも参考にしました。 https://www.cresc.co.jp/tech/java/Google_Dart/DartLanguageGuide.pdf

instrって何

instructions(命令?)のことらしいですが、スナップショット自体と処理を別々に管理しているのでしょうか。(ヒープスナップショットと処理スナップショットとか?)

isolateって何

このことであってますかね。Flutter (dart)は基本的にはシングルスレッドですが、 isolateというライブラリを使用することで並列処理が実現できるようになるようです。

その並列の単位をisolate(アイソレート)と呼称し、dart vm上では必ず1アイソレート立ち上がる、と認識しています。 api.dartlang.org

app.soとは

libflutter.soと同じような感じで、libapp.soがapkの中にあるのかなと思いきや存在しない・・・

f:id:ticktakclock:20190106174512p:plain

調べても全然出てこないので、そもそもこのapp.soが使われていない気もしてきました。 一応AOTビルドコマンドが存在します。 github.com

早速試してみましょう。

$ flutter build aot
Building AOT snapshot in release mode (android-arm-release)... 14.4s
Built to build/aot/.
$ ls build/aot/
app.dill            kernel_compile.d
frontend_server.d       kernel_compile.d.fingerprint
gen_snapshot.d          snapshot.d.fingerprint
isolate_snapshot_data       vm_snapshot_data
isolate_snapshot_instr      vm_snapshot_instr

app.soないじゃん 😇 コード検索してもそんなに出てこないし、もう使用されていないのか、根本的になにか勘違いしているのか 🤔

app.flxって何

Flutter SDK側を調べるとそれっぽいものが見つかりました。 ios側のビルドで使用するものっぽいです(flxはFiLe eXtensionの略称であっているのか?)

github.com

そもそもapp.flx自体がapkの中に入っていないですね。(app.soのときのapkのキャプチャ参照)

また、flutter buildでビルドできるコマンドを見てみるとflx Deprecatedと非推奨になっているので昔の名残なのかもしれませんね。

$ flutter build
Flutter build commands.

Usage: flutter build <subcommand> [arguments]
-h, --help    Print this usage information.

Available subcommands:
  aot      Build an ahead-of-time compiled snapshot of your app's Dart code.
  apk      Build an Android APK file from your app.
  bundle   Build the Flutter assets directory from your app.
  flx      Deprecated
  ios      Build an iOS application bundle (Mac OS X host only).

Run "flutter help" to see global options.

おわりに

新年の調べもごと『Flutterってどうやって動いているのか?』を知りたくてFlutter Android側のコードを眺めてみました。 FlutterApplicationではlibflutter.soを使用するための準備とFlutterVMを起動する前の設定値やファイルの準備を行っているようでした。 調べてもそんなに出てこないし、まだまだわからないことが多くてツラミ感じていますが、次はMainActivity側のコードを読んで行こうと思います。

Flutterに入門してみて理解したところやハマったところ

前説

こんにちは、tkyです。

2018/10/08 に開催された技術書典5で購入(知人経由で・・・)したFlutterの入門本でいよいよ私もFlutter入門者になりました。 その時学習した内容とハマりポイントなどを知見として残しておこうと思います。

今回学習に際し、mBaaSとしてFirebase Cloud Firestoreを使用しています。

  • Flutter
  • Dart
  • Firebase Cloud Firestore

なお、このときFlutter1.0がリリースしたてだったのでそれなりにハマるだろうな〜という想定はしていましたが、そこまでズボズボではなかったですw

本記事ではFlutterのインストール手順や各プラットフォームのセットアップ方法などは記載しません。

flutter doctor しまくってエラーに記載されているコマンドをひたすら実行していったら環境整えられたので調べる手間が省けて楽でした。

何作ったの

『Flutte x firebaseで始めるモバイルアプリ開発』という本で解説している貸し借りアプリを書籍ともに作りました。

booth.pm

f:id:ticktakclock:20181215192411p:plain
つくったもの

良かったところ

iOSにはもちろんXCodeは必要ですし、エミュレータも同様ですが、デプロイがAndroid Studio上からできて、hot reloadもちゃんと効くのはまじで便利なのでは?

f:id:ticktakclock:20181216171224p:plain

学習してみて

まだDartの言語仕様部分だったり、どう作る?みたいな深掘りはできていませんが、こんな感じでFlutterって動いてるんだ〜という入門程度には学習できたかと思われます。

今後はWidgetの学習とレイアウトの特訓、アーキテクチャBLoCを使ったアプリ開発あたりに触れて知識の深掘りをしていこうと思います。

今回の気付き

  • Widget充実しすぎて便利
  • publicとprivateの区分方法がアンダースコア(_)
  • 直感的にレイアウトを置ける、onPressedなどのイベントが書きやすい
  • 画面遷移がpush,pop形式でわかりやすい
  • 画面は基本的にStatefulで良さそう?

Widget充実しすぎて便利

FlutterはWidgetと呼ばれるUIオブジェクトを配置することで描画しますが、かなり種類が豊富なのでは?と感じています。

AndroidのUIっぽくMaterial Componentsが、iOSのUIっぽくCupertinoという種別のUIがありました。 書籍ではMaterial Componentsで実装し、Android, iOSどちらも同じUIとなっています。

flutter.io

publicとprivateの区分方法がアンダースコア(_)

Dartにはpublicやprivateのような修飾子はなく、基本publicの扱いとなります。 アンダースコア(_)を変数、関数、クラスにの先頭につけることで同じような働きができるようになります。

Dart公式ドキュメントにはアンスコつけたらそのライブラリだけで見れるようになるよ、と書いてあります。(この"ライブラリ"のスコープがわからん・・・) www.dartlang.org

start with an underscore (_) are visible only inside the library.

直感的にレイアウトを置ける、onPressedなどのイベントが書きやすい

例えばボタンを配置するときに、以下のように記述します。

ログアウトボタンを押したとき、Topページに戻る

              FlatButton(
                child: const Text("ログアウト"),
                onPressed: () {
                  _auth.signOut();
                  Navigator.pushNamedAndRemoveUntil(context, "/", (_) => false);
                },
              ),

FlatButtonのプロパティにイベントを記述する書き方は結構好きです。 またNavigatorを使用して画面遷移を実現しています。画面遷移はルーティングで行うため、画面設計は必要そうですね。

画面遷移がpush,pop形式でわかりやすい

先程の例で説明しましたがNavigator.push or popで画面遷移を実現しています。それなりにわかりやすいかなと思いました。

// Topページに遷移する

Navigator.pushNamedAndRemoveUntil(context, "/", (_) => false);

// /newというルートを作成して、InputForm画面に遷移する
Navigator.push(
                context,
                MaterialPageRoute(
                    settings: const RouteSettings(name: "/new"),
                    builder: (BuildContext context) => InputForm(null)));

// 1つ前の画面に遷移する
Navigator.pop(context);

画面は基本的にStatefulで良さそう?

Flutterにはスプラッシュ画面など動きがない固定画面で使用するStatelessと、

内部でデータを保持し動的に表示を行う画面などで使用するStatefulという考え方があるようです。

ん〜、基本内部で状態を保持して画面更新するパターンのほうが多い気がするのですがStafefulで良さそうな感じでしょうか?もう少し学びながら掴んでいきたいと思います。

ハマりポインツ

AndroidについてはAndroid Studioで開発できる点でほぼ何もハマることなく開発できました。

iOSがちょっとハマりました。

  • iOSCould not build Objective-C module 'Firebase 問題
  • iOScloud_firestore/CloudFirestorePlugin.h' file not found 問題

iOSCould not build Objective-C module 'Firebase 問題

最初の問題でした。エラーの内容通り、Ob-cのFirebaseモジュールがSwiftではビルドできない、と言われていますが、なぜ???という感じですね。

Ob-cのモジュールをinstallしてしまったのだろう、という感覚レベルで、対応法がぐぐったらすぐに出てきたので解決でした。

簡単に言うと、「Podfile 系でinstallしたやつ全部消してもっかいinstallし直す」です。

pippi-pro.com

iOScloud_firestore/CloudFirestorePlugin.h' file not found 問題

こいつにずっと悩まされました。。

f:id:ticktakclock:20181215190413p:plain
cloudfirestoreplugin file not found

試行錯誤した内容は以下です。まぁ解決できませんでした。

  • pod install しなおす
  • flutter clean してみる
  • コマンドでビルドしてみる「flutter build ios
  • flutter packages getしてみる

また、「flutter firestore」のチュートリアル等を確認して自分の手順と違うところを確認したり。 Firebase for Flutter

Podfileはこんな感じで書いてました。

target 'Runner' do
  use_frameworks!

  pod 'Firebase/Core'
  pod 'Firebase/Firestore'
end

この問題2つの共通点

よくよくハマった2点を考えてみるとどちらもPodでした。実はFlutterをやるまでcocoapodsについては未導入だったので今回初めてインストールしたのですが、 setupコマンドを打っていないことに気が付きました。

$pod setup

本にも書いてあるので、ちゃんと本に書いてあることは読みましょう、というn度目の教訓を得ました・・・

setupしたあとにxcode起動したらエラーは消えて無事に動作確認できました。

podfileに書かれていることはまだ理解できていないので、「なぜエラーが消えたのか?」は微妙にわからないままなので要勉強です。

僕はそもそもiOSをやったことなかった

Cordovaを使ったアプリ開発の経験はあるので、XCodeiOSプロジェクトをビルドするとかはできるのですが、 iOSネイティブをやったことなかったことで基礎的な問題の解決に少し時間がかかりました。

しかしながら、開発の効率が非常に高そうなFlutterは今後とも触れて行きたいと思います。

付録(バージョン情報など)

開発時点でのFlutter doctor内容を載せておきます。

$ flutter doctor -v
[✓] Flutter (Channel beta, v1.0.0, on Mac OS X 10.13.6 17G2307, locale ja-JP)
    • Flutter version 1.0.0 at /Users/ticktakclock/flutter
    • Framework revision 5391447fae (2 weeks ago), 2018-11-29 19:41:26 -0800
    • Engine revision 7375a0f414
    • Dart version 2.1.0 (build 2.1.0-dev.9.4 f9ebf21297)

[✓] Android toolchain - develop for Android devices (Android SDK 28.0.3)
    • Android SDK at /Users/ticktakclock/Library/Android/sdk
    • Android NDK at /Users/ticktakclock/Library/Android/sdk/ndk-bundle
    • Platform android-28, build-tools 28.0.3
    • Java binary at: /Applications/Android Studio.app/Contents/jre/jdk/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 1.8.0_152-release-1136-b06)
    • All Android licenses accepted.

[✓] iOS toolchain - develop for iOS devices (Xcode 9.2)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Xcode 9.2, Build version 9C40b
    • ios-deploy 1.9.4
    • CocoaPods version 1.5.3

[✓] Android Studio (version 3.2)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin version 31.1.1
    • Dart plugin version 181.5656
    • Java version OpenJDK Runtime Environment (build 1.8.0_152-release-1136-b06)

[!] VS Code (version 1.29.1)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension not installed; install from
      https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter

[!] Connected device
    ! No devices available

pod setupしたときのPodfileの内容も載せておきます。

# Uncomment this line to define a global platform for your project
# platform :ios, '9.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

def parse_KV_file(file, separator='=')
  file_abs_path = File.expand_path(file)
  if !File.exists? file_abs_path
    return [];
  end
  pods_ary = []
  skip_line_start_symbols = ["#", "/"]
  File.foreach(file_abs_path) { |line|
      next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ }
      plugin = line.split(pattern=separator)
      if plugin.length == 2
        podname = plugin[0].strip()
        path = plugin[1].strip()
        podpath = File.expand_path("#{path}", file_abs_path)
        pods_ary.push({:name => podname, :path => podpath});
      else
        puts "Invalid plugin specification: #{line}"
      end
  }
  return pods_ary
end

target 'Runner' do
  use_frameworks!

  # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock
  # referring to absolute paths on developers' machines.
  system('rm -rf .symlinks')
  system('mkdir -p .symlinks/plugins')

  # Flutter Pods
  generated_xcode_build_settings = parse_KV_file('./Flutter/Generated.xcconfig')
  if generated_xcode_build_settings.empty?
    puts "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first."
  end
  generated_xcode_build_settings.map { |p|
    if p[:name] == 'FLUTTER_FRAMEWORK_DIR'
      symlink = File.join('.symlinks', 'flutter')
      File.symlink(File.dirname(p[:path]), symlink)
      pod 'Flutter', :path => File.join(symlink, File.basename(p[:path]))
    end
  }

  # Plugin Pods
  plugin_pods = parse_KV_file('../.flutter-plugins')
  plugin_pods.map { |p|
    symlink = File.join('.symlinks', 'plugins', p[:name])
    File.symlink(p[:path], symlink)
    pod p[:name], :path => File.join(symlink, 'ios')
  }
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['ENABLE_BITCODE'] = 'NO'
    end
  end
end

Androidで動画配信アプリを作成する

こんにちはtkyです。

前回GCP上に動画配信サーバを作成しました。

今回はこの動画配信サーバにアクセスできるクライアントアプリを作成してみようと思います。

何作ったの

Androidで動画配信アプリを作成しました。言語はKotlinです。

構成は前回PCから配信していたのをAndroid端末に変更しただけで、サーバ構成は変わらずです。 f:id:ticktakclock:20180918200631p:plain

コードはGitHubRtmpClientを参照してください。

前回ストリームキーの話をしましたが、今回はandroid固定で配信することとします。 ※rtmp://xxx.xxx.xxx.xxx/live/android というURLに固定で配信、視聴することになります。

一応、動画を送信する側を「配信」、受信する側を「視聴」と呼ぶことにして進めたいと思います。

どんな感じで作ったの

実装については後述でポイントでコードを載せます。UI関連の説明はしないのでGithub見てもらえたらと思います。

技術的な話

配信側についてはRtmpPublisherライブラリを利用させていただきました。

視聴側についてはExoPlayerextension-rtmpという拡張がありましたのでこの2つのライブラリで実現できそうです。

それぞれAndroid配信⇒VLC視聴、OBS配信⇒Android視聴という流れで画面毎に確認して、最後にAndroid配信⇒Android視聴を確認して完成としたいと思います。

OBSはPCで利用できる配信ツールの事です。

VLCはPCで利用できる動画再生ソフトですが、リアルタイム動画配信の視聴ツールとしても利用できます。

画面的な話

コードはGitHub見てもらえたらですが、簡単に構成だけ。

一応、各画面はFragmentで構成して、1Activity(MainActivity)で作ってみます。(※後にはまることになるとはこの時は知る由もなかった・・・)

  • メイン画面(MainFragment) 配信ボタン、視聴ボタンを作成します。
    • 配信ボタン 配信画面に移動
    • 視聴ボタン 視聴画面に移動
  • 配信画面(PublisherFragment) 配信開始/停止ボタンを作成します。
    • 配信開始/停止ボタン 配信停止中:文言「開始」、タップ時:配信開始する 配信中:文言「停止」、タップ時:配信停止する
  • 視聴画面(PlayerFragment) 遷移したら視聴開始します。また更新ボタンを設置します。
    • 更新ボタン 視聴停止⇒視聴開始する

視聴側

視聴側についてはExoPlayerを使っていきます。

gradle

gradleでexoplayerに必要なモジュールとrtmp拡張の記述を記載します。 ExoPlayerは2.6.0を使っています。

    // ExoPlayer
    // https://github.com/google/ExoPlayer
    implementation "com.google.android.exoplayer:exoplayer-core:$EXO_PLAYER_VERSION"
    implementation "com.google.android.exoplayer:exoplayer-ui:$EXO_PLAYER_VERSION"
    implementation "com.google.android.exoplayer:extension-rtmp:$EXO_PLAYER_VERSION"

(余談)バージョンについて

バージョン情報はすべてgradle.propertiesに記述して各gradleでこの値を参照するように作成しています。 このようにすることで1つのバージョン変更でこれを使用しているライブラリバージョンは一気に変更できて楽ですね。gradleありがとうって感じです。

# libraries
GRADLE_PLUGIN_VERSION=3.1.4
APP_COMPAT_VERSION=27.1.1
CONSTRAINT_LAYOUT_VERSION=1.1.3
TIMBER_VERSION=4.5.1
BUTTERKNIFE_VERSION=8.8.1
RTMP_PUBLISHER_VERSION=1.1.2
EXO_PLAYER_VERSION=2.6.0

Kotlinコード

Kotlinに入る前にまずはXMLから。案外コード量が少ないです。

    <com.google.android.exoplayer2.ui.SimpleExoPlayerView
        android:id="@+id/fragment_player_exp"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:resize_mode="fill"
        app:surface_type="texture_view"
        app:use_controller="false"
        >

Fragment側でこのViewに対して操作します。ButterKnifeを使ってちょっとだけすっきりさせます。

基本的にExoPlayerを使用するときと同じように定義して、RTMPプロトコルを使用する場合はMediaSourceインスタンスを作るときのDataSourceFactoryの指定でRtmpDataSourceFactoryを指定するだけです。

rtmp://xxx.xxx.xxx.xxx/live/stream_key url中のlivertmpサーバのアプリケーション名ですね。前回の記事をご覧ください。

    ・・・中略・・・
    @BindView(R.id.fragment_player_exp)
    lateinit var playerView: SimpleExoPlayerView
    private var player: SimpleExoPlayer? = null
    ・・・中略・・・
    /**
     * 再生を開始する
     * */
    private fun playStart() {
        // rtmp://xxx.xxx.xxx.xxx/live/stream_key
        val uri = Uri.parse("rtmp://$ipAddress/live/$streamKey")
        val player = ExoPlayerFactory.newSimpleInstance(
                DefaultRenderersFactory(context), DefaultTrackSelector(), DefaultLoadControl())
        playerView.player = player
        // rtmpプロトコルを使用する場合はRtmpDataSourceFactory()を使用する
        val mediaSource = ExtractorMediaSource(uri, RtmpDataSourceFactory(), DefaultExtractorsFactory(), null, null)
        player.prepare(mediaSource)
        player.playWhenReady = true
        this.player = player
    }
    /**
     * 再生を停止する
     * */
    private fun playStop() {
        player?.let {
            it.playWhenReady = false
            it.release()
        }
    }

ButterKnifeについて

ButterKnifeはButtonとかViewの定義をアノテーションを使って楽に定義できますって感じのライブラリです。 OnClickのイベントとかもアノテーションだけで定義するのでわざわざButton定義して、onClickListener定義して・・・ということをしなくてよくなるのは個人的に負荷が下がります。

視聴確認

OBSでrpmt://xxx.xxx.xxx.xxx/live/androidに配信してみます。 で、アプリ起動して待ち受けてみると・・・

f:id:ticktakclock:20180918194638j:plain

でた!!細長い!!! 16:9の配信に対して、スマホの画面いっぱいに拡縮して表示しようとしているのでまぁしょうがないですね 😅

こういったところを整備していくの大変そう・・・ とはいえ、視聴側は確認できました。

配信側

配信側はRtmpPublisherを使っていきます。 本当にありがたいことに使い方等がしっかりREADMEに書かれており特に不自由なく利用できました。

usageを見ると

val publisher: Publisher = Publisher.Builder(this)
  .setGlView(glView)
  .setUrl(rtmpUrl)
  .setSize(Publisher.Builder.DEFAULT_WIDTH, Publisher.Builder.DEFAULT_HEIGHT)
  .setAudioBitrate(Publisher.Builder.DEFAULT_AUDIO_BITRATE)
  .setVideoBitrate(Publisher.Builder.DEFAULT_VIDEO_BITRATE)
  .setCameraMode(Publisher.Builder.DEFAULT_MODE)
  .setListener(this)
  .build()

ということなので画面に以下のようなGLSurfaceViewを張り付けて、インスタンスPublisherに設定すればよさそうです。 あとはこのpublisherに対して、startやstop処理をしてあげます。 ライブラリではカメラプレビューの実装も入っているので本当にこれだけで実現できる仕様になっているのがうれしい限りです。

カメラ(動画)を使用するので、Manifestには以下が必要です。 で、今回パーミッションチェックの機構は実装しないので自分でパーミッション許可します・・・

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />

自分でチェックしてね ✅ f:id:ticktakclock:20180918194705p:plain

Kotlinコード

こちらもKotlinに入る前にまずはXMLからいきましょう。Publisherに渡すためのGLSurfaceViewを定義します。

    <android.opengl.GLSurfaceView
        android:id="@+id/fragment_publisher_glv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

Kotlinコードは抜粋で載せますので、詳細はRtmpClientを見てください。

    /**
     * preparePublisher
     *
     * */
    private fun preparePublisher() {
        // rtmp://xxx.xxx.xxx.xxx/live/stream_key
        val url = "rtmp://$ipAddress/live/$streamKey"
        // 転送不可を下げるため動画サイズは320x240とする
        publisher = Publisher.Builder(activity as AppCompatActivity)
                .setGlView(glView)
                .setUrl(url)
                .setSize(320, 240)
                .setAudioBitrate(Publisher.Builder.DEFAULT_AUDIO_BITRATE)
                .setVideoBitrate(Publisher.Builder.DEFAULT_VIDEO_BITRATE)
                .setCameraMode(Publisher.Builder.DEFAULT_MODE)
                .setListener(this)
                .build()
    }

で、実行してメイン画面から「配信ボタン」を押してみます。

     Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'android.hardware.Camera$Parameters com.takusemba.rtmppublisher.CameraClient.open()' on a null object reference
        at com.takusemba.rtmppublisher.RtmpPublisher.onResume(RtmpPublisher.java:100)
        at java.lang.reflect.Method.invoke(Native Method)
        at android.arch.lifecycle.ClassesInfoCache$MethodReference.invokeCallback(ClassesInfoCache.java:218)
        at android.arch.lifecycle.ClassesInfoCache$CallbackInfo.invokeMethodsForEvent(ClassesInfoCache.java:193) 
        at android.arch.lifecycle.ClassesInfoCache$CallbackInfo.invokeCallbacks(ClassesInfoCache.java:184) 
        at android.arch.lifecycle.ReflectiveGenericLifecycleObserver.onStateChanged(ReflectiveGenericLifecycleObserver.java:36) 
        at android.arch.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.java:354) 
        at android.arch.lifecycle.LifecycleRegistry.addObserver(LifecycleRegistry.java:180) 
        at com.takusemba.rtmppublisher.RtmpPublisher.<init>(RtmpPublisher.java:39) 
        at com.takusemba.rtmppublisher.Publisher$Builder.build(Publisher.java:158) 
        ・・・中略・・・

あれーーーー落ちるんですけどーーーー!!!

ライブラリを調べてみるとLifecycleObserver使っているようでした。さらに調べてみると、RtmpPublisherクラスのコンストラクタの初めにactivity.getLifecycle().addObserver(this);しているようですね。 これによりaddObserver(this)した瞬間に@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)アノテーションが付いているメソッドが呼ばれて、まだカメラインスタンスが作成できていないので、NPEの運びとなります。

fragmentはまだonResumeしていないのですが、ActivityはすでにResume状態なので、簡単に言うと1ActivityにしてFragmentでこれを実装しようとすると詰みということがわかりました 😇😇😇😇

ということでPublisherActivityを作成し急遽2Activity構成に変更です・・・

PublisherActivityについてはGithub参照ください。

で、ようやく配信!ちゃんと映像がでました! VLCでも確認すると、画像は非常に乱れていますが、配信出来ていることが確認できました。

f:id:ticktakclock:20180918194752g:plain

もう一台あるAndroid端末で配信、別のAndroid端末で視聴をやってみても3,4秒程度の遅延(かつ画像解像度は低い・・・)で視聴することができました。

配信時のインスタンス負荷状況

一応、配信時のGCPの負荷状況載せておきます。 GCPインスタンスについては前回記事見てください。

ネットワークの転送自体はとがったようになっているのに対して、ディスクIOはピークの後ろにも若干波がありますね。

RTMPサーバの配信データの置き場所 /usr/local/nginx/html/live/hls を確認すると何も入っていなかったことから、一定時間たつと配信データは削除されるようですね。 この一定時間(約5分?)は誰が設定して、どう処理しているのかなどは調査していません。余裕を見て調べつつサーバサイドと仲良くなれたらよいなと思います。

f:id:ticktakclock:20180918194825p:plain f:id:ticktakclock:20180918194836p:plain f:id:ticktakclock:20180918194843p:plain f:id:ticktakclock:20180918194852p:plain f:id:ticktakclock:20180918194903p:plain

まとめ

  • Androidで動画配信及び視聴クライアントを作成した
  • 今回ストリームキーはandroid固定にしたが、できればストリームキー生成はサーバ側に任せてみたい
  • 視聴側はExoPlayer使えば良いが、配信側は独自で作った方が良さそう(動画配信サービスとしてちゃんと作ろうと思った場合)
  • 想像以上に画像が荒かった。動画のサイズと通信速度かな?と思いつつどこが原因か切り分けてはいないので、余裕を見て調べてみたい。

今回作成したコードはGitHubにアップしていますのでRtmpClientを参照してください。

参考文献

様々なサイトを活用させていただきました。感謝です!!

RtmpPublisher https://github.com/TakuSemba/RtmpPublisher

ExoPlayer関連 https://qiita.com/niusounds/items/cce4ff69f5911908259b

GCP上に動画配信サーバーを作成する

はじめましてtkyです。

かねてから動画配信の仕組みに興味があったのでいろいろと調べて 理解ついでにやったことをまとめていきます。 GCPインスタンスの作り方は説明しません。

何作ったの

GCP上にUbuntu仮想マシンを構築、nginxを使用して動画配信サーバを作成しました。

図としてはこんな感じです。YouTubeLiveやニコニコ生放送のように配信者1名がいて、複数名の視聴者がいるような感じのライブ配信サービスですね。

f:id:ticktakclock:20180912202835p:plain

GCP仮想マシンを作る

GCPの12か月無料のやつを使いました。 やり方はググってください!クレカ片手にLet's try!

こんなにスペックいらないかな?と思いつつ以下の構成で作成します。

つくったらこうなります f:id:ticktakclock:20180912202932p:plain

ssh接続できるようにする

windowsの場合

C:\Users\ticktackclock\.ssh>ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (C:\Users\ticktackclock/.ssh/id_rsa): rtmp
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in rtmp.
Your public key has been saved in rtmp.pub.
The key fingerprint is:
SHA256:******************** ticktackclock@**************
The key's randomart image is:
+---[RSA 2048]----+
|*****************|
|*****************|
|*****************|
|*****************|
|*****************|
|*****************|
|*****************|
|*****************|
|*****************|
+----[SHA256]-----+

こんな感じでssh接続します。-i rtmpというのは先ほど作成した鍵情報を利用してssh接続しますよ、という意味ですね。

ssh xxx.xxx.xxx.xxx -i rtmp

※IPはVMの外部IPを指定します。

ssh接続時、パスワードを求められます。(sshkey-genで鍵を作成した時に指定したパスワードです)

The authenticity of host 'xxx.xxx.xxx.xxx (xxx.xxx.xxx.xxx)' can't be established.
ECDSA key fingerprint is SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '***.***.***.***' (ECDSA) to the list of known hosts.
Enter passphrase for key 'rtmp':
Welcome to Ubuntu 18.04.1 LTS (GNU/Linux 4.15.0-1018-gcp x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Sun Sep  9 12:53:03 UTC 2018

  System load:  0.0               Processes:           96
  Usage of /:   2.3% of 48.29GB   Users logged in:     0
  Memory usage: 3%                IP address for ens4: ***.***.***.***
  Swap usage:   0%

 * Read about Ubuntu updates for L1 Terminal Fault Vulnerabilities
   (L1TF).

   - https://ubu.one/L1TF

 * Want to make a highly secure kiosk, smart display or touchscreen?
   Here's a step-by-step tutorial for a rainy weekend, or a startup.

   - https://bit.ly/secure-kiosk


  Get cloud support with Ubuntu Advantage Cloud Guest:
    http://www.ubuntu.com/business/services/cloud

0 packages can be updated.
0 updates are security updates.



The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

ticktackclock@rtmp-server:~$

nginxをインストールする

先ほど立てた仮想マシンにnginxをインストールしていきます。

nginxとは?

OSSのwebサーバーのこと。 1対多(アクセス)の同時処理が得意。 消費メモリが少ない。 動画配信するような1対多アクセスの構成ならnginxの方がよさそうということで採用。

インストール方法

方法はいくつかあるようですが、 rtmpモジュールがデフォルトのnginxに入っていない為、ソースからコンパイルの方法で自分でビルドする必要があるようです。

必要なもの

# 必要なものインストール
$sudo apt-get install build-essential libpcre3 libpcre3-dev libssl-dev unzip zlib1g-dev

# nginxのソースコードダウンロード
$wget http://nginx.org/download/nginx-1.14.0.tar.gz

# rtmpモジュールをダウンロード
$wget https://github.com/arut/nginx-rtmp-module/archive/master.zip

# それぞれ解凍
$tar xvzf nginx-1.14.0.tar.gz
$unzip master.zip

# 解凍できたかlsで確認
$ls
master.zip  nginx-1.14.0  nginx-1.14.0.tar.gz  nginx-rtmp-module-master

# nginxソースコードディレクトリでビルド
$cd nginx-1.14.0/
$./configure --with-http_ssl_module --add-module=../nginx-rtmp-module-master
$make
$sudo make install

※私の場合、zlib1g-devをインストールしおらず、./configureするところでエラー吐きました。zlib1g-devをapt-get installしてもう一度./configureすると成功しました。

configureの結果以下のようなものが出力されます。この辺にnginxが入るんだなぁと思ってもらえれば良いかと。

Configuration summary
  + using system PCRE library
  + using system OpenSSL library
  + using system zlib library

  nginx path prefix: "/usr/local/nginx"
  nginx binary file: "/usr/local/nginx/sbin/nginx"
  nginx modules path: "/usr/local/nginx/modules"
  nginx configuration prefix: "/usr/local/nginx/conf"
  nginx configuration file: "/usr/local/nginx/conf/nginx.conf"
  nginx pid file: "/usr/local/nginx/logs/nginx.pid"
  nginx error log file: "/usr/local/nginx/logs/error.log"
  nginx http access log file: "/usr/local/nginx/logs/access.log"
  nginx http client request body temporary files: "client_body_temp"
  nginx http proxy temporary files: "proxy_temp"
  nginx http fastcgi temporary files: "fastcgi_temp"
  nginx http uwsgi temporary files: "uwsgi_temp"
  nginx http scgi temporary files: "scgi_temp"

(超余談)公式nginxをリポジトリに追加してapt-getする方法

この項目は「そうなんだ~」で読み飛ばしてください。

ubuntuの初期リポジトリにあるnginxはバージョンが古いらしい(?)ので公式のリポジトリからインストールする必要があります。

調べてもらえれば一通り手順は出てくるのですが、備忘録のため記載しておきます。

# nginxサイトが配布するPGPキーを追加
$curl http://nginx.org/keys/nginx_signing.key | sudo apt-key add -

# リポジトリを一覧に追加
$VCNAME=`cat /etc/lsb-release | grep DISTRIB_CODENAME | cut -d= -f2` && sudo -E sh -c "echo \"deb http://nginx.org/packages/ubuntu/ $VCNAME nginx\" >> /etc/apt/sources.list"
$VCNAME=`cat /etc/lsb-release | grep DISTRIB_CODENAME | cut -d= -f2` && sudo -E sh -c "echo \"deb-src http://nginx.org/packages/ubuntu/ $VCNAME nginx\" >> /etc/apt/sources.list"

# updateしてからnginxをインストール
$sudo apt-get update
$sudo apt-get install nginx

起動確認

余談はさておいて、nginxがinstallできたはずなので起動確認です。

# nginx起動
$sudo /usr/local/nginx/sbin/nginx

# nginxが起動しているか確認
$ps aux | grep nginx
root     14331  0.0  0.0  32876   820 ?        Ss   13:02   0:00 nginx: master process nginx
nginx    14332  0.0  0.0  37700  4388 ?        S    13:02   0:00 nginx: worker process
ticktackclock    15004  0.0  0.0  14856  1096 pts/0    R+   14:53   0:00 grep --color=auto nginx

ブラウザからも見てみましょう。

http://xxx.xxx.xxx.xxx

f:id:ticktakclock:20180912203018p:plain

/usr/local/nginx/html/

なおここで表示されているhtmlは上記のパスに入っています。 ただの静的なページを載せたいだけならこのディレクトリ直下にindex.html置けば自分のwebサイトを公開できるということですね。

GCPrtmp通信用のport:1935を開ける

rtmpTCP:1935ポートを使用して通信します。

GCPのデフォルトではこのポートはファイアウォールの設定をしておらず、rtmp通信しても為はじかれてしまうのでちゃんとポート開けましょう。

ファイアウォールルールの設定

メニューより

 ネットワーキング>VPCネットワーク>ファイアウォールルール

ルールの命名規則は以下のような感じらしいですが、ガン無視してしまいました・・・

  • 名前:allow-{{port}}
  • ターゲット:allow{{port}}-server

f:id:ticktakclock:20180912203110p:plain f:id:ticktakclock:20180912203119p:plain

作成できると以下のようにルールのリストに表示されます。

f:id:ticktakclock:20180912203141p:plain

※名前間違えると編集では変更できず、もう一度作り直しになるの地味にめんどいです

VMインスタンスにルール適用

これだけで満足しては実はいけないんですよね。

作成したルールをVMインスタンスに適用して初めて作業完了となります。

メニューより

 コンピューティング>Computing Engine>VMインスタンス

VMインスタンスの詳細からネットワークタグを編集します。

ネットワークタグはファイアウォールルールで設定したターゲットタグを指定します(今回の場合allow1935-server) f:id:ticktakclock:20180912203203p:plain

nginxにrtmpの設定をする

$sudo vim /usr/local/nginx/conf/nginx.conf
rtmp_auto_push on;

rtmp {
  server {
    listen 1935;
    chunk_size 4096;

    application live {
      live on;
      hls on;
      # 録画しないならoffとする
      record all;
      record_path /usr/local/nginx/html/record/hls;
      record_unique on;

      # hlsデータをどこに置くか
      hls_path /usr/local/nginx/html/live/hls;

      # hlsの分割単位
      hls_fragment 5s;
      hls_type live;
    }
  }
}

confで指定したディレクトリは事前に作成しておきましょう。

$sudo mkdir -p /usr/local/nginx/html/record/hls
$sudo mkdir -p /usr/local/nginx/html/live/hls

再起動する

$sudo /usr/local/nginx/sbin/nginx -s stop
$sudo /usr/local/nginx/sbin/nginx

なぜか-s reloadでは再起動してくれず、上記の方法で再起動させました。

$sudo /usr/local/nginx/sbin/nginx -s reload //この方法では再起動しなかった

※ちなみにrtmpモジュールが入っていないnginxではこのエラーがでるので、ちゃんとソースコードビルドしましょう。 nginx: [emerg] unknown directive "rtmp" in /etc/nginx/conf.d/default.conf:45

配信確認

これでサーバ側の準備が整ったことになります。自分のPCから配信してみます。

  • 配信ツールとしてOBSを使用する
  • 視聴ツールとしてVLCを使用する

ライブ配信サイトに対して使用できる無料の配信ツールです。インストールしてください。

配信サーバ単体のテストやAndroidなどのモバイル端末から配信した時の問題の切り分けなどに役立ちました。

URLとストリームキー

URLとストリームキーを設定します。

f:id:ticktakclock:20180912203233p:plain

設定出来たら配信開始!!!

  • [x] URL:rtmp://xx.xx.xx.xx/live
  • [x] ストリームキー:android

URL末尾のliveってなんぞや

nginxのconfで設定したapplication名のことです

rtmp {
・・・
    # ここで設定したliveが「rtmp://xx.xx.xx.xx/live」という配信URLになる
    application live {
      ・・・
    }
・・・
}

ストリームキーってなんぞや

ライブ配信するときの固有IDとなるもの。ユーザIDをストリームキーとかにするとAさんの配信Bさんの配信という考え方が作れそうですね。

rtmpアプリケーション単位で一意である必要があるので、ユーザIDでも良さそうですが、実際はサーバ側で一意のストリームキー(ハッシュとか)を生成して、配信者(クライアント側)でそれを使ってもらうという運用がよさそうです。

※実際にサービス運用でどうやってストリームキーを作成しているのか気になりますね。

VLCで配信出来ているか確認する

メニューからネットワークストリームを開く

メディア(M) > ネットワークストリームを開く(N)

f:id:ticktakclock:20180912203340p:plain

ネットワークURLにはOBSで設定したURLとストリームキーをくっつけたものを指定して再生!!!

rtmp://xxx.xxx.xxx.xxx/live/android

2~3秒くらいの遅延後、VLCで再生されているのが何となくわかりました。

仮想環境上の見え方

OBSで配信している間、サーバ上でどんなファイルができているのか見てみます。

$cd /usr/local/nginx/html/live/hls
$ls
android-0.ts  android-2.ts  android-1.ts  s  android.m3u8

5秒(nginxのconfでhls_fragment 5s;で設定した為)に1つずつtsが追加されていくのが確認できました。

配信時のインスタンス負荷状況

折角GCP使ってモニタリングできるので確認してみましょう。

こんな感じです。1つの配信、1つの視聴だけならCPU1%ほど。

f:id:ticktakclock:20180912203417p:plain f:id:ticktakclock:20180912203428p:plain f:id:ticktakclock:20180912203437p:plain f:id:ticktakclock:20180912203447p:plain f:id:ticktakclock:20180912203458p:plain

まとめ

  • GCPの無料枠で動画配信サーバを作成してみた
  • 新しいプロトコルの知識やサーバサイドの事を知れた
  • 何人くらいの同時配信に耐えられるのだろうか

次回はAndroidアプリからこのサーバに対して配信及び受信をしてみたいと思います!

next:Androidで動画配信アプリを作成する

参考文献

様々なサイトを活用させていただきました。感謝です!!

https://qiita.com/sparkgene/items/c3ac042f30cc5d0fe324

https://christina04.hatenablog.com/entry/2015/01/08/205438

https://centossrv.com/nginx-nginx-rtmp-module.shtml