fresh digitable

めんどくさかったなってことを振り返ったり振り返らなかったりするための記録

ImageSpanを使って画像をTextViewの中にいい感じに表示する

(これはAPI Level 25(Android 7.1.1)のNexus5XおよびAPI Level 23(Android6.0)のNexus5(エミュレータ)で確認した)

TextViewsetCompoundDrawables(Drawable, Drawable, Drawable, Drawable)を使うと文字領域の上下左右に一つずつDrawableを置くことができるが、文章の中に画像を置いたり、例えば右の領域にDrawableを二つ並べて置くということはできない。そんな時の代替手段の一つにImageSpanがある。HTMLのimgタグに相当するものだろうか。

https://developer.android.com/reference/android/text/style/ImageSpan.htmldeveloper.android.com

これを使って、

RT by [アイコン画像] @screen_name

とか、

ユーザ名 @screen_name [認証マーク] [鍵マーク]

のように文字と画像とを一列に並べようとしていたところ、画像の底辺をどこに合わせるか指定する引数にALIGN_BOTTOMを選ぶ(デフォルト)と上の方に不思議な隙間ができてしまったのでどんな処理をしているのか見てみることにした。軽くぐぐってみると高さが合わない問題はすでに認識されている様子(たとえば、複数行のTextViewでImageSpanが下付きに描画される事象を回避する - Qiita)。この情報にならってDynamicDrawableSpan#draw()をオーバライドして解決を試みようとしたのだが、問題はどうもDynamicDrawableSpan#getSize()の方にあるのではないかという気がした。その理由を次から説明する。

DynamicDrawableSpan#draw()の引数の意味

DynamicDrawableSpan | Android Developersをみると引数についての説明がある。ここで重要なのは次の三つ。

  • top: 行(Canvas)の上辺(ふつう0?)
  • y: ベースライン
  • bottom: 行(Canvas)の底辺

DynamicDrawableSpan#getSize()では何をやっているのか

Cross Reference: /frameworks/base/core/java/android/text/style/DynamicDrawableSpan.java

(2016/12/19 12:28 追記)親クラスgetSize()draw()の説明がある:Cross Reference: /frameworks/base/core/java/android/text/style/ReplacementSpan.java

getSize()の戻り値はintなのだが、これはdraw()で描こうとするものの幅がどれだけかという意味。なのでgetDrawable().getBounds().rightを返せばよいだけのはずなのだが、ついでに引数であるPaint.FontMetricsIntの中身を書き換えている。具体的には次に挙げる変数の値を書き換えているのだが、これはdraw()で描こうとするものの高さ方向のデータになる。

  • top: グリフの中で最も背の高い文字の、ベースラインから上側の距離(負の数)
  • ascent: 一つの文字で推奨されるベースラインから上側の距離(負の数)
  • descent: 一つの文字で推奨されるベースラインから下側の距離(正の数)
  • bottom: グリフの中で最も足が長い文字の、ベースラインより下側の距離(正の数)

ちなみにこの説明は、Paint.FontMetricsの方にしか書いてない。topascentが負の数である理由は、スクリーンの座標系が上から下に向かって大きくなっているためらしい。ここで設定した値に応じてdraw()topybottomの値が決まるようである。

getSize()の元の処理は、ベースラインから上側の距離をDrawableの高さと等しくして、ベースラインから下の距離を0に設定している。その上でdraw()ではbottomDrawableの底辺を揃えて、ALIGN_BASELINEの時にはさらにベースラインからdescentの分だけ上に引きあげようという計算をしている。つまり、ベースラインの上側にDrawableの高さ分のスペースを確保しているのに、それよりさらに低いbottomの位置に底辺を合わせているので、ALIGN_BOTTOMの時にはbottomの分だけDrawableの上に隙間ができるということである。その行の他の文字がベースラインの下側を使っているために、getSize()で指定したbottom=0が却下されてしまうのではないかと思われる。

上の隙間をなくすには

画像の領域をベースラインの上側にのみ確保しようとしているのがそもそもおかしいのではないか。そうしたいのはALIGN_BASELINEを指定した時だけのはず。なので単純に次のようにしてみた。ポイントはDrawableのベースラインをどこにするか。getCachedDrawable()privateメソッドなので再定義する必要がある。

