본문 바로가기

Android/Lint

[Lint] Lint에 Custom rule을 추가해보자. - 1. 기본 설정 및 적용

728x90

Android Studio에서 제공하는 Lint는 필요에 따라 Custom Rule을 추가할 수 있다.

 

즉,

 

 

이처럼 정상적인 코드를 오류로 표기할 수도 있는 것이다.

 

위의 Log와 더불어 몇 개의 Custom Rule을 추가해볼 예정인데, 필자가 해당 부분을 적용하면서 발생했던 문제와 해결했던 방법을 위주로 작성해보고자 한다.


우선,

Lint Rule을 작성할 Module을 만들어 주도록 하자.

 

 

lint 모듈에 룰을 작성하기 전에 module 범위의 gradle에 설정을 해주어야 하는데,

여기서 첫 번째로 문제가 발생하게 된다.

 

apply plugin: 'java-library'

sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

dependencies {
    compileOnly 'com.android.tools.lint:lint-api:26.1.2'
    compileOnly 'com.android.tools.lint:lint-checks:26.1.2'
}

jar {
    manifest {
        attributes("Lint-Registry-v2": "com.example.lint.LintIssueRegistry")
    }
}

 

많은 가이드 문서에서 이런식으로 gradle을 작성하라고 하니 일단 따라서 작성해본다.

여기서 compileOnly키워드를 사용하여 선언함으로써 컴파일 시에만 동작하도록 설정을 해준다.

 

이처럼 선언하면 정상적으로 동작한다고 하지만 이대로 가져와서 Sync를 수행하면 오류가 발생하게 된다.

오류가 발생한 부분과 수정해야 할 부분을 살펴보자.

 

첫 번째로 java가 아닌 kotlin을 사용하고 있기 때문에 plugin을 변경해 주어야 한다.

 

apply plugin: 'kotlin'
apply plugin: 'com.android.lint'

 

해당 부분은 오류가 발생하는 부분이 아니지만 수정해주도록 한다.

 

두 번째로 오류가 발생하는 부분으로 jar을 사용하는 부분이다.

이 부분에 대해서는 많은 글을 확인해도 저것 외에 사용하는 방법에 대해 나와있는 것이 없었다.

따라서 해당 부분을 선언하는 부분이나, 방식 등을 변경해야 하는가 싶어 여러모로 찾아보았는데,

Gradle 버전이 올라감에 따라서 위와 같이 사용하면 안 된다고 한다.

필자는 이 부분을 찾기까지에 생각보다 오랜시간이 걸렸다.

 

따라서,

 

tasks.withType(Jar) {
    manifest {
        attributes("Lint-Registry-v2": "com.example.lint.LintIssueRegistry")
    }
}

 

이처럼 변경을 해주어야 한다.

여기서, com.example.lint.LintIssueRegistry 부분은 사용자의 환경에 따라 다르게 설정해 주면 된다.

 

attributes("Lint-Registry-v2": "yourPackage.yourRegistryClass")

 

이처럼 선언해주면 되는데, RegistryClass는 추후에 생성할 클래스로 해당 부분은 나중에 클래스를 생성한 다음에 추가해주도록 하자.

 

결과적으로, gradle의 전체 코드를 보면 다음과 같다.

 

apply plugin: 'kotlin'
apply plugin: 'com.android.lint'

sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

dependencies {
    // kotlin
    compileOnly "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"

    // lint
    compileOnly "com.android.tools.lint:lint-api:30.1.3"
    compileOnly "com.android.tools.lint:lint-checks:30.1.3"

    // test
    testImplementation 'junit:junit:4.13.2'
    testImplementation "com.android.tools.lint:lint-tests:30.1.3"
}

tasks.withType(Jar) {
    manifest {
        attributes("Lint-Registry-v2": "com.example.lint.LintIssueRegistry")
    }
}

 


 

 

