社内Android勉強会でAndroid Lintを実装して得た知見

by nozomi-takuma | December 22, 2020
tips | #android

社内Android勉強会でCustom Android Lintを実装する中で得た知見

はじめに

この記事はDeNA Advent Calendar 2020の22日目の記事です。

こんにちは、品質管理部 SWETグループ田熊です。

DeNAでは、社内のAndroidエンジニアが事業部やチームの垣根を超えて知見を共有できる場として「Android.Tuesday」という社内勉強会を実施しています。

Android.Tuesdayは名前の通り毎週火曜日に開催され、DeNA内だけでなくMobility Technologiesのエンジニアにも参加いただいており、2社間のAndoridエンジニアの技術交流の場としても活用されています。

このAndroid.Tuesdayの場で、6週に渡ってAndroid Lint勉強会を開催し、ViewDataBindingのリークを検知するLintの実装を行いました。

この記事では社内Android Lint勉強会の様子と、Lintを実装する中で得た知見について紹介します。

Android Lintに入門する

参加しているAndroidエンジニアは全員Lint開発の知識がほぼない状態でした。

まずは、以下の記事の内容を実装してみることからはじめました。

kotlinでも検出できるCustom Lintを作成してみた (現在Android Studioの最新は4.1ですので、手元でLintのプロジェクトを作成される際はAndoridStudio4+用のサンプルを参考にするのがよいと思います。Lintを登録する箇所がアップデートされています。)

記事の通りにLintの実装を行い、動くことが確認できたところで、実際に自分たちが作成するCustom Lintの方針を考えました。

Lintの実装案として、次のような候補がでました。

  • CoroutineのGlobalScopeを使用している箇所を検知して、ライフサイクルにあわせたScopeを設定するようにしたい
  • MutableLiveDataで初期値が設定されておらず、実質Nullableだけど、型がNonNullになっている箇所を検知したい
  • ConstraintLayoutで、非推奨になっているmatch_parentを使用している箇所を検知したい
  • ViewDataBindingをフィールドに持っている場合に、onDestroyViewで解放していない箇所を検知してメモリリークを防ぎたい

この中で難易度としてちょうどいいのではないかと思われた「ViewDataBindingをフィールドに持っている場合に、onDestroyViewで解放していない箇所を検知してメモリリークを防ぎたい」を実装してみることにしました。

ViewDataBindingのリークを検知するLint

Android DevelopersのView Binding#fragmentsに書いてあるように、FragmentでViewDataBindingを保持している場合は、リークしないようにonDestroyViewで破棄することが望ましいです。

今回は次のようなコードを想定して、onDestroyViewのメソッドの中で_binding = nullがなかったらエラーとするLintを実装します。

class MyFragment : Fragment() {

    private var _binding: MyBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = MyBinding.inflate(inflater, container, false)
        return binding.root
    }

    //
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null // <- これがなかったら怒る
    }
}

Lintの実装方針

ViewDataBindingのリークを検知するLintは、次の方針で実装をすることにしました。

  • androidx.fragment.app.Fragmentを実装したクラスを探す
  • androidx.databinding.ViewDataBinding型のサブタイプのフィールドを探す
    • なかったらフィールドにView Bindingをもっていないのでskip
  • onDestroyViewメソッドを探す
    • メソッドがなかったら破棄し忘れている可能性が高いのでエラー
  • メソッドのボディに_binding = nullがあるか探す
    • なかったら破棄をし忘れている可能性が高いのでエラー

(ここからAndroid Lintの実装に触れていきますが、前述のkotlinでも検出できるCustom Lintを作成してみたで触れられている内容は省略していますので、そちらも合わせて参照いただければと思います。)

Android Lintは主に次のメソッドをオーバーライドすることで実装することができます。

class ViewBindingLeakDetector : Detector(), Detector.UastScanner {

    override fun getApplicableUastTypes(): List<Class<out UElement>>? {
       // フィルターしたい要素を指定する
       return listOf(..)
    }
    
    override fun createUastHandler(context: JavaContext): UElementHandler? {
        return object : UElementHandler() {
            // getApplicableUastTypesで返す型に対応した実装をoverrideする
        }        
    }

}

今回は、まずFragmentを実装しているクラスをフィルターするところから始めます。 LintではASTをみてOKやNGを判定しますが、ASTはコードをツリー構造にしたものです。そのため、クラスがフィルターできれば、そこからクラスの持つメソッドやフィールドをたどれるという算段です。

class ViewBindingLeakDetector : Detector(), Detector.UastScanner {

    override fun getApplicableUastTypes(): List<Class<out UElement>>? {
        // クラスの情報を持つNodeでフィルターする 
        return listOf(UClass::class.java)
    }

    override fun createUastHandler(context: JavaContext): UElementHandler? {
        return object : UElementHandler() {
        
            // UClassに対応したvisitClassをoverride
            override fun visitClass(node: UClass) {
              // クラスの情報を持つnodeが1つずつ流れてくる
              // 方針をたてた検出のロジックを実装する
            }             
        }
    }
}