(2016/12/21 22:37:paint.getFontMetrics()を使って計算するよう修正)

  @Override
  public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
    final Rect bounds = getCachedDrawable().getBounds();
    if (fm != null) {
      final Paint.FontMetrics fontMetrics = paint.getFontMetrics();
      final int verticalAlignment = getVerticalAlignment();
      if (verticalAlignment == ALIGN_BASELINE) {
        fm.ascent = -bounds.bottom;
      } else if (verticalAlignment == ALIGN_BOTTOM) {
        fm.ascent = (int) (bounds.bottom * fontMetrics.top / (-fontMetrics.top + fontMetrics.bottom));
      }
      fm.descent = Math.max(bounds.bottom + fm.ascent, 0);
      fm.top = fm.ascent;
      fm.bottom = fm.descent;
    }
    return bounds.right;
  }

  @Override
  public void draw(Canvas canvas,
                   CharSequence text, int start, int end,
                   float x, int top, int baseline, int bottom,
                   Paint paint) {
    final Drawable cachedDrawable = getCachedDrawable();
    final int drawableHeight = cachedDrawable.getBounds().bottom;

    canvas.save();
    int transY = top;
    if (getVerticalAlignment() == ALIGN_BASELINE) {
      transY = baseline - drawableHeight;
    } else if (getVerticalAlignment() == ALIGN_BOTTOM) {
      transY = bottom - drawableHeight;
    }
    canvas.translate(x, transY);
    cachedDrawable.draw(canvas);
    canvas.restore();
  }

例に挙げた先のページでは、複数行のTextViewでline spacingを1.5に設定すると画像が下付き文字のようになってしまうという現象が確認されているが、これはdraw()の引数であるbottomが行間の高さも含んでいるためではないかと考えられる。Paint.FontMetricsIntは行間の高さをleadingという変数名で持っている。このleadingの分だけ上に移動させてやることで意図した通りに表示できるのではないかと思われる。上のソースでいうと

      transY = bottom - drawableHeight;

      transY = bottom - drawableHeight - paint.getFontMetricsInt().leading;

にするという感じ。これは手元では確認していないので間違っているかもしれない。

今後の展望

中央揃えができたらいいなと思っている。一連のソースは[WIP] introduced RetweetUserView to replace RT user container by akihito104 · Pull Request #166 · akihito104/UdonRoad · GitHubに上がっており、そのうちマージされる予定。パッチ書いてAOSPに取り込まれるのが一番いいんだと思うんだけどいろんなケースに対応できるかどうかはちょっと自信がない。でもチャレンジしてみるのもいいのかな。

関係ありそうなissueとか

Issue 21397 - android - ImageSpan ALIGN_BASELINE works incorrectly on last line only: caused by bug in DynamicDrawableSpan - Android Open Source Project - Issue Tracker - Google Project Hosting

android - Align text around ImageSpan center vertical - Stack Overflow

Html.fromHtml()はカジュアルに使うものではない?

拙作のツイッタークライアントが、起動して3時間ほど放置しておくと全く動かなくなってしまうようになった。どこか触るとANRが出て落とせるので、その都度traces.txtを見ると、いつもHtml.fromHtml()を呼んでるところで止まっている様子。

内部ではパーサーオブジェクトのイニシャライズでnew char[2000]をやっているところだった。

Cross Reference: /external/tagsoup/src/org/ccil/cowan/tagsoup/Parser.java

名前やコメントから察するにこの配列はHTMLのコメント部分を読み込む時に使うバッファのようなのだが、メンバ名で検索しても宣言の箇所しかヒットしない。可視性がprivateなのでなんのためにあるのかわからなかった。

traceviewで見ると処理時間も結構かかるようだし、せいぜい1行のTextViewに使うには大げさなのかなと思ってなるべく呼ばないようにする工夫を入れた。固まってしまう原因はまだよくわからないので経過を見守ることにする。

(これはAndroid 6.0で確認した)

FragmentManager is already executing transactionsと言われた時、Fragmentの初期処理で例外が起きていないか確認する

(この現象はAndroid6.0, support-lib ver.24.2.1で確認した。)

次のような例外が出た。メッセージを読むと「トランザクションをすでに実行しているところだ」と言われているようなので、commit()するタイミングが悪かったのかななどを考えていろいろ試してみるも効果なし。ググるcommitNow()するといいよみたいなStackOverflowのやり取りが出てきたりするが、効果なし。