gradle 설정을 완료했으니, 앞서 말했던 Registry 클래스와 lint에 대한 룰을 작성할 클래스를 생성해주도록 하자.

 

우선,

Gradle에 선언했던  Registry 클래스이다.

 

class LintIssueRegistry : IssueRegistry() {

    // lint api 버전 설정
    override val api = CURRENT_API

    // 추가할 Custom Lint List
    override val issues: List<Issue>
        get() = listOf()
}

 

 

IssueRegistry() 를 상속받아서 구현하며, 작성한 Lint List를 제공해주기 위해 사용하는 클래스이다.

 

여기서 Lint에 대한 룰을 작성하고 추가하는 경우에 listOf에 추가해 주면 된다.

 

override val issues: List<Issue>
    get() = listOf(LintContentViewDetector.ISSUE)

 

필자는 위와 같이 작성하였다.

 

다음으로, 추가할 룰을 작성해주어야 하는데, 이에 필요한 항목은 2가지로 나누어 진다.

 

  • Detector : 추가한 룰(문제)을 감지하기 위하여 사용하는 Class. 다양한 조건으로 감지가 가능하다.
  • Issue : 감지된 문제를 표현하기 위한 객체. 문제의 이유와 해결 방법 등 문제에 대한 내용을 포함하고 있다.

이 두 가지 항목이 반드시 필요하며, 각각 하나의 파일로 나누어서 작성할 수 있지만 조건에 따라 이슈를 나눠서 사용하고, 많은 부분을 사용할 것이 아니라면 하나의 파일로 만들어서 사용하는 것이 좋아 보인다.

 

두 가지 항목 중, Issue에 대해 먼저 확인해보자.

 

companion object {
    private val IMPLEMENTATION = Implementation(
        LintContentViewDetector::class.java,
        Scope.JAVA_FILE_SCOPE
    )

    val ISSUE: Issue = Issue
        .create(
            id = "IssueIdCheck",
            briefDescription = "요약된 설명 입니다.",
            explanation = "린트의 설명 부분에 나오는 텍스트 입니다.",
            category = CORRECTNESS, // lint Category에 맞춰서 작성
            priority = 5, // 우선 순위. 1 ~ 10 사이의 정수
            severity = ERROR, // Error의 경우 빨간 줄, Warning의 경우 노란색 라인으로 표기된다.
            implementation = IMPLEMENTATION // 해당 Detector가 수행 될 범위.
        )
}

 

이슈를 생성하는데 필요한 기본적인 구조는 다음과 같다.

 

  • id : Issue를 식별하기 위한 고유 ID
  • briefDescription : Issue의 대한 요약된 설명
  • explanation : Issue에 대한 설명
  • cateogry : Issue에 대한 범주 설정
  • priority : Issue의 우선순위
  • severity : Issue의 심각도
  • implementation : Detector가 수행될 범위

해당 항목들은 이슈를 생성하는데 반드시 필요한 항목들이다.

여기서 Category와 Severity 같은 경우, 필자는 조금 더 간결하게 보이기 위하여 필요한 함수, 변수만 작성하였지만 사용할 수 있는 다양한 설정들이 있기 때문에

 

Category.CORRECTNESS
Severity.ERROR

 

와 같이 선언하여 자동완성으로 나오는 항목을 확인하여 필요에 따라 설정하길 바란다.

 

위의 Issue를 해석하면,

해당 이슈는 IssueIdCheck라는 id를 가지고 있으며 설명은 위에 작성된 Description, explanation으로 나온다.

정확성에 문제가 있는 것으로 보이며, 우선순위는 5, 해당 이슈가 감지되면 Error로 표기하며 LintContentViewDetector를 사용하여 Java 파일을 감지한다.

라고 볼 수 있다.

 

 

다음으로 Detector에 대한 구현을 해주어야 한다.

 

class LintContentViewDetector : Detector(), SourceCodeScanner {