検出ロジックの実装

androidx.fragment.app.Fragmentを実装したクラスを探す

親クラスが何か?という情報はPsiClass#superClassから見つけることができました。また、親クラスの完全修飾名は、PsiClass#superClass#qualifiedNameで取得することができます。

ただし、superClassは直接継承しているクラスしか取得できません。 そのため、AFragment -> BFragment -> androidx.fragment.app.Fragmentといった継承関係がある場合でも検出できるように、再帰的にsuperClassをチェックするようにしました。

// createUastHandlerからvisitClassを抜粋

override fun visitClass(node: UClass) {
    
    val psiClass: PsiClass = node.javaPsi
    
    // Fragmentの実装クラスか
    val isFragment = matchSuperType(node.javaPsi, "androidx.fragment.app.Fragment")   
}

// 任意のクラスを継承しているかを再帰的に確認する
fun matchSuperType(psiClass: PsiClass, qualifiedName: String): Boolean {
    val superClass = psiClass.superClass

    return when {
        superClass == null -> { 
            // 継承元がないときはnullになる
            // 全継承元を走査したことになるはずなのでfalseを返す
            false
        }
        superClass.qualifiedName == qualifiedName -> {
            true
        }
        else -> {
            // superClassを引数にして再帰的に確認
            matchSuperType(superClass,  qualifiedName)
        }
    }
}

androidx.databinding.ViewDataBinding型のサブタイプのフィールドを探す

Classが保持しているフィールドはPsiClass#allFieldsから取得できました。戻り値はPsiFieldです。 そこからPsiField#type.superTypesとたどることで、フィールドの型とその親をたどることができました。

val viewBindingFields: PsiField =
    psiClass.allFields.filter { field ->
        // interfaceなど複数実装できるためsuperTypesは配列で返ってくる
        field.type.superTypes.any { type ->
            // ViewBindingはViewDataBindingを直接継承しているので再帰的にはみない
            type.canonicalText == "androidx.databinding.ViewDataBinding"
        }
    }

onDestroyViewメソッドを探す

メソッドも、フィールドと同じ要領でPsiClass#allMethodsから取得できました。

val onDestroyView: PsiMethod = psiClass.allMethods.firstOrNull { method ->
    method.name == "onDestroyView"
}

メソッドのボディに_binding = nullがあるか探す

まずはメソッドのボディを取得します。 ひとつ上の項目で取得したPsiMethodにはbodyプロパティがありますが、このプロパティを取得してもnullが返ってきてしまいます。

どうも実際の型はKtLightMethod型で、KtLightMehod型のbodyプロパティは常にnullを返すようです。ボディの中身はKtLightMethod#kotlinOriginから取得する必要がありました。

onDestroyView as KtLightMethod // PsiMethodからキャスト

val block = onDestroyView.kotlinOrigin?.children?.first {
    // メソッドのボディはKtBlockExpressionで取得できる
    // このあたりはPsiViewerを見ながらフィルターしました
    it is KtBlockExpression
}

つぎにボディの中から、_binding = nullを探します。 _binding = nullという構文はPSI上ではBinaryExpressionに該当します。 左辺が_binding、オペレーターが=、右辺がnullのBinaryExpressionがボディに含まれているかをチェックします。

// BinaryExpressionのみフィルター
val binaryExpression = block.children?.filterIsInstance<KtBinaryExpression>()

// _binding = nullを探す
// resultがnullの場合はエラーにする
val result = binaryExpression?.firstOrNull {
    it.left?.text == "_binding" // 厳密には取得したViewDataBindingと比較する
            && it.operationToken.toString() == "EQ"
            && it.right?.text == "null"

これらの処理をつなげ、必要な箇所でエラーを返すように実装すればViewDataBindingのリークを検知するLintの完成です!

デバッグのTips

今回、LintはLint対象プロジェクト内のモジュールとして実装しました。 Lint実行対象のモジュールに、次の設定をすることで実現できます。

dependencies {
    lintChecks project(':lint')
}

あわせて、Android StudioでGradleを実行するRun Configurationを作成し、Lintを実行するタスクを指定します。(例: lintDebug)

すると、通常の開発と同じようにAndroid Studio上でデバッグ実行をすることができ、Lintの処理の中をブレークポイントで止めることができます。

実装中はフィールドの中身を見たい場面が多々有り、何度もお世話になりました。

おわりに

社内勉強会でAndroid Lintに入門し、実際にAndroid Lintを実装する中で得た知見を紹介しました。

Android Lint何もわからない状態からはじまった勉強会ですが、参加者の知恵を出し合いながらなんとか動くLintを実装できたことを嬉しく思っています。


この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ Twitter や facebook、はてなブックマークにてコメントをお願いします!

また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい。