LoginSignup
22

More than 3 years have passed since last update.

【Android】動的Layout生成に潜む罠

Last updated at Posted at 2019-12-01

ついにきましたね、[CA Tech Dojo/Challenge/JOB Advent Calendar 2019]、初日は@hohohorisが書かせていただきます。

自分は、2019年8月にCA Tech Dojo(Kotlin編)に参加させていただいた後、10月にCA Tech JOBでCATS(CyberAgent Advanced Technology Studio)でAndroidエンジニアとしてインターンをさせていただきました。

詳しくはインターン参加記を書いたので是非。

CA Tech Dojo(AndroidアプリKotlin編)という最高のインターンで最優秀賞をもらった話
【CA Tech JOB】CA Tech Dojoからの成長

概要

この記事ではAndroid開発で避けては通れないLayout周りの話をします。
個人アプリでCloud Firestoreを使おうとしていたのでその話をしたかったのですが、諸々間に合わず初日からニッチ目な記事を書きます...。

タイトルにある通り、Androidで動的にレイアウトを生成する際に自分がつまづいたのでそのときに得られた知見を共有したいと思います。

例えばFragment内のDataBindingのinflateの際も割と脳死で

HogeFragment
    override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?,savedInstanceState: Bundle?): View? {
      binding = DataBindingUtil.inflate(context, R.layout.fragment_hoge, container, false)
      return binding.root
    }

って書いてるだけの人、いると思います。

以前の自分です。

このinflateの方法、全てに通用すると思っていませんか?意味、わかってますか?
その辺りを書いていきたいと思います。

基本的にLayoutの生成全般に関わることですが、よく使われているDataBindingにフォーカスしていきます。
ので、Android初心者/Androidを書いたことがない読者にもわかるようDataBindingとは、から説明します。

DataBinding

DataBindingは、MVVMと相性の良い技術で、レイアウトのViewとオブジェクトのデータを紐づける機能です。
MVVMやその他アーキテクチャの共通している思想として、「責務の分離」があります。
UIの表示をするレイヤーと、ロジックを記述するレイヤーを分けることにより、テストがしやすく、それぞれの責務が明確なため運用もしやすくなるメリットがあります。
その際、データソースとUIとの紐づけを完結に記述できるライブラリがDataBindingなのです。

beforeとafterでサンプルコードを見てみるとわかりやすいと思います。

before

DataBinding導入前

activity_main.xml
  <TextView
   android:id="@+id/txtName"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
  />
MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        viewModel.user.observe(this, Observer { 
            textName.text = it.name
        })
    }

コードは端折っていますが、ViewModelの何かしらのメソッドによりLiveDataなuserに更新があったらxmlのtextを変更するというシンプルなコードです。

after

上記コードにDataBindingを適用すると、

activity_main.xml
<layout>
    <data>
        <variable
            name="viewModel"
            type="com.example.MainViewModel"
        />
    </data>
    <!-- 略 -->
       <TextView
         android:id="@+id/txtName"
         android:text="@{viewModel.user.name}"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
       />
    <!-- 略 -->
<layout>
MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.viewModel = viewModel
    }

xmlにデータソースとなるオブジェクトをvariableとして持たせ、インスタンスはActivity/Fragmentで渡してあげます。
xmlに渡したデータソースに変更があれば、@{}で囲んだ箇所が動的に更新されるという素敵なことができます。

コード量が少ないと恩恵がわかりづらいですが、setTextをする必要がないため、コードが綺麗になります。
他にも、BindingAdapterや双方向バインディングなど便利な機能が備わっているのですが、この記事のスコープ外なので気になるひとは調べてみてください。

DataBindingによるLayout生成方法

本題に入ります。
上記で、DataBindingによりlayoutを生成したのですが、これにはいくつか種類があります。

val binding = DataBindingUtil.setContentView(activity, R.layout.hoge)
//②と`②は同義。
val binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.hoge, container, false)
`②val binding = HogeBinding.inflate(LayoutInflater.from(context), container, false)
//③と`③は同義。
 val binding = HogeBinding.bind(View.inflate(context, R.layout.hoge, container))
