2017/12/21

Android: ExoPlayer - Downloader

ExoPlayer 2.6.0からDownloaderが追加されたので実装を追った際のメモ書き.

Download

ダウンローダの構築に必要な情報を持つビルドパラメータクラスDownloaderConstructorHelper
ダウンローダのコンストラクタ引数に使われる.

public SegmentDownloader(Uri manifestUri, DownloaderConstructorHelper constructorHelper) {

ダウンロードメイン処理

// SegmentDownloader#download
  @Override
  public final synchronized void download(@Nullable ProgressListener listener)
      throws IOException, InterruptedException {
    priorityTaskManager.add(C.PRIORITY_DOWNLOAD);
    try {
      getManifestIfNeeded(false);
      List<Segment> segments = initStatus(false);
      notifyListener(listener); // Initial notification.
      Collections.sort(segments);
      byte[] buffer = new byte[BUFFER_SIZE_BYTES];
      CachingCounters cachingCounters = new CachingCounters();
      for (int i = 0; i < segments.size(); i++) {
        CacheUtil.cache(segments.get(i).dataSpec, cache, dataSource, buffer,
            priorityTaskManager, C.PRIORITY_DOWNLOAD, cachingCounters, true);
        downloadedBytes += cachingCounters.newlyCachedBytes;
        downloadedSegments++;
        notifyListener(listener);
      }
    } finally {
      priorityTaskManager.remove(C.PRIORITY_DOWNLOAD);
    }
  }

ダウンロード済みのコンテンツは再ダウンロード時にスキップされる

// CacheUtil#cache(DataSpec, Cache, CacheDataSource, byte[], PriorityTaskManager, int, CachingCounters, boolean)
      long blockLength = cache.getCachedBytes(key, start,
          left != C.LENGTH_UNSET ? left : Long.MAX_VALUE);
      if (blockLength > 0) {
        // Skip already cached data.

MasterPlaylist or MediaPlaylist?

ダウンロードするURLはMasterPlaylist or MediaPlaylist どちらかで, 内部ではMasterPlaylistではない(MediaPlaylistの)場合にSingleVariantMasterPlaylistとして扱うようにしている.

// HlsDownloader#getManifest
  @Override
  protected HlsMasterPlaylist getManifest(DataSource dataSource, Uri uri) throws IOException {
    HlsPlaylist hlsPlaylist = loadManifest(dataSource, uri);
    if (hlsPlaylist instanceof HlsMasterPlaylist) {
      return (HlsMasterPlaylist) hlsPlaylist;
    } else {
      return HlsMasterPlaylist.createSingleVariantMasterPlaylist(hlsPlaylist.baseUri);
    }
  }

ダウンロードをとめる.

ダウンロードを停止するには Thread.currentThread().interrupt(); を使う.
割り込みをチェックする(停止できる)タイミングは

1 . 各セグメント毎の読み込み前
2. セグメントの指定バッファサイズ読み込みの都度

ダウンロードコンテンツの永続化

SegmentDownloaderはオンラインデータソース(dataSource)とオフラインデータソース(offlineDataSource)をそれぞれ持っている.
それぞれのデータソースはDownloadConstructorHelperで生成される.
オンラインデータソースはコンストラクタで指定されたファクトリから生成されるデータソースを持つCacheDataSourceが作られる.
オフラインデータソースはデフォルトでFileDataSourceを持つCacheDataSourceが作られる.
また, オンラインデータソースにはキャッシュに情報を書き込むCacheDataSinkがデフォルトで設定される.

// DownloaderConstructorHelper#buildCacheDataSource
  public CacheDataSource buildCacheDataSource(boolean offline) {
    DataSource cacheReadDataSource = cacheReadDataSourceFactory != null
        ? cacheReadDataSourceFactory.createDataSource() : new FileDataSource();
    if (offline) {
      return new CacheDataSource(cache, DummyDataSource.INSTANCE,
          cacheReadDataSource, null, CacheDataSource.FLAG_BLOCK_ON_CACHE, null);
    } else {
      DataSink cacheWriteDataSink = cacheWriteDataSinkFactory != null
          ? cacheWriteDataSinkFactory.createDataSink()
          : new CacheDataSink(cache, CacheDataSource.DEFAULT_MAX_CACHE_FILE_SIZE);
      DataSource upstream = upstreamDataSourceFactory.createDataSource();
      upstream = priorityTaskManager == null ? upstream
          : new PriorityDataSource(upstream, priorityTaskManager, C.PRIORITY_DOWNLOAD);
      return new CacheDataSource(cache, upstream, cacheReadDataSource,
          cacheWriteDataSink, CacheDataSource.FLAG_BLOCK_ON_CACHE, null);
    }

オンラインデータソースではcacheWriteDataSourceの設定が行われる.
DownloaderConstructorHelper#buildCacheDataSourceで特に指定がない限り,
cacheWriteDataSourceには, オンラインデータソース(upstream)とCacheDataSinkが設定されたTeeDataSourceが指定される.
これで, オンラインデータソースの情報がCacheに保存される.

// CacheDataSource#CacheDataSource(...)
  public CacheDataSource(Cache cache, DataSource upstream, DataSource cacheReadDataSource,
      DataSink cacheWriteDataSink, @Flags int flags, @Nullable EventListener eventListener) {
    ...
    if (cacheWriteDataSink != null) {
      this.cacheWriteDataSource = new TeeDataSource(upstream, cacheWriteDataSink);

CacheDataSourcewriteDataSinkが指定されている場合は, TeeDataSourcecurrentDataSourceとして設定する.

// CacheDataSource#openNextSource
    if (cacheWriteDataSource != null) {
    currentDataSource = cacheWriteDataSource;
    lockedSpan = span;
    } else {
    currentDataSource = upstreamDataSource;
    cache.releaseHoleSpan(span);
    }

TeeDataSourceはオンラインデータソースの情報をdataSinkに書き込みながら読み込み処理を行う.

// TeeDataSource#read
  @Override
  public int read(byte[] buffer, int offset, int max) throws IOException {
    int num = upstream.read(buffer, offset, max);
    if (num > 0) {
      // TODO: Consider continuing even if disk writes fail.
      dataSink.write(buffer, offset, num);
    }
    return num;
  }

ここで書き込まれているdataSinkDownloaderConstructorHelperで生成された(デフォルトだと)CacheDataSinkになる.

// DownloaderConstructorHelper#buildCacheDataSource
      DataSink cacheWriteDataSink = cacheWriteDataSinkFactory != null
          ? cacheWriteDataSinkFactory.createDataSink()
          : new CacheDataSink(cache, CacheDataSource.DEFAULT_MAX_CACHE_FILE_SIZE);

CacheDataSink.writeによってファイルへの書き込みが行われる.

// CacheDataSink#openNextOutputStream
    cache.startFile(dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten,
        maxLength);
    underlyingFileOutputStream = new FileOutputStream(file);
    if (bufferSize > 0) {
      if (bufferedOutputStream == null) {
        bufferedOutputStream = new ReusableBufferedOutputStream(underlyingFileOutputStream,
            bufferSize);
      } else {
        bufferedOutputStream.reset(underlyingFileOutputStream);
      }
      outputStream = bufferedOutputStream;

// CacheDataSink#write
    outputStream.write(buffer, offset + bytesWritten, bytesToWrite);

書き込まれる対象のファイルはChache.startFileから取得できる.

// SimpleCache#startFile
    return SimpleCacheSpan.getCacheFile(cacheDir, index.assignIdForKey(key), position,
        System.currentTimeMillis());

SimpleCacheSpan.getCacheFileでキャッシュするべきファイルパスを取得できる.

// SimpleCacheSpan#getCacheFile
  return new File(cacheDir, id + "." + position + "." + lastAccessTimestamp + SUFFIX);

SegmentDownloader.downloadで各セグメントをキャッシュする.

// SegmentDownloader#download
for (int i = 0; i < segments.size(); i++) {
CacheUtil.cache(segments.get(i).dataSpec, cache, dataSource, buffer,
    priorityTaskManager, C.PRIORITY_DOWNLOAD, cachingCounters, true);

CacheUtil.cacheにより, オンラインストリームの情報が永続化される.

// CacheUtil#cache(...)
while (left != 0) {
    long blockLength = cache.getCachedBytes(key, start,
        left != C.LENGTH_UNSET ? left : Long.MAX_VALUE);
    if (blockLength > 0) {
      // Skip already cached data.

キャッシュヒットした場合はオンラインソースからの情報取得をスキップする.

// CacheUtil#cache(...)
    if (blockLength > 0) {
      // Skip already cached data.
    } else {
      // There is a hole in the cache which is at least "-blockLength" long.
    blockLength = -blockLength;
    long read = readAndDiscard(dataSpec, start, blockLength, dataSource, buffer,
        priorityTaskManager, priority, counters);

キャッシュヒットしなかった場合はデータソースから読み込む.
このデータソースはTeeDataSourceであるため, オンラインデータソースから読み込みながらキャッシュへ書き込むことになる.

// CacheUtil#readAndDiscard
    int read = dataSource.read(buffer, 0,
        length != C.LENGTH_UNSET ? (int) Math.min(buffer.length, length - totalRead)
            : buffer.length);

// CacheUtil#cache(DataSpec, Cache, DataSource, CachingCounters)
cache(dataSpec, cache, new CacheDataSource(cache, upstream),
        new byte[DEFAULT_BUFFER_SIZE_BYTES], null, 0, counters, false);

キャッシュインデックスファイル: cached_content_index.exi
キャッシュコンテンツファイル:下記パターンにマッチするファイル名

Matcher matcher = CACHE_FILE_PATTERN_V3.matcher(name);

キャッシュコンテンツのバージョンが古い場合(現時点だと.v1.exo, or .v2.exoなファイル)はアップグレード処理の機能が動く.

// SimpleCacheSpan#upgradeFile
  private static File upgradeFile(File file, CachedContentIndex index) {
    String key;
    String filename = file.getName();
    Matcher matcher = CACHE_FILE_PATTERN_V2.matcher(filename);
    if (matcher.matches()) {
      key = Util.unescapeFileName(matcher.group(1));
      if (key == null) {
        return null;
      }
    } else {
      matcher = CACHE_FILE_PATTERN_V1.matcher(filename);
      if (!matcher.matches()) {
        return null;
      }
      key = matcher.group(1); // Keys were not escaped in version 1.
    }

    File newCacheFile = getCacheFile(file.getParentFile(), index.assignIdForKey(key),
        Long.parseLong(matcher.group(2)), Long.parseLong(matcher.group(3)));
    if (!file.renameTo(newCacheFile)) {
      return null;
    }
    return newCacheFile;
  }

これらに該当しないファイル(ExoDownloader管理外ファイル)は不要ファイルとしてSimpleCache.initialize()で削除される.

// SimpleCache#initialize
    File[] files = cacheDir.listFiles();
    if (files == null) {
      return;
    }
    for (File file : files) {
      if (file.getName().equals(CachedContentIndex.FILE_NAME)) {
        continue;
      }
      SimpleCacheSpan span = file.length() > 0
          ? SimpleCacheSpan.createCacheEntry(file, index) : null;
      if (span != null) {
        addSpan(span);
      } else {
        file.delete();
      }
    }

インデックス

CachedContentIndexのコンストラクタパラメータencrypttrueにすればインデックスファイルが暗号化される.

// CachedContentIndex#CachedContentIndex(File, byte[], boolean)
  public CachedContentIndex(File cacheDir, byte[] secretKey, boolean encrypt) {
    this.encrypt = encrypt;
    if (secretKey != null) {
      Assertions.checkArgument(secretKey.length == 16);
      try {
        cipher = getCipher();
        secretKeySpec = new SecretKeySpec(secretKey, "AES");
      } catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
        throw new IllegalStateException(e); // Should never happen.
      }
...

// CachedContentIndex#getChipher
    // Workaround for https://issuetracker.google.com/issues/36976726
    if (Util.SDK_INT == 18) {
      try {
        return Cipher.getInstance("AES/CBC/PKCS5PADDING", "BC");
      } catch (Throwable ignored) {
        // ignored
      }
    }
    return Cipher.getInstance("AES/CBC/PKCS5PADDING");
...

// CachedContentIndex#writeFile
      if (encrypt) {
        byte[] initializationVector = new byte[16];
        new Random().nextBytes(initializationVector);
        output.write(initializationVector);
        IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector);
        try {
          cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
        } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
          throw new IllegalStateException(e); // Should never happen.
        }
        output.flush();
        output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher));
      }
...

インデックスファイルを読み込む

// CachedContentIndex#readFile
      int count = input.readInt();
      int hashCode = 0;
      for (int i = 0; i < count; i++) {
        CachedContent cachedContent = new CachedContent(input);
        add(cachedContent);
        hashCode += cachedContent.headerHashCode();
      }

キャッシュファイルはインデックス情報と紐づけて管理されている.
インデックス情報のクラスはSimpleCacheクラスで生成される.

// SimpleCache#SimpleCache(File, CacheEvictor, byte[], boolean)
  this(cacheDir, evictor, new CachedContentIndex(cacheDir, secretKey, encrypt));

インデックス情報はSimpleCacheの初期化時に読み込まれる.

// SimpleCache#initialize
  index.load();

インデックス情報が格納されたファイルは下記のような構造になっており, フォーマットにしたがって順番にロードされる.
(暗号化されている場合はIV情報が格納されて, number_of_CachedContent以降が暗号化される)

  private final byte[] testIndexV1File = {
      0, 0, 0, 1, // version
      0, 0, 0, 0, // flags
      (byte) 0xFA, 0x12, ..., // IV
      0, 0, 0, 2, // number_of_CachedContent
      // number_of_CachedContentの分格納される
          0, 0, 0, 5, // cache_id
          0, 5, 65, 66, 67, 68, 69, // cache_key
          0, 0, 0, 0, 0, 0, 0, 10, // original_content_length
      (byte) 0xF6, (byte) 0xFB, 0x50, 0x41 // hashcode_of_CachedContent_array
  };


// CachedContentIndex#readFile
  DataInputStream inputStream = new DataInputStream(new BufferedInputStream(atomicFile.openRead()));
  int version = input.readInt();
  int flags = input.readInt();
  if ((flags & FLAG_ENCRYPTED_INDEX) != 0) input.readFully(initializationVector);
  int count = input.readInt();
  for (int i = 0; i < count; i++) {
    CachedContent cachedContent = new CachedContent(input);
    add(cachedContent)
    hashCode += cachedContent.headerHashCode();
  }
  if (input.readInt() != hashCode) return false;

CachedContentの情報を読み込む際にインデックス情報をメモリにロードする.
上記のインデックス情報にはコンテンツのメタ情報が格納されている.

  • id: 元のストリームを識別するためのファイルID
  • key: 元のストリームを識別するためのキー
  • length: 元のストリームの長さ
// CachedContentIndex#add(CachedContent)
  private void add(CachedContent cachedContent) {
    keyToContent.put(cachedContent.key, cachedContent);
    idToKey.put(cachedContent.id, cachedContent.key);
  }

鍵の保存

HlsDownloader.loadManifestでマニフェストがパース・ロードされる.

// HlsDownloader#loadManifest
    ParsingLoadable<HlsPlaylist> loadable = new ParsingLoadable<>(dataSource, dataSpec,
        C.DATA_TYPE_MANIFEST, new HlsPlaylistParser());
    loadable.load();

ロード時にはTeeDataSourceが設定されたDataSourceInputStreamから読み込まれるため,
マニフェストはこのタイミングで永続化される.

    DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec);
    try {
      inputStream.open();
      result = parser.parse(dataSource.getUri(), inputStream);

さらに, パース処理ではマニフェストの先頭から各行ごとに解析される.
鍵の情報はSegmentインスタンスにも記録されていく.

// HlsPlaylistParser#parse
        if (line.isEmpty()) {
          // Do nothing.
        } else if (line.startsWith(TAG_STREAM_INF)) {
          extraLines.add(line);
          return parseMasterPlaylist(new LineIterator(extraLines, reader), uri.toString());
        } else if (line.startsWith(TAG_TARGET_DURATION)
            || line.startsWith(TAG_MEDIA_SEQUENCE)
            || line.startsWith(TAG_MEDIA_DURATION)
            || line.startsWith(TAG_KEY)
            || line.startsWith(TAG_BYTERANGE)
            || line.equals(TAG_DISCONTINUITY)
            || line.equals(TAG_DISCONTINUITY_SEQUENCE)
            || line.equals(TAG_ENDLIST)) {
          extraLines.add(line);
          return parseMediaPlaylist(new LineIterator(extraLines, reader), uri.toString());

// HlsPlaylistParser#parseMediaPlaylist
      } else if (line.startsWith(TAG_KEY)) {
        String method = parseStringAttr(line, REGEX_METHOD);
        String keyFormat = parseOptionalStringAttr(line, REGEX_KEYFORMAT);
        encryptionKeyUri = null;
        encryptionIV = null;
        if (!METHOD_NONE.equals(method)) {
          encryptionIV = parseOptionalStringAttr(line, REGEX_IV);
          if (KEYFORMAT_IDENTITY.equals(keyFormat) || keyFormat == null) {
            if (METHOD_AES_128.equals(method)) {
              // The segment is fully encrypted using an identity key.
              encryptionKeyUri = parseStringAttr(line, REGEX_URI);
            } else {
              // Do nothing. Samples are encrypted using an identity key, but this is not supported.
              // Hopefully, a traditional DRM alternative is also provided.
            }
      ...
      } else if (!line.startsWith("#")) {
        String segmentEncryptionIV;
        if (encryptionKeyUri == null) {
          segmentEncryptionIV = null;
        } else if (encryptionIV != null) {
          segmentEncryptionIV = encryptionIV;
        } else {
          segmentEncryptionIV = Integer.toHexString(segmentMediaSequence);
        }
        segments.add(new Segment(line, segmentDurationUs, relativeDiscontinuitySequence,
            segmentStartTimeUs, encryptionKeyUri, segmentEncryptionIV,
            segmentByteRangeOffset, segmentByteRangeLength));

ここで追加されたSegmentはダウンローダが保存する形式のSegmentに変換される.
プレイリストのセグメント:
HlsMediaPlaylist.Segment#Segment

ダウンローダのセグメント:
SegmentDownloader.Segment

変換はHlsDownloader#addSegmentで行われる.
Segmentに鍵情報が格納されているので, fullSegmentEncryptionKeyUri != nullとなる.
encryptionKeyUrisHashSetなので, 新しい鍵Uriの場合にencryptionKeyUris.add(keyUri)trueを返し,
その鍵のURI情報はDataSpecuriとして格納され, 一つのセグメントとしてコレクションに追加される.

// HlsDownloader#addSegment
    if (hlsSegment.fullSegmentEncryptionKeyUri != null) {
      Uri keyUri = UriUtil.resolveToUri(mediaPlaylist.baseUri,
          hlsSegment.fullSegmentEncryptionKeyUri);
      if (encryptionKeyUris.add(keyUri)) {
        segments.add(new Segment(startTimeUs, new DataSpec(keyUri)));
      }
    }
    Uri resolvedUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, hlsSegment.url);
    segments.add(new Segment(startTimeUs,
        new DataSpec(resolvedUri, hlsSegment.byterangeOffset, hlsSegment.byterangeLength, null)));

セグメントのコレクションはSegmentDownloaderによってキャッシュされるので, 結果的に鍵情報も同じように永続化される.

      for (int i = 0; i < segments.size(); i++) {
        CacheUtil.cache(segments.get(i).dataSpec, cache, dataSource, buffer,
            priorityTaskManager, C.PRIORITY_DOWNLOAD, cachingCounters, true);

.v3.exo

ファイルサイズの上限はCacheDataSource#DEFAULT_MAX_CACHE_FILE_SIZEで定義されており,
デフォルトで2MiB(2 * 1024 * 1024)が指定されている.
これを変更するにはDownloaderConstructorHelperのコンストラクタ引数cacheWriteDataSinkFactoryに自前のDataSink.Factoryを設定する.

Cache cache = new SimpleCache(dir, new NoOpCacheEvictor());
DownloaderConstructorHelper constructor =
    new DownloaderConstructorHelper(cache,
        new DefaultHttpDataSourceFactory("ExoPlayer", null),
        null,
        new CacheDataSinkFactory(cache, 20480),
        null);

.v3.exoの1ファイルあたりの上限サイズは2MiBがデフォルトで, これを超えると次の.v3.exoファイルに分割保存される.
.v3.exoSegmentDownloader.Segmentの単位で保存され, のファイル名は
<id>.<ストリームの書き込みバイト位置>.<ファイル書き込み時のタイムスタンプ>.<バージョン>.exo
の形式で決まる.

idはSegmentDownloader.Segmentの単位で管理されており, idが同じであれば同じセグメントを指す.
SegmentDownloader.SegmentはPlaylistや.ts, 暗号キーといった単位を表現する)
セグメントが変わればidも変わるため, .exoの書き込み容量が上限サイズを迎えていなくても次のファイル名に変わる.

分割保存された.exoファイルを, 後に結合するためには前述のcached_content_index.exiにある
インデックス情報(id, key, content length)を使って復元される.

暗号化

.exiは平文で保存されるのがデフォルトの挙動.
これを暗号化して保存したい場合はSimpleCacheに秘密鍵を渡すことで実現できる.

byte[] secretKey = "Bar12345Bar12345".getBytes("UTF-8")
new SimpleCache(cacheDir, new NoOpCacheEvictor(), secretKey);
2017/11/07

Android: デフォルトで@NonNull扱いにする

JSR 305’s @ParametersAreNonnullByDefault を使うと, @Nullable でアノテートされていないメソッド, 引数, フィールドが @NonNull アノテートされているように解釈され, @NonNull アノテートされているのと同じ振る舞いになります.

プロジェクトによっては, @NonNull を明示することが煩わしく, @Nullable のみを定義することにして, それ以外は @NonNull 扱いとするルールを採用しているところもあるかと思います.
ただ, これではIDEが提供するNull安全のインスペクションメッセージによる恩恵を受けることができず, 実装者が “@Nullableをつけ忘れていた” なんて悲劇を招く可能性もあります.

“Tool, not Rules” ということで, デフォルトの振舞いを @NonNull にしたいときは, @ParametersAreNonnullByDefault が使えます.
このアノテーションはクラス単位でつけることもできますが, 例えば次のようにパッケージ単位でも指定できるため, プロジェクトのデフォルト設定としても役に立ちます.

com.android.myappフォルダに↓のような package-info.java を用意すれば, パッケージ単位で @NonNull アノテーションが有効になります.

// com.android.myapp.package-info.java
@javax.annotation.ParametersAreNonnullByDefault
package com.android.myapp;

JavaからKotlin化する際には @Nullable / @NonNull が定義されていると, とても移行しやすいですのでおすすめです.

以上です.

2017/10/16

Android Performance. UI Rendering

レイアウトXMLはどのようなプロセスを経てピクセル情報に変換され, 画面に描画されるのでしょうか?
Androidのパフォーマンスを改善するには, UIレンダリングの仕組みを理解しておく必要があります.

Android Performance. Dropped frameでは画面のアップデートが16ms毎に行われ, これが遅延するとユーザ体験を悪くしてしまうことについて触れました.

アプリが60fpsを維持するためにはMainThreadでの処理を軽くし, 16msごとのリフレッシュレートを逃さないようにしなければなりません.
60fpsを維持できなくする理由はたくさんありますが, 今回はViewの更新とレンダリングパイプラインについて見ていきます.

Layout & Draw

レイアウトXMLがパースされるとレイアウトツリー(ビューヒエラルキー)が作成されます. 描画はルートノードから始まり, ツリーを渡り歩きながらレイアウトと描画が行われます.
複数のビューを持つ親ビュー(ビューグループ)の場合は, 子ビューにいくつかの制約や制限をつけて描画を要求します. 描画の順序は親ビューが先で子ビューが後になるので, 親が子より奥に描画され, 子ビューが親に重なる形で描画されることになります.

ビューのレイアウトにはメジャーとレイアウトのプロセスがあります. 親ビューは子ビューのサイズに依存するので, まずは子ビューのサイズを計測します. 計測が終わると親ビューが全ての子ビューを計算されたサイズで配置していきます. これはビューツリーからトップダウントラバーサルで処理されるため, ビュー階層が浅いほどパフォーマンスが良くなります. ビューのレイアウトが終わるとこれを描画します.

Rasterization

Viewをディスプレイに描画するには, ボタンやテキストをピクセルに変換する必要があります. 例えば, ラスタ形式(ビットマップ, etc.)ではない文字列やボタン, ベクタードロワブルのようなオブジェクトはラスタライズと呼ばれるプロセスでピクセル形式に変換されてから画面に出力されます.
Android3.0以降, レンダリングパイプラインはハードウェアアクセラレーションをサポートしました. ラスタライズはとても時間のかかるプロセスなので, 専用にデザインされたハードウェアユニット(アクセラレータ)で高速に処理されます. これがGPU(Graphics Processing Unit)です.

GPUはポリゴンやテクスチャといったいわゆる画像などのために設計されたハードウェアユニットです. CPUはそういった画像をGPUに供給する役割を果たします. この操作には OpenGL ES のAPIを使って行われています.

ボタンなどのUIオブジェクトを描画したい場合, まずはCPUでポリゴンやテクスチャ情報に変換し, これをGPUに送ってラスタライズします. CPUでポリゴンやテクスチャ情報に変換したり, GPUにこれを入力する処理は高速ではありません.

パフォーマンスのために, これらのオブジェクトに変換する回数を減らすことは効果があります. OpenGL ES のAPIはGPUに入力したオブジェクトをGPU上にそのままキャッシュさせることが可能です. 同じボタンやUIコンポーネントを使う場合は, 単にGPU上に残ったキャッシュを参照すればよいので, 余計なオーバーヘッドが起こりません. レンダリングの性能を最適化するには, GPU上にあるキャッシュを可能な限り長時間保持して, これを再利用するようにすることです.

Display list

標準UIコンポーネントのドロワブルなどはあらかじめGPUに入力されており, これらの描画は効率的に動きます.
しかし, 実際のUIは複雑で, 例えば背景画像といったビットマップはCPUが画像をメモリにロードしてGPUに転送されます. また, ベクタードロワブルはパスを繋げてポリゴンを描画する必要があります.
テキストにいたってはCPUで文字グリフをテクスチャにラスタライズしたあとGPUにこれを入力し, GPUメモリにグリフを参照する領域を描画します.
アニメーションリソースはもっと複雑で, ビジュアルが変わればGPUリソースを1コマ, 1コマ何度も更新しなければなりません.

ハードウェアアクセラレーションが有効である場合, ディスプレイリストを使った新しい描画モデルで描画されます. ディスプレイリストにはGPUレンダリングに必要な情報アセットとOpenGLコマンドリストが格納されていて, 無駄なオーバーヘッドを抑えて効率的に描画することができます.

Draw Phase

ビューが実際にレンダリングされる前に, まずGPUに適した形式に変換するDrawフェーズがあります. これはJavaによるonDrawコマンドで行われますが, Canvasを使ってテッセレートされた複雑なオブジェクトかもしれません.
この変換が終わると, システムによって結果がディスプレイリストとしてキャッシュされます.

Androidではその都度画面全体を再描画することはせず, 更新が必要な領域に絞って描画します. しかし, 多数のビューが無効化(invalidate())されるとDrawフェーズに多くの時間を費やします. あるいはonDrawで非常に複雑なロジックを抱えているかもしれません.

Execute Phase

作成されたディスプレイリストは2Dレンダラーによって実行されます. ディスプレイリストはOpenGL ES APIを使ってドローされます. これによってGPUにデータが送られ, 最終的にピクセルを画面に送ります.
複雑な描画をするカスタムビューでは, OpenGLが描画できるようにコマンドも複雑になる必要があります. 複雑なビューを描画することは2DレンダラーのExecuteフェーズに多くの時間を費やす原因になります.

画面上でUIオブジェクトの位置が変わった場合は, 同じディスプレイリストをもう1度Executeフェーズを実行するだけです. しかし, 画像のビジュアルが変化すると過去のディスプレイリストが無効になるかもしれません. その場合はDrawフェーズでディスプレイリストを再作成して, 再び実行する必要があります. 画像の描画内容が変わるたびにこのプロセスが繰り返されます. このパフォーマンスは画像の複雑さによって変わるため不正確です.

Process

DrawフェーズとExecuteフェーズが終わるとCPUはフレームのレンダリングが完了したことをGPU/グラフィックドライバーに伝えます. このアクションはブロッキングコールであるため, GPUがコマンドを受け付けたことの応答をCPUは待つことになります.
GPUからのコマンド応答が長くなると, このプロセスも長くなります. プロセスが長くなるのは大抵GPUが多くの仕事をしていることが多いです. 多数の複雑なビューの結果, 多くのOpenGLレンダリングコマンドが必要になりGPUの仕事が増えるのです.

16ms / Frame

16msの間に起こるレンダリングパイプラインは次の通りです.

  1. Input(ユーザからの入力)
  2. Animation(アニメーション)
  3. Measure&Layout
  4. Drawing(Draw Phase)
  5. Sync/Upload
  6. Issuing Commands(Execute Phase)
  7. Processing(Process)
  8. Misc

これらの時間はProfile GPU Renderingツールで見ることができます. 下図はフレームごとのレンダリングに要した時間を並べたもので, 緑色の水平線が16msを示すラインです. これを超えるとDropped Frameが発生します.


実際にアプリケーションを作成すると, 16ms/フレーム・60fpsを維持することが大変であることを実感できるでしょう. パフォーマンスを改善するには計測して問題のある箇所を特定することを繰り返すことが重要です.

前回と合わせて, 最低限必要な知識は揃いましたので, アプリのパフォーマンスを悪くしている箇所を特定し, それを改善するアプローチについて次回以降に書きたいと思います.

次回に続く…

2017/10/13

Android Performance. Dropped frame

SystemEvents, Input Events, Application, Service, Alarm, UI Drawingといった多くの処理はMain Thread(UI Thread) で実行されます.
重要なポイントは, 画面は16ミリ秒の間隔で再描画されているということです.

Why 16ms, Why 60fps?

人間は繋がりのある複数枚の絵が十分な速さで連続していると, それがあたかもアニメーションしているかのように錯覚します. パラパラ漫画やアニメGifの原理です.
アニメーションをスムーズに見せるために, どれだけ素早く画像を表示できるかという点が重要で, 滑らかで流れるようなアニメーションには必要不可欠な要素です.

人間の脳がアニメーションしているように感じるためには, 最低でも12fps程度の速度が必要です. これよりも遅いとパラパラ漫画のようなぎこちない見た目になります. 12fpsという速度はアニメーションには見えてもあまりスムーズには映りません.
24fpsは流れるようなアニメーションに見えますが, これはモーションブラーやビジュアルエフェクトの効果によるものです.
60fpsはモーションブラーやエフェクトなしでスムーズに映ります. これ以上のfpsはほぼ感知できない領域です.

注意すべきは人間の目の明敏さで, フレームレートが60fpsから24fpsに落ちると, 途端にアニメーションのスムーズさを欠いたように感じ, よくない印象を与えることになります.

VSYNC

スムーズなアニメーションを実現するためにも, Androidがどのようにして60fpsを実現しているのかを理解しておきましょう. それには2つの用語を理解しておく必要があります.

リフレッシュレート

1秒間に画面を何回リフレッシュできるかの値で, ハードウェアが定めた一定間隔で実行されます.
単位はHz(ヘルツ)で, 例えば60Hzであれば1秒間に60回のリフレッシュが可能です.

フレームレート

GPUが一秒間で幾つのフレームを描画できるかの値です.
単位はfpsで, 例えば60fpsであれば一秒間に60フレームの描画が可能です.

Synchronized

GPUが画像データを出力し, ハードウェアがそれを画面に表示します.
スクリーンの描画は, これを何度も繰り返しているので, GPUとハードウェアはできる限り一緒に働くことが望ましいのですが, リフレッシュレートとフレームレートは同じ頻度で起こることが保証されていません.

フレームレートがリフレッシュレートより早いと, ティアリングという現象が発生します.
これは, GPUが新しいフレームをメモリに上書きしている最中に, 画面がリフレッシュされてしまい, まだ更新中の画像を描画してしまうことで, 画像が崩れる(部分的に古いフレームが残る)現象です. これを解決するのがダブルバッファリングです.

ダブルバッファリングでは, GPUがバックバッファにフレームを描画し, それが終わるとフレームバッファーと呼ばれる領域にコピーします. 画面をリフレッシュするときはこのフレームバッファから取り出してリフレッシュするわけです. これによって古いフレームへの上書きが行われないので, 中途半端に上書きされた状態にはなりません.

ここで注意しないといけないことのは, 画面のリフレッシュ中にバックバッファからフレームバッファへのコピー作業が発生しないようにすることです. そうしないと, 同じ問題が起こります. ここで登場するのがVSYNC(Vertical Synchronization)です.

通常はフレームレートがリフレッシュレートよりも高いことが望ましいです. なぜなら, 画面を読み込むよりもGPUのリフレッシュの方が早くなるからです.
GPUはフレームをバックバッファに載せると, VSYNCによって次の画面リフレッシュまで処理を待つことになります.

しかし, 反対にフレームレートがリフレッシュレートよりも低い場合, 例えば30fpsに対して60Hzのディスプレイであった場合, フレームバッファのリフレッシュ作業には, 画面リフレッシュの倍の時間を要するため, 同じフレーム内容で2回ずつリフレッシュすることになります.
問題は, これが断続的に起こった場合です.

十分に早いフレームレートで動作しても, 突然フレームレートが落ちると, ユーザはスムーズなアニメーションに続いて, ぶつ切りになったものを見ることになります.
これらの事象は一般的に ラグ, ジャンク, ヒッチング, スタッター と呼ばれます.

アプリの開発者はこれらの事象を避けなければなりません.
人間の目は明敏で, フレームレートが落ちると, 途端にアニメーションのスムーズさを欠いたように感じ, よくない印象を与えてしまうことを思い出してください.

アプリ開発者が目指すところは 常に60fpsのパフォーマンスを維持すること です.

1000ms / 60frames = 16.666ms/frame

MainThreadでは16msの間隔でUI Drawingイベントが発生します. 60fpsの滑らかなアニメーションを実現するためには16ms間隔の描画が必要になります.

Main Thread(UI Thread)

単一スレッドの処理は逐次実行されるため, 順番に処理されていきます. MainThreadも例外ではありません. UI DrawingイベントもMainThreadで実行されるので, もしあなたの処理が長引くとUI Drawingイベントが遅延し, 次のリフレッシュレートのタイミングを逃してしまい, アニメーションで描画されるはずであったフレームが抜け落ちる ドロップフレーム が発生します.
あなたが書いた処理の後には, 常にUI Drawingイベントが待ち構えていることを忘れないでください.

次回に続きます…

2017/10/10

aapt:attr でリソースファイル数を節約する

layer-listselector など, リソースがまた別のリソースを参照する場合があります.

<selector ...>
  <item android:drawable="@drawable/image01" />
  <item android:drawable="@drawable/image02" />

image01image02 がベクタードロワブルの場合は, 新たに image01.xml, image02.xml と2つのドロワブルリソースを用意する必要があります.

  • selectorlayer-list の定義ファイル
  • image01.xml
  • image02.xml

image01image02 が他リソースでも使われている共通化されたリソースであれば良いのですが, 他では使われず, ここでしか参照されない場合は1つのリソースファイルとしてまとめて定義できた方が管理が楽です.

そうした場合は aapt:attr タグが使えます.

<?xml version="1.0" encoding="utf-8"?>
<selector
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:aapt="http://schemas.android.com/aapt">
  <item>
    <aapt:attr name="android:drawable">
      <vector ... >
        <path ... />
      </vector>
    </aapt:attr>
  </item>
</selector>

<aapt:attr> タグで指定したリソースは, aaptによってリソースファイルとして抽出・生成され, name属性名の値は, 親タグの同属性に指定のリソースを設定する動作となります.
この機能は全てのAndroidバージョンで利用できます.

以上です.

2017/10/09

DevFest2017

Android1.5~8.0 Walkthrough のセッションに登壇した際のスライドとスピーカーノートメモ、あと喋った内容の文字起こし.

はじまり。

2017年8月に最新のOS Android8.0 コードネーム Oreoがリリースされました.

アプリを Oreo に最適化するには TargetSdkVersion を 26 に上げる必要があります。
TargetSdkVersion を上げることで、Oreoの新機能を十分に活かすことができます。

ここ数年のアップデートでは システムリソースの消費を抑える DozeやAppStandby、バックグラウンド動作制限などがリリースされています。

これによって、ユーザは端末やアプリを使っていないときの バッテリー消費 を抑えることができます。
その一方で, 開発者は OSの仕様変更に対応する必要があります。

バックグラウンド活動のデザイン原則というものがあります。

  • バックグラウンドの活動を減らすことができないのか?
  • デバイスが充電中の状態になるまで活動を遅らせることができないのか?
  • 他の活動とまとめることができないのか?

といったことを考える必要があります。

8.0で バックグラウンド活動が厳格化されたことで 開発者はこれらと “まじめに” 向き合っていく必要があります。

これらの機能を搭載したOSが市場にどれぐらい流通しているのかをグラフにしました。
一番左のグラフは、下から青がAndroid ヌガー, 緑がマシュマロ, 黄色がロリポップ, 赤がキットカット のシェア率を積み上げたものです。

Dozeは マシュマロ以降のOSに搭載されていますので 市場端末の およそ50% がこれを搭載しています。
Android ヌガーでリリースされた, 一部のBroadcastを無効にするProjectSvelteは 18% です。

この割合は DevelopersサイトのDashboardで公開されている 10月時点でのWorldWideなOSバージョンシェアの数字になります。国内に限定したり、ターゲットユーザ層やminSdkでそもそもサポートしていないOSがあると思いますので、みなさんのサービスと同じ数字にはならない点にご注意ください。

本日は、こういった仕様変更や動作制限の移り変わりを Android 1.5~8.0まで 振り返ります。
時間の都合上、厳選してピックアップしている点はご了承ください。

まず初めは2009年4月リリースのOS1.5 CUPCAKEです.

2009年といえば バラク・オバマ氏が アメリカ合衆国大統領に就任した年 になりますね。
その頃Androidは スクリーンキーボードのサポートやアプリウィジェットプロバイダーをリリースしていました。

リリース:2009年4月 Android1.5 - Api Lv.3

3rd party keyboards… サードパーティ製のキーボードはこの頃からサポート.
Bluetooth A2DP… BluetoothプロファイルのA2DPがサポートされました. 当然まだBLEはサポートされていません.
AppWidgetProvider… アプリウィジェット機能のAPIがリリースされ, 開発者はアプリウィジェットを作成することができるようになりました.

次にリリースされたのが 2009年9月 OS1.6 DONUT です。
Cupcakeでは 320ピクセル x 480ピクセル の解像度のみをサポートしていましたが、Donutからは複数の解像度を扱えるようになりました。
また、バッテリー問題が今よりも はるかに深刻だった時代で, アプリ毎のバッテリー使用量をユーザが確認できる機能などが追加されました。

リリース:2009年9月 Android1.6 - Api Lv.4

Battery usage indicator… アプリごとの消費電力がわかる画面を搭載
当時は電力消費問題が深刻で朝満充電にしても夕方前にはバッテリー切れという状態.

New Android Market UI… 現Google PlayのUIが大幅刷新.
当時のAndroidアプリは簡素なものが多かっただけに, Android Marketの多彩な表現は開発者の目をひくものだった

Text-to-speech engine… 多言語の音声合成エンジンでテキスト読み上げをサポート. ただし日本語は含まれていなかった.

2009年10月 Donutリリースから わずか1ヶ月後には OS2.0 Eclair がリリースされました。
この頃はOSバージョンアップが 今よりも頻繁にあった時代です。
ここでサービス周りのアップデートがありましたので詳しくみてみます。

リリース:2009年10月 Android2.0~2.1 - Api Lv.5~7

Service.setForeground deprecated… Service.setForegroundが非推奨に.
代わりにService.startForegroundを使う必要がある. さらにフォアグラウンドで動作していることをユーザに伝えるためにOngoing Notificationの登録が必須化された.

Key events executed on Key-up… Android2.0はHOMEやBackといったバーチャルキーをサポートするため, ユーザが誤ってキーダウンしてもドラッグすることでキーイベントをキャンセルすることができるように, キーアップでイベント発火されるように変更された.

Multi-touch… マルチタッチがサポートされて, キーボードで素早く文字入力しても抜けることが少なくなりました

その他… Live WallpaperのAPIリリースもこの時.

まず、2.0のタイミングでService.setForegroundメソッドが非推奨になりました。
2.0未満のOSでは フォアグラウンドサービスを開始するのに 通知アイコン が不要でした。

通知アイコンが必須になったのは2.0からで、これによって、ユーザがバックグラウンドで活動しているアプリの存在に気づき、
無用なアプリを停止させることができるようになりました。

また、当時はバックグラウンドの活動に対する制限が緩かったので、バックグラウンドにいるアプリプロセスを片っ端からKillしていくタスクキラー系アプリが バッテリー寿命に効くということで流行りました。
アプリ開発者はそうしたキラー系アプリとも戦っていた時代です。

8.0ではサービスの在り方が大きく変わりました。原則、バックグラウンド状態から新しくサービスを起動できなくなったり、startForegroundServiceで起動する場合には5秒以内にフォアグラウンドへ昇格させないとANRが発生するなど厳格化されました。

バックグラウンド活動まわりで使えるAPIに、ロリポップでリリースされたJobScheduler APIがあります。
これの互換性ライブラリとして FirebaseJobDispatcherが API Lv.9から利用可能です。カバー率はほぼ100%です。
JobSchedulerは ロリポップ から使えるAPIなので 78% の端末で使うことができます。

2010年5月にはOS2.2 Froyoがリリースされました。
音声操作機能や、テザリング機能、GCMの前身にあたる C2DM がリリースされたのもこの時です。

リリース:2010年5月 Android2.2 - Api Lv.8

Install on external storage… アプリのインストール領域に外部ストレージを指定可能になった.

Backup Manager, C2DM… 新しい端末に乗換えした時に便利なアプリデータをクラウドへバックアップ/リストアを実現するAPI Backup Managerがリリース.
アプリはBackup agentを実装することでこれを実現することができる. 現在のバックアップの仕組みとは少し異なる. またGCMやFCMの前身にあたるC2DMもこのOSからサポートされています. C2DMはGCMにリプレースされた時点で非推奨になっています.

JIT compiler… JITコンパイラサポートにより2~5倍高速化. マニフェストに vmSafeMode=false を指定することでJITコンパイラによる最適化を無効化することができます. このオプションは後々AOTコンパイラを無効化するオプションに置きかわります.

その他… PlayServiceはこれ以前のバージョンでは対応していない.

2010年12月には OS2.3 Gingerbreadがリリースされました。
電池が何に使われたかを計測するバッテリー管理機能などが強化されています。

Androidのイースターエッグが搭載されたのもGingerbreadからです。
Gingerbreadでは ゾンビ ジンジャーブレッドマン の絵がイースターエッグで表示されます。
実際、ジンジャーブレッドは ゾンビ な状態になります。

スマホ向けOSの最新版としての期間が長かったことと、スマホブームが重なったこともあって 一時期は全体の60%を超えるシェアにまでGingerbreadは普及しました。
その後は、2015年にマシュマロがリリースされて、ようやくGingerbreadのシェアが10%を切ったぐらいに ”ゾンビ” な状態でした。

リリース:2010年12月 Android2.3 - Api Lv.9/10

2010年… 東北新幹線全線開業した年.

1touch word selection & copy/paste… テキストのロングプレスで単語が選択されフリー選択モードに移行するようになった.

Improved Power management… アプリがバックグラウンドで消費したCPUタイムをユーザが見られるようになるなどバッテリー管理機能が強化された.

その他… StrictMode搭載. Apache Harmony 6.0ベース化. システムアプリやシステムUIの刷新. Google PlayServiceのサポートはここから.

ここで、パフォーマンスに関する仕様変更についてみてみます。
OS5.0から実行環境がARTに置き換わりましたが、それまではDalvikでした。

OS2.2でJITコンパイラが搭載されたことで CPU使用率の高いコードのパフォーマンスが 最大で5倍改善されました。
OS2.3ではコンカレントGCが採用され、いわゆる”Stop the world”が改善されています。
OS5.0でランタイムがARTに置き換わり、OS7.0ではARTにJITコンパイラが採用されています。

JITコンパイラの採用によってDEXを ジャストインタイム方式で 実行形式にコンバートすればよくなるので、
アプリのインストールやアップデート、OSバージョンアップの時間が大幅に短縮されています。

ランタイムやコンパイラやGCアルゴリズムの違いによってパフォーマンスに差がでる場合もありますので、
ランタイムの違いぐらいは覚えておいて損はないと思います。

ARTはキットカットでも利用できますが オプショナルです。
標準搭載されたのはロリポップ以降ですので 78% の端末に搭載されています。

2011年 2月には OS3.0 Honeycomb がリリースされました。
なかには 黒歴史 という人もいるハニカムですが、重要なアップデートが多くあった OS です。

ActionBar, Fragment, Loader, ハードウェアアクセラレーション, ホログラフィックUIがリリースされています。
ホログラフィックUIはこのスライドデザインのように 黒背景に水色のアクセントカラーをもつテーマで、白背景もバリエーションとしてありましたが、
黒背景が印象的なUIでした。ハニカムは大画面向けのOSで、スマホ向けには配信されていません。

リリース:2011年2月 Android3.0 - Api Lv.11/12/13

New UI design for tablets… Android3.0はタブレットデバイスのような大画面向けのアップデートですが, その内容は後々スマホ向けにも展開され非常に重要なアップデート内容が多く含まれている.

ActionBar, Fragment, Loader… アプリのUI要素にActionBarが導入されました. ActionBarにはMenuキーをエミュレートするオーバーフローメニューが導入されました. また, ActivityをFragmentというサブコンポーネントに分割してMaster-Detail Flowのような柔軟な画面デザインを提供することができる. 開発者は画面の大きさが異なるスマートフォンとタブレット両方で動作するアプリケーションを効率よく作成できるようになります. またActivityやFragmentからの非同期ロードをサポートするLoaderも追加.

Holographic UI… システム全体に新しいUIテーマが適用され, デザインが一新されました. アプリはTheme.Holoを指定することでこれを適用できるようになります. Notificationの表現がリッチになり始めたのもこの頃です.

その他… クリップボードへのコピー&ペースト対応. ハードウェアアクセラレーションサポート.

2011年10月には OS4.0 IceCreamSandwichがリリースされました。
ハニカムの大画面向けUI Frameworkがスマホ向けにも移植され、統一UIフレームワークとなりました。
また、ハードウェアにMenuキーを搭載することが必須でなくなったのもこのタイミングからです。

リリース:2011年10月 Android4.0 - Api Lv.14/15

Unified UI framework… Honeycombで追加されたタブレット向け要素がスマートフォン向けにも引き継がれた. スマホでは画面が小さいことからアクションアイテムがActionBarに収まらない場合, 上下に分割するSplit ActionBarの実装もここから始まります.
ただし, SplitActionBarは現在では非推奨となっています.

MENUボタンがハードウェアに搭載されることは必須ではなくなり, オプションメニューを提供する場合はActionBarにオーバーフローメニューを配置する必要が出てきたのもこのバージョンからです.

2012年 6月には OS4.1 JellyBean がリリースされました。
16ms毎のvsyncやトリプルバッファリングによって、アニメーションやスクロールがより滑らかになりました。
Unicode6対応によって Unicode絵文字にも対応し、また, Google Play Service v1がリリースされたのもこの年です。

リリース:2012年6月 Android4.1~4.3 - Api Lv.16/17/18

Project Butter… 16ms毎のvsyncやグラフィクスのトリプルバッファリングにより, より早く, よりスムーズなユーザ体験を得られるようになりアニメーションやスクロール操作がより滑らかになりました. デバッグツールのsystraceがリリースされたのもこのタイミングです.

Unicode6.0… Unicode6.0絵文字がサポートされたのがこのOSからです. それまでの絵文字はキャリア絵文字でそれぞれ独自の文字コードが割り当てられていましたが, Unicode6.0絵文字がサポートされたことでキャリアを問わず絵文字が使えるようになりました.

Notification styles, GCM … NotificationにBigStyle/InBoxStyle/PictureStyleのスタイルが加わったのがこのバージョン. まだC2DMはGCMにリプレースされた.

その他… 2012年はAndroidMarketがGooglePlayに改名され, Androidアプリ以外のビデオや音楽も扱うストアサービスとして登場した.
GooglePlayServiceライブラリもこの頃にリリースされた. API.18でBluetooth GATTプロファイルに対応も対応した.

Unicode6対応で うれしいことは Unicode絵文字が使えるようになったことですね。
プッシュ文言にUnicode絵文字を使うサービスも増えてきましたが、Unicode絵文字が使えるのはOS4.3からで、それ以前のOSでは文字化けするものがあります。
また, 絵文字に色がついてカラフルになったのはOS4.4からです。

OS5.0では、人間に関わる絵文字はスライドにあるような黄色いキャラクターのグリフに差し替えられました。
OS6.0でUnicode 7と8をサポートし、また「お父さんの絵文字+お母さんの絵文字+子供の絵文字」を
Zero Width Joiner の文字コードで連結すると「家族」の絵文字、1文字に置き換わる仕様にも対応しています。

OS7.0ではUnicode9に対応し、5.0で対応されたnonhuman shapeのキャラクターが”人間”の見た目に戻りました。
絵文字には国や宗教、人種、思想に配慮した仕様になっていて複雑ですが、「human shape」な絵文字と Skin toneの文字コードを繋げることで
絵文字の肌の色を変えることができるようになり、絵文字のバリエーションがグッと増えました。

一応、国内キャリア端末は標準絵文字グリフをキャリア絵文字のグリフで上書きしているので、
OSが同じでもキャリアによって絵文字の見た目に違いがでる問題があることも, ここに付け加えておきます。

それぞれのUnicodeバージョンを搭載している端末の割合はこちらの通りで、
Unicode6が 94%、Unicode 7&8が 50%、Unicode 9が 18%です。

2013年 10月にはOS4.4 Kitkatがリリースされました。
エントリーレベルのデバイスでも動作できるように設計されたOSでストレージアクセスフレームワークが搭載されたのもここからです。
WebViewのアップデートもありましたが そちらは後ほどお話しします。

リリース:2013年10月 Android4.4 - Api Lv.19

Support 512MB RAM device… エントリーレベルのデバイスであっても動作するように設計されたOSで, アプリもActivityManager.isLowRamDevice() APIを使うことで低スペックデバイス向けのコンフィグレーションが可能になりました.

Storage Access Framework… これまで端末内のファイルをユーザに選択させたり, 保存場所を指定させる場合に使われるファイルエクスプローラはOSから提供されていませんでした. ストレージアクセスフレームワークを使うことでユーザに一貫したファイルシステムへの参照方法を提供できるようになりました.

Chromium WebView… WebKitがChromiumベースに差し代わりました. これよりChrome Dev Toolsによるリモートデバッグもサポートされるようになった.

その他… RTLサポートが強化されました. それまではテキストの対応しかなく, リソースを重複して持つ必要がありました.

Android4.4からは, バッテリー消費を抑えるためにアラームの発火タイミングが不正確になります。
4.4以降、どうしても正確なアラームが欲しい場合は AlarmManager の setWindow() か setExact() を使うことになります。

Android6.0は Dozeによるデバイスアイドル状態では アラームの発火が保留されます。
アイドル状態でもアラームを正確に発火させたい場合は setAndAllowWhileIdle() か setExactAndAllowWhileIdle() を使うことになります。

ちなみに、アラームはアプリごとに9分間に1回以上発火はされない仕様です。

2014年10月にはOS5.0 Lollipop がリリースされました。
マテリアルデザインによってUI/UXが大きく変更され、ベクタードロワブル や マルチユーザのサポート、
ARTの標準搭載、CPUの64bitアーキテクチャサポートなど、幅広いアップデート内容になっています。

リリース:2014年10月 Android5.0 - Api Lv.21/22

Material Design, Project Volta… マテリアルデザインの導入でUI/UXが大きく刷新された. RecyclerViewやZ軸, シャドウの概念もここから.
また, バッテリー消費を抑えて電池持ちを改善するプロジェクトProject Voltaが明らかにされました. ジョブスケジューラの機能が提供されたことにより, アプリの動作が最適化されバッテリー消費を抑えることに貢献しています.

Overview, Notification, Multi-user… OverviewはこれまでRecentsと呼ばれていた”最近使ったアプリーケション一覧”の機能に相当するものです.
従来は使ったアプリケーションのリストが並ぶだけでしたが, ここに複数のActivityをドキュメントとして追加することができるようになり, マルチタスクにも使えるようになりました. また, Notificationにはプライオリティやカテゴリの概念が追加され, 重要な通知がヘッドアップ表示されるようになったのもこの頃です.

64bit, ART… また, パフォーマンス改善も行われ, ARTランタイム対応や64bit対応もここから始まりました.

その他… Chromium WebViewがPlayStoreからアップデートできるようになった. AndroidHttpClientのメンテナンスが終了・廃止されURLConnectionの使用が必須に. API Lv20はAndroid Wear向けのAPI Lvとして割り当てられた. またディベロッパープレビュー版という提供方法が始まったのもここから.

WebView周りの変更についてみてみると、4.3までのWebViewは WebKit上で動作していましたが, 4.4以降はChromium上で動作します。
5.0では Google Play経由で アップデート可能になり、WebViewのセキュリティパッチが素早くユーザに届けられるようになりました。
WebViewに依存したアプリの開発者は WebViewのアップデート頻度が高くなったので注意する必要があります。

7.0 以降は Chrome APKから WebViewを提供する機能が搭載されています。これによって、メモリ消費が改善されました。
8.0では アプリのWebView がマルチプロセスモードで実行されます。ウェブコンテンツはアプリのプロセスとは別の独立したプロセスで処理されるので、
セキュリティが強化されています。

また、ここに記載していませんが Chrome custom Tab の機能がOS4.1以降で利用できるようになっています。

Chromium版は 93%の端末 に搭載されていて, WebViewを個別にアップデート可能な端末は 78% です。

2015年 10月にはOS6.0 マシュマロがリリースされました。
このあたりからアプリの挙動を変えるアップデートが目立つようになりました。
RuntimePermission, Doze, AppStandbyなどです。

リリース:2015年10月 Android6.0 - Api Lv.23

RuntimePermission… パーミッションモデルに大きな変更が入りました. ユーザはアプリのパーミッションを管理できるようになり, 好きなタイミングで権限を付与/剥奪できるようになります. また, アプリインストール時にパーミッション許可を求めることはせず, アプリの任意のタイミングでユーザにパーミッション付与を求めるようになります.

Doze, App Standby… 電源に接続していない状態で, 一定時間端末を画面オフで放置していた場合にスリープ状態を維持するDozeや, アプリが長時間アイドル状態であった場合にアプリのネットワークアクセスが無効になり, 同期とジョブが保留されるようになりました.

AutoBackup, Do not disturb… アプリデータが自動でGoogle Driveへバックアップできるようになりました. 追加のコードは必要ありません. バックアップを無効にする場合はマニフェストに1行無効にするフラグを定義します. また, Do not disturbモードもこのバージョンからです.

その他… Apache HTTP clientが削除. OpenSSLからBoringSSLに移行. TextSelectionもそれまでの編集モードからpopup windowでアクションを選択するUIに変更されました.

6.0のDoze機能は, 画面OFF かつ 充電中ではない場合 かつ 端末をほとんど動かさない静止状態 にし続けると、
CPU と ネットワーク通信 を一時保留して バッテリーの寿命を延ばす 省電力機能が働きます。

7.0ではDoze状態になる条件が緩和されて、端末が静止していなくてもDoze状態に入ります。
これによって、ポケットにスマホを入れて持ち歩いているような状況でも バッテリー消費を抑えることができるようになりました。

Dozeがリリースされたのは Android マシュマロ以降なので 50%の端末 がこれを搭載しています。

2016年8月には OS7.0 Nougat がリリースされました。
マルチウィンドウやRAMの使用量を削減するProject Svelteによって一部のブロードキャストが廃止されました。
また、ランチャーアイコンにまつわる変更もあります。

リリース:2016年8月 Android7.0/71 - Api Lv.24/25

Multi-window, Screen zoom… スマートフォンやタブレットで画面を分割して2つのアプリを並べて利用できるようになり, AndroidTVではピクチャーインピクチャーがサポートがサポートされました. また視力が低いユーザ向けの補助機能としてスクリーンズームが搭載され, 端末の画面密度設定が変更可能になりました.

Doze2, File security… 従来はDozeモードに突入するためには端末が静止状態である必要がありましたが, Doze2ではこの制限がなくなりました.
また, プライベートディレクトリのアクセス権限が厳格化され, 他アプリにファイルを直接読み書きさせることができなくなりました. これに伴いfileスキームのURIを含むIntentを共有しようとするとセキュリティ例外が投げられるようになっています.

Project Svelte… アプリのバックグラウンド実行を最適化することでRAMの使用量を削減する取り組み. CONNECTIVITY_ACTION、ACTION_NEW_PICTURE、ACTION_NEW_VIDEOの暗黙的なブロードキャストが削除されました. これらのブロードキャストは複数のアプリが同時に起動するため, メモリが逼迫しシステムのパフォーマンスを低下させる要因になるためです.

その他… 3DレンダリングAPIのvulkanがプラットフォームに統合, データセーバ機能の搭載, WebViewがChrome APKから提供される, VRサポート, App Shortcutなど. またこのタイミングでApache HarmonyベースからOpenJDKベースに移行された.

OS4.3 では 一般的なサイズよりも大きく アプリアイコンを表示するランチャーアプリに対応するため,
端末の抽象解像度ではなく、リクエストされたサイズに応じてリソースを返す mipmapリソースがサポートされました。

OS7.1では アプリアイコンを丸く表示するランチャーアプリが増えたため, アプリから丸いアイコンを提供するRound Iconリソースが追加されています。
OS8.0では さらに元のデザインを崩すことなく、自由にアプリアイコンの形を変えることができるAdaptive Iconがサポートされています。

そして 2017年8月の Android8.0 現在に至ります。

リリース:2017年8月 Android8.0 - Api Lv.26

Background execution limits… バックグランドによる動作が大きく制限されました。サービスを開始してもアプリがバックグラウンドに遷移するとサービスは自動で停止されます。
バックグランドからサービス開始したい場合はContext.startForegroundServiceメソッドをコールし, 5秒以内にフォアグラウンドサービスに昇格させる必要があります。
また、暗黙的なブロードキャストも制限されはじめ、JobShcedulerへの移行が推奨されています。

Notification dots, XML font… アプリの通知がランチャーアイコンにドットで表現されるようになったり, フォントをリソースとして扱えるXMLフォントの機能が導入されました。

Alert windows… システムウィンドウより上にアラートウィンドウを表示できなくなりました。アプリはTYPE_APPLICATION_OVERLAYウィンドウを使うことができます。

その他… HttpsURLConnectionが古いTSLバージョンへフォールバックする動作をやめる, WebViewのマルチプロセスモード実行, ANDROID-IDの処理方法変更, クリッカブルなViewがデフォルトでフォーカス可能に変更, スマホ/タブレットでのピクチャーインピクチャーモード対応, AppShortcutの改善, アダプティブアイコン, 最大アスペクト比, マルティディスプレイ, JobScheduler改善など

Android8.0 ではバックグラウンドで実行する動作を制限していますが、多くの場合 ジョブスケジューラに置き換えることができます。

これは ディベロッパーサイトの Intelligent Job-Scheduling というページからの引用で、
 ジョブを賢くスケジューリングすることで、バッテリ寿命といったシステム状態とともに、アプリのパフォーマンスも向上できます。
と書かれています。

バッテリー寿命というのは、モバイルユーザ体験の重要なポイントです。

Android を よりスマートで、より早く、よりパワフルなプラットフォームに仕上げるには、OSのバージョンアップだけではなく、
アプリの最適化によるバージョンアップも必要不可欠です。

OSに最適化する作業は大変ですが、プラットフォームにも デバイスにも また, ユーザにも優しいアプリ開発を心がけたいものです。

発表は以上ですが、本日紹介した内容は時間の都合上、細かな内容は省いています。
みなさんのサービスに関わる部分で気になるものがありましたら、これらのページを参考にしてみてください。



2017/07/25

VisibleForTestingとRestrictTo

昨日, メッセージの表示頻度を簡単に調整できるライブラリdenbunをリリースしました.

Denbun

初めてのライブラリリリースなので色々と学びがありました.
本稿ではVisibleForTestingRestrictToアノテーションについて書き留めます.

VisibleForTesting

フィールドやメソッドのスコープはできるだけ狭くすることが大切ですが, テスタビリティを確保するためにやむなくスコープを広くとる場合があります.
VisibleForTestingは, スコープをテスタビリティのために広く定義していることを明示します.

例えば, Denbunライブラリでは情報の永続化先であるSharedPreferenceとのI/Oをフックできるようにしてテスタビリティを確保しています.

@VisibleForTesting(otherwise = PACKAGE_PRIVATE)
public DenbunConfig daoProvider(@NonNull Dao.Provider provider) { ... }

このメソッドはプロダクションコードではPackage Privateスコープで扱われることを想定し, テストコードではPublicスコープで扱われることを想定しています.
そのため本来あるべきスコープはPackage Privateなのですが, テスタビリティのためにPublicとしています.

メソッドが本来あるべきスコープはVisibleForTestingアノテーションのotherwiseパラメータに指定します.
こうすることで, プロダクションコードにおいてPackage Privateスコープ外からアクセスしてきた場合にインスペクションによる警告が表示されるようになります.

ただし, このアノテーションはクラスファイルに影響を及ぼすものではないので, インスペクションの警告を無視して無理やり要素にアクセスすることは可能です.

VisibleForTestingの真価は, このアノテーションで指定された要素をプロダクションコードで呼び出すとインスペクションの警告によって使い方が間違っていることを教えてくれるところにあります.
これは, javadocにコメントを残す対応よりもはるかに効果的で簡単です. また, 利用側に実装者の意図をインスペクションを通して伝えることができるので利用側にとっても嬉しい機能です. 実際のライブラリ開発では手軽に導入できてアクセス制御で悩むことも減るのでとても便利に使えます.

ただ, 実際にはアクセス制御できていないので, APIを公開することが致命的であるケースにおいてはイミュータブルインタフェースをかませるなどの対応が必要です. (そのようなケースはあまり思い浮かびませんが, セキュリティが必要なSDKなどでは該当しそうです)

RestrictTo

次にRestrictToアノテーションです. これはテストのために用意されたメソッドであることを明示するものです.
VisibleForTestingはテスタビリティのための”スコープ”に着目しているので, そのメソッド自体は想定されるスコープ内であればプロダクションコードで呼ばれることが許されています.
例えば, VisibleForTesting(otherwise = private)なメソッドであればプロダクションコードでもクラス内(privateスコープ内)からの呼び出しが想定されているということです.

一方で, RestrictToはメソッド自体の存在に着目しています.
RestrictTo(TEST)であれば, テストコードからの呼び出しのみを想定しており, プロダクションコードでの呼び出しは想定されません. RestrictTo(LIBRARY)であれば, ライブラリ内での用途に限った要素であることを明示しています.
これはライブラリを作る側としてはとても強力です. これもVisibleForTestingと同じく, 呼び出し側が想定外の呼び出しを行なった場合にインスペクションの警告を表示します.

例えば, Denbunライブラリでは, DenbunBoxの初期化は一度しか行えず, 2回目以降はno-opになるよう実装されています.
しかし, UnitTestをする際にテストケースごとにDenbunBoxを再初期化したくなる場合も想定して, DenbunBoxの状態をリセットするreset()メソッドを用意しています.

@RestrictTo(TESTS) public static void reset() { ... }

このメソッドは, ライブラリ内部および, プロダクションコードからの呼び出しも想定していません. テストに限定した利用を想定したものです.

ライブラリを作る際には, こういったアノテーションも活用して, 利用する側に作り手の意図を明示するのも大切だなと感じました.

以上です.

Denbunライブラリでメッセージの表示頻度を調整する

tl;dr

はじめに

モバイルプラットフォームでは, ユーザ向けに何かしらのメッセージを表示することがよくあります.
それは, イベントの発生を知らせるものであったり, ユーザのアクションが完了したことを知らせるものであったり, エラーの発生を知らせるものであったりと様々です.
これらのメッセージは重要なものですが, 中には退屈と思われてしまうものもあります.

  • ユーザがアプリケーションの振る舞いを学習するのに重要なメッセージが, アプリケーションを使い慣れた後になっては, ただのお節介なメッセージになってしまうケース
  • 毎回閉じるだけの”お知らせダイアログ”といった類のもの
  • コンテンツの削除確認といった誤操作防止目的のもの
  • Backキーを押した際の「アプリケーションを終了しますか?」なもの

ユーザを退屈させないためにも, メッセージの表示頻度を調節することが重要です.

Denbun

メッセージの表示頻度を調整するためのアプローチはいくつかあります.

  • ダイアログに「今後表示しない」チェックボックスをつけてユーザ主動でダイアログ表示をやめさせる方法
  • 一度しか表示しないような回数限定メッセージ
  • 一週間のうち決まった曜日にだけ表示する定期的なメッセージ など…

これらのアプローチをとるためには, 表示設定や表示回数といった内容を永続化して都度, 表示頻度を調整する必要があります.
そこで, メッセージの前回表示時間や表示回数といった情報を保存し, 表示頻度の調整をサポートするDenbunライブラリをリリースしました.



このライブラリは, 次のようなメッセージ通知を実現したい場合に有効です.

  • 「今後表示しない」 オプション付きメッセージ
  • N回だけ表示するメッセージ
  • 定期的に表示するメッセージ(1週間に1回の頻度で表示. 月曜日に1回だけ表示. etc.)
  • N回表示した後は, n時間経過するまで表示しないメッセージ

メッセージの表現系(Dialog, Toast, Snackbar, etc.)は問いません.
このライブラリは, メッセージの前回表示時間や表示回数をSharedPreferenceに保存しており, これらの情報を駆使して”今, メッセージを表示すべきかどうか” を判断することで, メッセージの表示頻度を調整します.

使い方

まず初めに, Application.onCreateなどで, DenbunBoxを初期化します.
DenbunBoxはこのライブラリの起点となる重要なクラスです.

DenbunBox.init(new DenbunConfig(this));

DenbunBoxの初期化が終わったら, メッセージを表現するDenbunインスタンスを取得します.
メッセージの表示頻度の調節はこのDenbunインスタンスを通して行います.

Denbun msg = DenbunBox.get(ID);

Denbunインスタンスのshow()を呼び出すことで, 表示時間や表示回数の情報が更新され永続化されます.

Denbun msg = DenbunBox.get(ID);
msg.shown();

メッセージの最適な表示頻度はメッセージ毎に異なりますので, Denbunインスタンスを取得する際に最適な表示頻度を算出できるFrequency Adjusterを指定します.
例えば, 下記の例は1回限りのメッセージ通知を実現する例です.

// This message is displayed only once.
Denbun msg = DenbunBox.get(ID, new CountAdjuster(1));
...
msg.isShowable(); // true
msg.shown();
msg.isShowable(); // false

あるいは, メッセージを直接的に今後表示しなくすることも可能です.

Denbun msg = DenbunBox.get(ID);
msg.suppress(true);

メッセージによっては表示頻度の計算が複雑になるものもあるでしょうから, Frequency Adjusterは自前のものを実装してDenbunBox.getに指定することもできます.

実際にDialogやToastを表示する際には, DenbunインスタンスのisShowable()の値を確認してから表示すると決められた頻度でメッセージを表示することができます.

テスタビリティ

Denbunライブラリを使ったコードをテストしたい場合は下記が参考になります.
DenbunConfigにはDenbunライブラリとSharedPreferenceのI/Oを取り持つDAOのgetter/setterが用意されています(このメソッドは@VisibleForTestingです)

DenbunConfig conf = new DenbunConfig(app);

// spy original DaoProvider
Dao.Provider origin = conf.daoProvider();
conf.daoProvider(pref -> (spyDao = spy(origin.create(pref))));
DenbunBox.init(conf);

DenbunBox.find(ID).shown();
verify(spyDao, times(1)).update(any());

おわりに

Denbunライブラリを使い始めるには次の一文をbuild.gradleに追記するだけです.
<latest version>には最新のライブラリバージョンを指定してください.

compile 'com.yuki312:denbun:<latest version>'

近々v1.0.0をリリース予定です.
PRやIssueがあればGitHubの方に登録していただけると幸いです.

以上です.

2017/07/20

Intentの共有先一覧から自アプリを除外する

他アプリ起動周りでちょっとハマったのでメモ.

テキストやURIを暗黙Intentで共有する場合, 自アプリがそれに反応するintent-filterを持っていると, ActivityChooserに表示候補として含まれてしまう場合があります.
自アプリで捌きたくないから他アプリに共有しているのに, そのリストに自アプリが載っているのはよろしくない.
ということで, Intentは投げるけれどActivityChooserに自アプリを含めない方法を探りました.

TL;DR

  • createChooser, ChooserActivityまわりの挙動がOSバージョンで異なっている
  • API LV.23 前後でPackageManager.MATCH_DEFAULT_ONLYの振る舞いが変わる
  • API LV.23 前後でActivity選択ダイアログのレイアウトが変わる
  • 結論queryIntentActivitiesからの自前ダイアログ生成のが楽そう

シンプルにqueryIntentActivitiesIntent.createChooserを組み合わせればできるだろうと思っていたのですが, 古いOSで確認したところ意図した通りに動きませんでした.
で, 古いOSでの動作もサポートすべく, 色々検討した結果を残しておきます.

createChooser

Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
int flag = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PackageManager.MATCH_ALL
    : PackageManager.MATCH_DEFAULT_ONLY;
List<ResolveInfo> launchers 
  = context.getPackageManager().queryIntentActivities(intent, flag); // *a

// 自アプリを起動対象から除外する
List<Intent> intents = new ArrayList<>();
for (ResolveInfo app : launchers) {
  if (context.getPackageName().equals(app.activityInfo.packageName)) {
    continue;
  }
  Intent target = new Intent(intent);
  target.setPackage(app.activityInfo.packageName);
  intents.add(target);
}

if (intents.isEmpty()) {
  // 起動対象のアプリが見つからなかった
} else {
  // createChooserの第一引数のIntentに反応できるアプリが存在しない場合は EXTRA_INITIAL_INTENTS
  // の指定が無視されるため, 必ず反応できるIntentを設定する目的でremove(0)を指定する.
  Intent chooser = Intent.createChooser(intents.remove(0), title); // *1
  chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, intents.toArray(new Parcelable[0])); // *1
  context.startActivity(chooser);
}

ポイントは *1 の部分で, 下記のコードではAPI Lv.23未満だとうまく動作しませんでした.

  Intent chooser = Intent.createChooser(new Intent(), title); // *1
  chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, intents.toArray(new Parcelable[0])); // *2

EXTRA_INITIAL_INTENTSに目的のIntentを設定すればうまくいきそうなものですが, API Lv.23未満だと *1 の第一引数Intentに反応できるActivityの数が0であった場合に EXTRA_INITIAL_INTENTS が無視される挙動になります(つまりActivityNotFound)
API Lv.23以上ではEXTRA_INITIAL_INTENTSが評価されます.

API Lv.23未満でcreateChooserの第一引数に渡すIntentは, 少なくとも1つ以上のActivityが反応できる必要があるので下記のようなコードになりました.

Intent chooser = Intent.createChooser(intents.remove(0), title); // *1
chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, intents.toArray(new Parcelable[0])); // *2

MATCH_ALL

*a で, PackageManager.MATCH_DEFAULT_ONLY はAPI Lv.23から挙動が変わっています.
API Lv.23未満だと, Category.DEFAULTに反応するActivityを抽出するものでしたが,
API Lv.23以上だと, 「既定で開く」設定されたActivityがある場合はそのActivityしか返却されなくなりました. API Lv.23以上でAPI Lv.23未満と同じ挙動にするためにはAPI LV.23から追加されたPackageManager.MATCH_ALLを指定する必要があります.

int flag = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PackageManager.MATCH_ALL
    : PackageManager.MATCH_DEFAULT_ONLY;
List<ResolveInfo> launchers 
  = context.getPackageManager().queryIntentActivities(intent, flag); // *a

より便利にいくなら, API Lv.23以上でもMATCH_DEFAULT_ONLYResolveInfoを拾って, 「既定で開く」設定が自アプリになっていなければそのまま起動, 自アプリであれば上記の処理を実行するとすればいけそうです.

この処理でうまくいきましたが, デバイスによってはシェアダイアログのレイアウトが下記のように残念な結果に :(

動作をみる限りでは, createChooserに渡したIntentが1行目に並び, EXTRA_INITIAL_INTENTSに渡したIntentが2行目に並んでいる様子.
これを解決するならシェアダイアログを自前で組む必要がありそうです.
(あるいはAPI Lv.23ではcreateChooserの第一引数にどのActivityにもマッチしないnew Intent()といったIntentを指定するなど…)

API Lv.24からEXTRA_EXCLUDE_COMPONENTSなる定数も追加されているので, API Lv.24以上はこれを使えということかもしれませんが, こんなことにOSバージョン分岐させるのも面倒なので, 手っ取り早くやるならqueryIntentActivitiesからの自前ダイアログ作成が安定しているという結論に落ち着きました.

以上です.