본문 바로가기
프로그래밍 놀이터/안드로이드, Java

[android] Coding in Style: Static Analysis with Custom Lint Rules ( from Dev Summit 19 )

by 돼지왕 왕돼지 2019. 11. 14.
반응형

Coding in Style: Static Analysis with Custom Lint Rules ( from Dev Summit 19 )






* Initial project set-up


-

module 을 만들어서 java-library 로 지정하고, aar 로 패키징해서 배포한다.



-

apply plugin: 'java-library'
apply plugin: 'kotlin'

ext {
    lintVersion = "26.5.1" // android gradle version 에 23 을 더해준다. (걍 그렇게 되었다고 함)
}

dependencies {
    compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk8"

    // Lint
    compileOnly "com.android.tools.lint:lint-api:$lintVersion"
    compileOnly "com.android.tools.lint:lint-checks:$lintVersion"

    // Lint Testing
    testImplementation "com.android.tools.lint:lint:$lintVersion"
    testImplementation "com.android.tools.lint:lint-tests$lintVersion"
    testImplementation "junit:junit:4.12"
}

cf) 참고로 compileOnly 는 compile 시에만 사용하며, artifact 로 만들 때는 포함하지 않는다.


-

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
    ...
}

dependencies {
    lintPublish project(path: ':lint') // auto lint apply
    implementation 'androidx.appcompat:appcompat:1.1.0'
    testImplementation 'junit:junit:4.12'
    // ...
}


-

...

android {
    ...
}

dependencies {
    ...
    implementation project(':library') // aar
    // or
    lintChecks project(path: ':lint') // jar
}




* IssueRegistry


-

IssueRegistry 는 Issue 들의 collection 이다. 

Issue 는 detect 하고 싶은 것을 말하며, detect 하는 로직을 가진 issue detector 들이 구현되어야 한다.


IssueRegistry 의 위치는 "resources/META-INF/services/com.android.tools.intt.client.api.IssueRegistry" 가 되어야 한다.



-

class IssueRegistry : IssueRegistry() {
    override val issues: List<Issue>
        get() = listOf(
            NoisyIssue,
            BadConfigurationProviderIssue,
            RemoveWorkManagerInitializerIssue
        )
}


-