    // Lint로 검출할 Method Name을 List형태로 반환.
    override fun getApplicableMethodNames(): List<String> {
        return listOf("setContentView")
    }

    // getApplicableMethodNames()에서 작성한 함수명이 발견되면 호출.
    override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
        val evaluator = context.evaluator
        // databinding으로 사용하는 경우에는 lint 표시를 하지 않겠다.
        if (!evaluator.isMemberInClass(method, "androidx.databinding.DataBindingUtil")) {
            reportUsage(context, node)
        }
    }

    private fun reportUsage(context: JavaContext, node: UCallExpression) {
        context.report(
            issue = ISSUE,
            scope = node,
            location = context.getCallLocation(
                call = node,
                includeReceiver = true,
                includeArguments = true
            ),
            message = "마우스 오버 시 나오는 텍스트 입니다."
        )
    }
}

 

우선 Detector 클래스를 해석하자면,

setContentView를 사용하는 부분을 감지하는데, DataBindingUtil을 사용하는 setContentView는 감지하지 않겠다.

라는 조건으로 감지하도록 구현되어 있다.

 

필자가 이와 같은 조건을 추가한 이유는,

BaseActivity를 만들고 DataBinding을 사용하여 layout을 설정하고자 하는데, 만들어둔 BaseActivity를 상속받아 사용하지 않고 AppCompatActivity()를 상속받고 databinding을 사용하지 않는 것을 방지하기 위해서이다.

따라서 그냥 사용되는 setContentView를 사용하면 에러가 표시되도록 하고, Databinding을 사용하는 setContentView의 경우 에러가 뜨지 않도록 해둔 것이다.

 

override 된 클래스를 확인해보자.

 

// Lint로 검출할 Method Name을 List형태로 반환.
override fun getApplicableMethodNames(): List<String> {
    return listOf("setContentView")
}

 

함수의 이름대로, 반환된 List에 포함된 String과 동일한 이름의 Method를 감지한다.

필자는 위에 말했듯이 setContentView라는 Method를 감지하도록 하였는데,

 

return listOf("setContentView", "btnClick", "sample")

 

이처럼 여러 개를 나열해서 한 번에 감지하도록 구현할 수 있다.

 

// getApplicableMethodNames()에서 작성한 함수명이 발견되면 호출.
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
    val evaluator = context.evaluator
    // databinding으로 사용하는 경우에는 lint 표시를 하지 않겠다.
    if (!evaluator.isMemberInClass(method, "androidx.databinding.DataBindingUtil")) {
        reportUsage(context, node)
    }
}

 

위의 getApplicableMethodNemase()에서 반환된 String을 이름으로 가지는 Method를 감지하게 되면 해당 함수가 호출되게 된다.

 

해당 함수에서 사용되는 param 값에 대해서 알아보면,

 

  • JavaContext : 감지된 소스코드에 대한 정보
  • UCallExpression : 감지된 메서드 노드 대한 정보
  • PsiMethod : 감지된 메서드에 대한 정보

와 같은 값들이 들어가게 된다.

 

message = "${context.evaluator}\n${node.methodName}\n${method}"

 

 

필자는 해당 값들을 사용했을 때, 어떠한 값들이 보이는지 확인하기 위하여 메시지에 값을 넣어가면서 여러 가지 확인을 해보았는데, JavaContext에 대한 값들은 어떻게 쓰이는지, 어떠한 값이 들어가 있는지 정확하게 파악을 하지 못하였다.

 

 

그 외 두 가지 값에 대해서는 사용할 수 있는 함수들만 확인해봐도 어떠한 데이터들이 들어있는지 예측할 수 있다.

 

다시 visitMethodCall 함수로 돌아가서, 해당 함수의 내용을 보면

 

if (!evaluator.isMemberInClass(method, "androidx.databinding.DataBindingUtil")) {
    reportUsage(context, node, method)
}

 

이와 같은 조건문이 있는데, 이 조건문을 해석하면