java.lang.IllegalStateException: FragmentManager is already executing transactions
at android.support.v4.app.FragmentManagerImpl.execSingleAction(FragmentManager.java:1626)
at android.support.v4.app.BackStackRecord.commitNowAllowingStateLoss(BackStackRecord.java:679)
at android.support.v4.app.FragmentPagerAdapter.finishUpdate(FragmentPagerAdapter.java:143)
at android.support.v4.view.ViewPager.populate(ViewPager.java:1240)
at android.support.v4.view.ViewPager.populate(ViewPager.java:1088)
at android.support.v4.view.ViewPager.onMeasure(ViewPager.java:1614)
at android.view.View.measure(View.java:18788)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:5951)
at android.support.design.widget.CoordinatorLayout.onMeasureChild(CoordinatorLayout.java:700)
at android.support.design.widget.HeaderScrollingViewBehavior.onMeasureChild(HeaderScrollingViewBehavior.java:90)
at android.support.design.widget.AppBarLayout$ScrollingViewBehavior.onMeasureChild(AppBarLayout.java:1364)
at android.support.design.widget.CoordinatorLayout.onMeasure(CoordinatorLayout.java:765)
at android.view.View.measure(View.java:18788)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:5951)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:194)
at android.support.v7.widget.ContentFrameLayout.onMeasure(ContentFrameLayout.java:135)
at android.view.View.measure(View.java:18788)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:5951)
at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1465)
at android.widget.LinearLayout.measureVertical(LinearLayout.java:748)
at android.widget.LinearLayout.onMeasure(LinearLayout.java:630)
at android.view.View.measure(View.java:18788)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:5951)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:194)
at android.view.View.measure(View.java:18788)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:5951)
at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1465)
at android.widget.LinearLayout.measureVertical(LinearLayout.java:748)
at android.widget.LinearLayout.onMeasure(LinearLayout.java:630)
at android.view.View.measure(View.java:18788)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:5951)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:194)
at com.android.internal.policy.PhoneWindow$DecorView.onMeasure(PhoneWindow.java:2643)
at android.view.View.measure(View.java:18788)
at android.view.ViewRootImpl.performMeasure(ViewRootImpl.java:2100)
at android.view.ViewRootImpl.measureHierarchy(ViewRootImpl.java:1216)
at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:1452)
at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:1107)
at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:6013)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:858)
at android.view.Choreographer.doCallbacks(Choreographer.java:670)
at android.view.Choreographer.doFrame(Choreographer.java:606)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:844)
at android.os.Handler.handleCallback(Handler.java:739)
at android.os.Handler.dispatchMessage(Handler.java:95)
at android.os.Looper.loop(Looper.java:148)
at android.app.ActivityThread.main(ActivityThread.java:5417)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:726)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:616)

スタックトレースをみるとViewPagerの各ページを作る途中に起きているということぐらいはわかるが、私の書いたどのコードが発端になっているのかまではわからなかった。スタックトレースの一番上のメソッドを見てみると、

    public void execSingleAction(Runnable action, boolean allowStateLoss) {
        if (mExecutingActions) {
            throw new IllegalStateException("FragmentManager is already executing transactions");
        }

        if (Looper.myLooper() != mHost.getHandler().getLooper()) {
            throw new IllegalStateException("Must be called from main thread of fragment host");
        }

        if (!allowStateLoss) {
            checkStateLoss();
        }

        mExecutingActions = true;
        action.run();
        mExecutingActions = false;

        doPendingDeferredStart();
    }

となっている。mExecutingActions==trueであるために起きているということなのだが、これを書き換えているのはそのすぐ下と、FragmentManagerImpl#execPengingActions()の中。どっちもRunnable#run()の前後にフラグのon/offをやっている。Runnable#run()のなかで例外が起きてしまうとmExecutingActionがtrueのままになるのか、と考えて追加したコードのバグを取ったら期待したとおりに動いた。

FragmentManagerImpl#execSingleAction()は比較的最近追加されたAPIであるようす。action.run()の例外がどこで握りつぶされているのかなどの解析はまだしていない。このエラーメッセージでぐぐる日本人の時間を少しでも救えればいいかなと思って書いた。