val NoisyIssue = Issue.create( id = "NoisyIssueId", briefDescription = "This is noisy issue", explanation = "This is noisy issue. It detects something noisy", category = Category.CORRECTNESS, severity = Severity.INFORMATIONAL, implementation = Implementation( NoisyDetector::class.java, Scope.MANIFEST_SCOPE // AndroidManifest.xml 을 검사할 것이다. )


Severity Class Doc

Scope Class Doc



-

Detector 는 callback style 로 관심있는 부분만 확인 할 수 있다.

// 이 구현은 모든 manifest 파일에 대해 무조건 complain 을 한다.
class NoisyDetector : Detector(), XmlScanner{
    override fun getApplicableElements() = listOf("manifest")   

    override fun visitElement(context: XmlContext, element:Element){
        ...
    }

    override fun afterCheckFile(context:Context){
        context.report(
            NoisyIssue,
            Location.create(context.file),
            NoisyIssueDescription)
    }
}





* Detector test


-

Lint 에 대한 test code  를 짜보자

@Test
fun testNoisyDetector(){
    lint()
        .files(
            manifest(
                """<manifest package="test">...</manifest>"""
            ).indented()    
        )
        .issues(NoisyIssue)
        .run()
        .expect(
            """Noisy issue alarm""".trimIndent() // copied from test result
        )
}




* Source code 에 대한 Lint


-

PSI : Program Structure Interface

UAST : Universal Abstract Syntax Tree


UAST 가 java 와 kotlin 를 다 support 하므로 java, kotlin 용 lint 를 각각 따로 만들지 않아도 된다.

최종적으로 lint check 를 하는 것은 PSI + UAST 를 상황에 맞게 조합해서 한다.

IntelliJ 의 Psi Viewer 를 통해 구조와 내용을 볼 수 있다.


출처 : Medium Article


-

val BadConfigurationProviderIssue = Issue.create(
    id = "BadConfigurationProviderId",
    briefDescription = "This is BadConfigurationProviderIssue",
    explanation = "This is BadConfigurationProviderIssue. You can detect this one",
    category = Category.CORRETNESS,
    severity = Severity.FATAL, // lint 에 걸리면 compile error 
    implementation = Implmentation(
        BadConfigurationProviderDetector::class.java,
        Scope.JAVA_FILE_SCOPE // java 이지만 kotlin 도 cover
    )
)


-

class BadConfigurationProviderDetector : Detector(), SourceCodeScanner{
    private var mCorrect = false

    override fun applicableSuperClasses() = listOf("androidx.work.Configuration.Provider")

    override fun visitClass(context:JavaContext, declaration:UClass){
        // evaluator 는 helper class 로 기능들을 잘 알아두면 좋다.
        if (context.evaluator.extendsClass(declaration.javaPsi, "android.app.Application", false)){
            mCorrect = true
        }
    }

    override fun afterCheckEachProject(context: Context){
        if (mCorrect == false){
            context.report(
                issue = IssueRegistry.BadConfigurationProviderIssue,
                location = Location.create(context.file),
                message = "You have to extends Application"
            )
        }
    }
}







* Source code lint test


-

// test 를 할 때 android.jar 를 가져오기 싫어 stub 을 만든다.
object Stubs{
    val WORK_MANAGER_CONFIGURATION_INTERFACE : TestFile = kotlin(
        "androidx/work/Configuration.kt",
        """
            package androidx.work
            interface Configuration{
                interface Provider{
                    fun getWorkManagerConfiguration() : Configuration
                }
            }
        """)
        .indented().within("src")

    val ANDROID_APPLICATION_CLASS: TestFile = kotlin(
        "android/app/Application.kt",
        """
            package android.app
            open Class Application{
                fun onCreate(){
                }
            }
        """)
    .indented(),.within("src")
}


-

@Test
fun testBadConfigurationProviderDetector_Sccess(){
    lint()
        .files(
            WORK_MANAGER_CONFIGURATION_INTERFACE,
            ANDROID_APPLICATION_CLASS,
            APP_IMPLMENTS_CONFIGURATION_PROVIDER
        )
        .issues(BadConfigurationProviderIssue)
        .run()
        .expect("No warnings.")
}

@Test
fun testBadConfigurationProviderDetector_Failure(){
    lint()
        .files(
            WORK_MANAGER_CONFIGURATION_INTERFACE,
            ANDROID_APPLICATION_CLASS,
            OTHER_CLASS_IMPLMENTS_CONFIGURATION_PROVIDER
        )
        .issues(BadConfigurationProviderIssue)
        .run()
        .expect("You have to extends Application.".trimIndent())
}


-

아래 명령을 통해서 lint 를 돌릴 수 있다.

./gradlew :app:lintDebug


이는 보기 결과에 대한 좋은 XML 과 HTML report 를 만들어준다. metadata 도 포함해서 말이다.





* method call 을 detect 해보자.


-

class LogWtfDetector : Detector(), SourceCodeScanner{
    override fun getApplicableMethodNames() = listOf("wtf")

    override fun visitMethodCall(context:JavaContext, node:UCallExpression, method:PsiMethod){
        if (context.evaluator.isMemberInClass(method, "android.util.Log"){
            reportUsage(context, node, method)
        }
    }

    private fun reportUsage(context:Context, node:UCallExpression, method:PsiMethod){
        val quickfixData = LintFix.create()
            .name("Use Log.e()")
            .replace()
            .text(method.name)
            .with("e")
            .robot(true) // can be applied automatically
            .independent(true) // does not conflict with other auto-fix
            .build()

        context.report( /*... */, quickfixData = quickfixData)
    }
}




* Annotation 에 대한 lint 를 만들어보자


-

@Experimental
@Retension(AnnotationReetension.BINARY)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class TimeTravelExperiment

@TimeTravelExperiment
class TimeTravelProvider{
    fun setTime(timestamp:Long){
        // ...
    }
}

class UserTimeTravelExperimentFromJava{
    @TimeTravelExperiment
    void setTimeToNow(){
        new TimeTravelProvider().setTime(System.currentTimeInMillis());
    }

    // UMethod, annotated 라면 UAnnotated
    @UseExperimental(markerClass = TimeTravelExperiment.class)
    void setTimeToEpoch(){ 
        // TimeTravelProvider() -> UCallExpression
        // TimeTravelProvider().setTime(0); -> UCompositeQualifiedExpression
        new TimeTravelProvider().setTime(0); // UCodeBlockExpression
    }
}


-

class ExperimentalDetector : Detector(), SourceCodeScanner{
    override fun applicableAnnotations() = listOf("kotlin.Experimental")

    override fun visitAnnotationUsage(context:JavaContext, usage:UElement, annotation:UAnnotation, ... ){
        if (isKotlin(usage.sourcePsi)) return // kotlin 은 lint 에서 자동 지원

        if (isValidExperimentalUsage(context, usage, annotation) == false){
            reportUsage(context, usage, annotations)
        }
    }

    private fun isValidExperimentalUsage(context:JavaContext, usage:UElement, annotation:UAnnotation):Boolean{
        val featureName = (annotation.uastParent as? UClass) ?.qualifiedName ?: return false

        // find the nearest enclosing annotated element
        var element:UAnnoated? = if (usage is UAnnotated){
            usage
        } else{
            usage.getParentOfType(UAnnoated::class.java)
        }

        while (element != null){
            val annotations = context.evaluator.getAllAnnotations(element, false)
            if (annotations.any { it.qualifiedName == featureName }){
                return true
            }

            if (annotations
                .filter { annot -> annot.qualifiedName == "kotlin.UseExperimental" }
                .mapNotNull { annot -> annot.attributeValue.getOrNull(0) }
                .any { attr -> attr.getFullyQualifiedName(context) == featureName }){
                return true
            }
            
            // next annotated parenting element
            element = element.getParentOfType(UAnnotated::class.java)
        }

        return false
    }
}




* Rule 을 잘 적용하는 Tip


-

build-in lint 에 대해서는 아래를 참고하라.

https://tinyurl.com/aosp-lint-checks



-

확실치 않은 것들에 대해서는 test case 를 통해 debug point 찍고 잘 활용해라. (UAnnotated type 등?)



-

PSI Viewer 를 활용해라





* BaseLine


-

baseline 은 in-editor check 를 무시한다.

baseline 이 없다면, 적용되는 lint 에 대해 모든 소스코드를 한번에 고쳐야 한다.

basline 을 통해 lint error 를 확인하여 고치고, baseline 을 update 하는 과정을 반복하여, 점차적인 lint error 를 수정할 수 있다.



-

android{
    lintOptions{
        // ./gradlew lintDebug 로 생성된 file
        baseline file("lint-baseline.xml")
        // isAbortOnError = true // 이 녀석은 baseline 에 있는 녀석을 enable 시킨다.
    }
}



반응형

댓글