감지된 method가 해당 class의 method가 아니면 reportUsage를 호출하겠다. 가 된다.

 

reportUsage는 필자가 report 하는 부분을 따로 빼내어서 작성한 부분인데,

여기서 report를 함으로써 우리가 쉽게 확인할 수 있는

 

 

이와 같은 우리가 흔히 알고 있던 화면이 완성되는 것이다.

 

context.report(
    issue = ISSUE,
    scope = node,
    location = context.getCallLocation(
        call = node,
        includeReceiver = true,
        includeArguments = true
    ),
    message = "마우스 오버 시 나오는 텍스트 입니다."
)

 

report의 구조를 확인해 보자.

 

  • Issue : report 할 문제.
  • scope : 오류가 적용되는 범위
  • location : 문제가 발생한 위치
    • call : 위치 범위를 생성하기 위해 호출
    • includeRecevier : 메서드 호출의 수신자를 포함할지 여부
    • includeArguemnts : 메서드 호출에 대한 인자들을 포함할지 여부
  • message : 문제에 마우스를 올리게 되면 확인할 수 있는 텍스트

Issue에는 맨 처음 작성한 Issue를 넣어주고, scope에는 해당 문제가 감지된 메서드에 대한 정보가 들어있는 node를 넣어주면 된다.

 

해당 구조에 맞춰서 값을 넣어주면 Custom 한 Lint를 적용할 준비가 다 된 것이다.

 

작성한 이슈는 Lint Module로 따로 빼내어서 작업을 진행했기 때문에,

해당 조건을 추가할 모듈의 gradle에 Lint 모듈을 추가해주면 된다.

 

// lint Module
lintChecks project(':lint')

 

기존에 다른 모듈을 사용하기 위해서는 implementation을 사용했는데, 이번에는 lint로 사용되기 때문에 lintChecks 키워드를 사용하여 선언한다.

 

해당 부분에서도 lintChecks가 아닌 lintPublish를 사용하는 것을 볼 수 있었는데, 어느 부분이 다른지는 모르겠지만 필자가 작업한 환경에서는 lintPublish를 사용할 경우 정상적으로 적용이 되진 않았고 lintChecks를 사용했을 때 정상적으로 적용이 되었다.

 

이렇게 사용할 모듈에 lint 모듈을 추가해주고, sync를 해주면 정상적으로 추가된 조건으로 Lint가 적용되는 것을 확인할 수 있을 것이다.

 

여기서, sync로도 적용이 안 되는 경우

 

gradle build

// gradlew :moduleName:lintDebug
gradlew :app:lintDebug

 

를 터미널에서 입력해주면 정상적으로 확인이 가능할 것이다.

 


필자가 해당 부분을 적용함에 있어서, gradle을 사용한 세팅에 시간이 생각보다 많이 들어간 것 같다.

버전이 바뀜에 따라 사용되는 코드가 변경되는데 이에 따라 어떻게 변경하여 적용해야 하는지 알지 못했던 것이 가장 큰 이유로 보인다.

 

해당 작업을 진행하면서 전반적으로 Lint를 커스텀하는 프로세스에 대해 알 수 있었으니,

사용되는 다양한 함수를 사용하여 추가적인 조건을 넣어보면서 테스트를 해봐야 할 것 같다.

 

현재 위의 코드를 작성하면서 상속받은 IssueRegistry나 Detector도 최신 버전으로 마이그레이션이 가능한 부분으로 나오는데, 이 부분에 대해서도 추후 기회가 된다면 작업을 진행해보고자 한다.

 

해당 게시글에 사용한 예제는 다음 Github에 올려두었다.

https://github.com/HeeGyeong/CleanArchitectureSample

 

GitHub - HeeGyeong/CleanArchitectureSample

Contribute to HeeGyeong/CleanArchitectureSample development by creating an account on GitHub.

github.com

 

728x90