`③val binding = DataBindingUtil.bind<HogeBinding>.bind(View.inflate(context, R.layout.hoge, container))

ハマりどころ

Activityなら①、Fragmentなら②を使うことが多いのかなーという印象で、
なかなか、これらの生成方法の違いを意識して記述することはないかもしれません。

ただRecyclerViewのitemでDataBindingを使いたい時、DialogでDataBindingを使いたい時、CustomViewでDataBindingを使いたい時、これらの違いを意識していないで雰囲気でコードを書くことで落とし穴にはまってしまうかもしれません。

自分、ハマりました。
コンパイルエラーが生じたり、ランタイムでViewが表示されないことがありました。

ポイントとなるのは、レイアウトの生成と表示は別問題であることです。
inflatebindメソッドでよしなに生成と表示をしてくれることもありますが、本質的には別問題です。

Viewやレイアウトの生成時に引数として渡すparentattachToRootに注目しながら、実際にcommand+Bで内部実装を軽くみてみましょう。

DataBindingUtil#inflate

我々がDataBindingを利用する際によく使うこのinflateメソッドからみていきます。
説明の都合上、一旦FragmentでのDataBindingにフォーカスします。

基本的にはFragmentではonCreateViewメソッド内で②のようにDataBindingの初期化を行いますが、ここで第3引数にViewGroupであるcontainerを渡し、第4引数にfalseを渡しています。
ここで内部実装にコメントで記載がある引数の説明を読んでみます。


     * @param parent Optional view to be the parent of the generated hierarchy
     *               (if attachToParent is true), or else simply an object that provides
     *               a set of LayoutParams values for root of the returned hierarchy
     *               (if attachToParent is false.)
     * @param attachToParent Whether the inflated hierarchy should be attached to the
     *                       parent parameter. If false, parent is only used to create
     *                       the correct subclass of LayoutParams for the root view in the XML.

ざっくり翻訳すると、

  • attachToParentがtrueであるとき、第3引数で渡したparentは生成されたView(layout)の親になる
  • attachToParentがfalseであるとき、第3引数で渡したparentは生成されたViewのLayoutParamsの決定のみに使用される

という感じです。

公式リファレンスでも記載があるように、FragmentはView自体をattachしないほうが良いので、第4引数にfalseを渡しています。
image.png

Fragmentの場合はLayoutをinflateした後に、onCreateViewの戻り値としてViewGroupを返すことでLayoutを表示させます。
これが、「レイアウトの生成と表示は別問題」と記述したところに繋がっており、戻り値にnullを指定するとFragmentは表示されません。

DataBindingUtil#bind

このメソッドを直接呼ぶことは少ないかもしれません。ただ、使えてしまうので、違いを知った上で安全に運用することはDataBindingに寄らずLayout生成周りで役に立つはずです。

この場合、引数にはrootとなるViewを渡してあげる必要があります。
その際にView#inflateを使用したのですが、こちらは第3引数にparentを渡しますが、DataBindingUtil#inflateにあったattachToParentがありません。
これは、同じく内部実装の引数説明を読んでみると詳細がわかります。

     * @param root A view group that will be the parent.  Used to properly inflate the
     * layout_* parameters.

こちらは、問答無用に引数で渡したparentがLayoutの親になってしまうようです。
そのため、③に記載したbindingの初期化方法は間違いとわかります。FragmentはLayout自体をparentに追加するのは正しくないからです。

Fragment

Fragmentの場合は上述した通りです。

Adapter

前述はFragmentでのDataBindingにおけるLayout生成にフォーカスしましたが、AdapterのitemをDataBindingで生成するにはどうすれば良いでしょうか。

これもLayoutのを生成したあと「どのようにして表示するか」で考えれば良さそうです。
それによってparentを渡すかどうか、渡したparentにattachするかどうかが変わります。

Adapterの場合はinflateしたViewをViewHolderのコンストラクタの引数に渡してあげることで表示ができます。
そのため、AdapterでDataBindingを使う時もparentに直接attachする必要はなく、②の方法でinflateができます。

RecyclerViewの場合は確認していないのですが、数年前にListViewで書かれたAdapterにDataBindingを導入した際に誤って

val binding = DataBindingUtil.inflate(LayoutInflater.from(context), R.layout.item_hoge, parent, true)

と、parentにattachしてしまい、

android.view.InflateException: addView(View, LayoutParams) is not supported in AdapterView

なるExceptionに遭遇しました。

そもそもgetViewで渡されてくるparentは多くの場合ListViewなのですが、ListViewはそもそもViewGroupを継承していないためaddViewができないためです。

itemをDataBindingで生成したあとにはparentにattachせずに、getViewの戻り値としてbinding.rootを返すことで表示しないといけないのです。

RecyclerViewはViewGroupを継承してはいるものの、表示方法としてはparentにattachするのではなくRecyclerView.ViewHolderにbinding.rootを渡してあげるほうが良いでしょう。

CustomView

これまで、Layoutを生成した後に表示する方法が別に用意されていたため、attachToParentfalseにしていたのですが、CustomViewの場合には注意が必要です。

CustomViewの場合は、よしなに表示してくれる機構が備わっていないため、これまでのようにattachToParentfalseにしてしまうと、Layoutは生成されているが、宙ぶらりんな状態になってしまいます。

どうするかというと、

MyCustomView.kt
class MyCustomView : FrameLayout {

    constructor(context: Context) : this(context, null)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
    constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) {
        val binding = ViewMyCustomBinding.inflate(LayoutInflater.from(context), this, true)
    }
}

こんな感じで書いてあげます。
parentthisを渡していますが、これはMyCustomViewが継承しているFrameLayoutです。
そして今まではfalseにしていたattachToParenttrueにしてあげることで生成したLayoutをViewGroupのヒエラルキーにいれてあげるのです。

まとめ

普段あまり意識しないようなLayoutの生成に関して、内部実装を元に違いをみてみました。
自分はAndroid歴があと2ヶ月で1年になるのですが、
「なんかしらんけど動くw」
の状態は脱して、
「こういう機構で動いていたのか」
というのを意識するようにしています。

Advent Calendar1日目からマニアックめなテーマで記事を書きましたが、
これを機に内部実装ちょこちょこ見るようにするとワンランクレベルアップできるかもしれないので是非!

ほりすでした。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22