1 of 142

Custom Lint Rules

Игорь Таланкин

1

Tinkoff.ru

2 of 142

О чем доклад?

  • Статический анализ
  • Android Lint
  • Custom lint rules

2

Tinkoff.ru

3 of 142

Статический анализ

  • стилистика
    • отступы, форматирование
  • семантика
    • ошибки в логике, недостижимые участки кода
  • подсчет метрик
    • Source Lines Of Code, количество комментариев, сложность

3

Tinkoff.ru

4 of 142

Android Lint

4

Tinkoff.ru

5 of 142

Android Lint

  • статический анализатор кода
  • работает в IDE из коробки
  • мультидисциплинарный
    • XML, Java, Kotlin, JPEG, Properties, etc.
  • можно поставлять вместе с библиотеками

5

Tinkoff.ru

6 of 142

Timber

6

Tinkoff.ru

7 of 142

$ ./gradlew lint

Ran lint on variant debug: 2 issues found

Ran lint on variant release: 1 issues found

Wrote HTML report to file:///.../build/reports/lint-results.html

Wrote XML report to file:///.../build/reports/lint-results.xml

7

8 of 142

<?xml version="1.0" encoding="UTF-8"?>

<issues format="5" by="lint 3.3.1">

<issue

id="UnusedResources"

severity="Warning"

message="`R.layout.unused_layout` appears to be unused"

category="Performance"

priority="3"

summary="Unused resources"

explanation="..."

errorLine1="&lt;LinearLayout"

errorLine2="^">

<location

file="/.../unused_layout.xml"

line="2"

column="1"/>

</issue>

</issues>

8

9 of 142

HTML

9

Tinkoff.ru

10 of 142

IDE

10

Tinkoff.ru

11 of 142

android {

lintOptions {

baseline file('baseline.xml')

checkDependencies true

}

}

11

12 of 142

android {

lintOptions {

baseline file('baseline.xml')

checkDependencies true

}

}

12

13 of 142

android {

lintOptions {

baseline file('baseline.xml')

checkDependencies true

}

}

13

14 of 142

Custom Lint Rules

14

Tinkoff.ru

15 of 142

Custom Lint Rules: зачем?

  • находить ошибки

15

Tinkoff.ru

16 of 142

Custom Lint Rules: зачем?

16

Tinkoff.ru

17 of 142

Custom Lint Rules: зачем?

  • находить ошибки
  • контролировать соблюдение соглашений

17

Tinkoff.ru

18 of 142

Custom Lint Rules: зачем?

18

Tinkoff.ru

19 of 142

Custom Lint Rules: зачем?

  • находить ошибки
  • контролировать соблюдение соглашений
  • избежать рутинных и повторяющихся действий

19

Tinkoff.ru

20 of 142

Custom Lint Rules: зачем?

  • находить ошибки
  • контролировать соблюдение соглашений
  • избежать рутинных и повторяющихся действий
  • рефакторинг

20

Tinkoff.ru

21 of 142

Custom Lint Rules

  1. Добавить модуль

21

Tinkoff.ru

22 of 142

// custom-lint-rules/build.gradle

apply plugin: 'kotlin'��dependencies {� compileOnly "com.android.tools.lint:lint-api:$lintVersion"� testCompile "com.android.tools.lint:lint-tests:$lintVersion"}��jar {� manifest {� attributes("Lint-Registry-v2": "ru.tinkoff.CustomIssueRegistry")}}

22

23 of 142

// custom-lint-rules/build.gradle

apply plugin: 'kotlin'��dependencies {� compileOnly "com.android.tools.lint:lint-api:$lintVersion"� testCompile "com.android.tools.lint:lint-tests:$lintVersion"}��jar {� manifest {� attributes("Lint-Registry-v2": "ru.tinkoff.CustomIssueRegistry")� }�}

23

24 of 142

lintVersion = androidGradlePluginVersion + 23.0.0

// например

androidGradlePluginVersion = "3.5.0"

lintVersion = "26.5.0"

24

25 of 142

// custom-lint-rules/build.gradle

apply plugin: 'kotlin'��dependencies {� compileOnly "com.android.tools.lint:lint-api:$lintVersion"� testCompile "com.android.tools.lint:lint-tests:$lintVersion"�}��jar {� manifest {� attributes("Lint-Registry-v2": "ru.tinkoff.CustomIssueRegistry")}}

25

26 of 142

Custom Lint Rules

  • Добавить модуль
  • Реализовать IssueRegistry

26

Tinkoff.ru

27 of 142

class CustomIssueRegistry : IssueRegistry() {

override val api = CURRENT_API

override val issues: List<Issue> = listOf()

}

27

28 of 142

class CustomIssueRegistry : IssueRegistry() {

override val api = CURRENT_API

override val issues: List<Issue> = listOf()

}

28

29 of 142

import com.android.tools.lint.detector.api.CURRENT_API

29

30 of 142

class CustomIssueRegistry : IssueRegistry() {

override val api = CURRENT_API

override val issues: List<Issue> = listOf()

}

30

31 of 142

Custom Lint Rules

  • Добавить модуль
  • Реализовать IssueRegistry
  • Подключить модуль к проекту

31

Tinkoff.ru

32 of 142

// app/build.gradle

dependencies {

lintChecks project(':custom-lint-rules')

}

32

33 of 142

// some-kotlin-module/build.gradle

apply plugin: 'kotlin'

apply plugin: 'com.android.lint'

dependencies {

lintChecks project(':custom-lint-rules');

}

33

34 of 142

Custom Lint Rules

  • Добавить модуль
  • Реализовать IssueRegistry
  • Подключить модуль к проекту
  • Реализовать Detector

34

Tinkoff.ru

35 of 142

Пример

35

Tinkoff.ru

36 of 142

@Override

protected void onActivityResult(int requestCode, int resultCode, Intent data) {

super.onActivityResult(requestCode, resultCode, data);

if (requestCode == CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE) {

CropImage.ActivityResult result = CropImage.getActivityResult(data);

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

try {

Bitmap bitmap = getBitmap(getContentResolver(), resultUri);

profile_image.setImageBitmap(bitmap);

} catch (IOException e) {

e.printStackTrace();

}

}

}

}

36

37 of 142

@Override

protected void onActivityResult(int requestCode, int resultCode, Intent data) {

super.onActivityResult(requestCode, resultCode, data);

if (requestCode == CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE) {

CropImage.ActivityResult result = CropImage.getActivityResult(data);

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

try {

Bitmap bitmap = getBitmap(getContentResolver(), resultUri);

profile_image.setImageBitmap(bitmap);

} catch (IOException e) {

e.printStackTrace();

}

}

}

}

37

38 of 142

Интерфейсы

  • XmlScanner
  • SourceCodeScanner
  • ClassScanner
  • GradleScanner
  • BinaryResourceScanner
  • ResourceFolderScanner, OtherFileScanner, ...

38

Tinkoff.ru

39 of 142

SourceCodeScanner

  • вызовы методов с именем methodName
  • обращение к символам с именем symbolName
  • наследование от класса ClassName
  • использование аннотаций AnnotationName
  • любые другие конструкции

39

Tinkoff.ru

40 of 142

UAST

  • Unified Abstract Syntax Tree
  • универсальный анализ Java и Kotlin

40

Tinkoff.ru

41 of 142

// MyClass.java

public class MyClass {

String hello = "Hello!";

void bar() { }

}

// MyClass.kt

class MyClass {

val hello = "Hello!"

fun bar() { }

}

41

42 of 142

// MyClass.java

public class MyClass {

String hello = "Hello!";

void bar() { }

}

// MyClass.kt

class MyClass {

val hello = "Hello!"

fun bar() { }

}

42

UFile

UClass

(name = MyClass)

UField

(name = hello)

UMethod

(name = bar)

UBlockExpression

ULiteralExpression

(value = "Hello!")

43 of 142

Алгоритм

  1. Найти использование констант RESULT_OK, RESULT_CANCELLED
  2. Проверить второй операнд ==
  3. Если это requestCode, то сообщить об ошибке

43

Tinkoff.ru

44 of 142

class ActivityResultDetector: Detector(), SourceCodeScanner {

companion object {

val ISSUE = Issue.create(...)

}

override fun getApplicableReferenceNames(): List<String>?

override fun visitReference(

context: JavaContext,

reference: UReferenceExpression,

referenced: PsiElement)

}

44

45 of 142

class ActivityResultDetector: Detector(), SourceCodeScanner {

companion object {

val ISSUE = Issue.create(...)

}

override fun getApplicableReferenceNames(): List<String>?

override fun visitReference(

context: JavaContext,

reference: UReferenceExpression,

referenced: PsiElement)

}

45

46 of 142

val ISSUE = Issue.create(

id = "ActivityResultDetector",

briefDescription = "Comparing result code to request code",

explanation = "<...>",

category = Category.CORRECTNESS,

priority = 5,

severity = Severity.ERROR,

implementation = Implementation(

ActivityResultDetector::class.java,

Scope.JAVA_FILE_SCOPE)

)

46

47 of 142

val ISSUE = Issue.create(

id = "ActivityResultDetector",

briefDescription = "Comparing result code to request code",

explanation = "<...>",

category = Category.CORRECTNESS,

priority = 5,

severity = Severity.ERROR,

implementation = Implementation(

ActivityResultDetector::class.java,

Scope.JAVA_FILE_SCOPE)

)

47

48 of 142

val ISSUE = Issue.create(

id = "ActivityResultDetector",

briefDescription = "Comparing result code to request code",

explanation = "<...>",

category = Category.CORRECTNESS,

priority = 5,

severity = Severity.ERROR,

implementation = Implementation(

ActivityResultDetector::class.java,

Scope.JAVA_FILE_SCOPE)

)

48

49 of 142

val ISSUE = Issue.create(

id = "ActivityResultDetector",

briefDescription = "Comparing result code to request code",

explanation = "<...>",

category = Category.CORRECTNESS,

priority = 5,

severity = Severity.ERROR,

implementation = Implementation(

ActivityResultDetector::class.java,

Scope.JAVA_FILE_SCOPE)

)

49

50 of 142

val ISSUE = Issue.create(

id = "ActivityResultDetector",

briefDescription = "Comparing result code to request code",

explanation = "<...>",

category = Category.CORRECTNESS,

priority = 5,

severity = Severity.ERROR,

implementation = Implementation(

ActivityResultDetector::class.java,

Scope.JAVA_FILE_SCOPE)

)

50

51 of 142

enum class Severity {

FATAL("Fatal"),

ERROR("Error"),

WARNING("Warning"),

INFORMATIONAL("Information"),

IGNORE("Ignore")

}

51

52 of 142

val ISSUE = Issue.create(

id = "ActivityResultDetector",

briefDescription = "Comparing result code to request code",

explanation = "<...>",

category = Category.CORRECTNESS,

priority = 5,

severity = Severity.ERROR,

implementation = Implementation(

ActivityResultDetector::class.java,

Scope.JAVA_FILE_SCOPE)

)

52

53 of 142

enum class Scope {

RESOURCE_FILE,

JAVA_FILE,

CLASS_FILE,

MANIFEST,

PROGUARD_FILE,

GRADLE_FILE,

...

}

53

54 of 142

class ActivityResultDetector : Detector(), SourceCodeScanner {

companion object {

val ISSUE = Issue.create(...)

}

override fun getApplicableReferenceNames(): List<String>?

override fun visitReference(

context: JavaContext,

reference: UReferenceExpression,

referenced: PsiElement)

}

54

55 of 142

class ActivityResultDetector : Detector(), SourceCodeScanner {

companion object {

val ISSUE = Issue.create(...)

}

override fun getApplicableReferenceNames(): List<String>?

override fun visitReference(

context: JavaContext,

reference: UReferenceExpression,

referenced: PsiElement)

}

55

56 of 142

override fun getApplicableReferenceNames(): List<String>? {

return listOf("RESULT_OK", "RESULT_CANCELED")

}

56

57 of 142

override fun visitReference(

context: JavaContext,

reference: UReferenceExpression,

referenced: PsiElement) {

// TODO

}

57

58 of 142

override fun visitReference(

context: JavaContext,

reference: UReferenceExpression,

referenced: PsiElement) {

// TODO

}

58

59 of 142

override fun visitReference(

context: JavaContext,

reference: UReferenceExpression,

referenced: PsiElement) {

// TODO

}

59

60 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

// ...

}

}

60

reference: UReferenceExpression

61 of 142

override fun visitReference(

context: JavaContext,

reference: UReferenceExpression,

referenced: PsiElement) {

// TODO

}

61

62 of 142

public class Activity extends ContextThemeWrapper {

/** Standard activity result: operation succeeded. */

public static final int RESULT_OK = -1;

}

62

referenced: PsiElement

63 of 142

PSI

  • Program Structure Interface
  • используются для представления внутренней структуры исходного кода

63

Tinkoff.ru

64 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

// ...

}

}

64

65 of 142

65

UBinaryExpression

UQualifiedReferenceExpression

UReferenceExpression

(requestCode)

UReferenceExpression (Activity)

UReferenceExpression

(RESULT_OK)

66 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

// ...

}

}

66

reference: UReferenceExpression

67 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

// ...

}

}

67

UQualifiedReferenceExpression

68 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

// ...

}

}

68

UBinaryExpression

69 of 142

val comparison: UBinaryExpression =

reference.getParentOfType(UBinaryExpression::class.java)

69

70 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

// ...

}

}

70

leftOperand: UExpression

rightOperand: UExpression

71 of 142

val operand =

comparison.leftOperand as? UReferenceExpression

71

72 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

// ...

}

}

72

method: UMethod

73 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (requestCode == Activity.RESULT_OK) {

Uri resultUri = result.getUri();

// ...

}

}

73

method.parameterList.parameters[0]

74 of 142

val method: UMethod? =

reference.getParentOfType(UMethod::class.java)

val paramRequestCode = method.parameterList.parameters[0]

74

75 of 142

if (operand.resolve() == paramRequestCode) {

context.report(

issue = ActivityResultDetector.ISSUE,

location = context.getLocation(comparison),

message = "Error!"

)

}

75

76 of 142

if (operand.resolve() == paramRequestCode) {

context.report(

issue = ActivityResultDetector.ISSUE,

location = context.getLocation(comparison),

message = "Error!"

)

}

76

77 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

if (requestCode == Activity.RESULT_OK) {

Uri uri = data.getData();

// ...

}

}

77

operand: UReferenceExpression

78 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

if (requestCode == Activity.RESULT_OK) {

Uri uri = data.getData();

// ...

}

}

78

resolve()

79 of 142

if (operand.resolve() == paramRequestCode) {

context.report(

issue = ActivityResultDetector.ISSUE,

location = context.getLocation(comparison),

message = "Error!"

)

}

79

80 of 142

if (operand.resolve() == paramRequestCode) {

context.report(

issue = ActivityResultDetector.ISSUE,

location = context.getLocation(comparison),

message = "Error!"

)

}

80

81 of 142

class CustomIssueRegistry : IssueRegistry() {

override val api = CURRENT_API

override val issues: List<Issue> = listOf(

ActivityResultDetector.ISSUE

)

}

81

82 of 142

class CustomIssueRegistry : IssueRegistry() {

override val api = CURRENT_API

override val issues: List<Issue> = listOf(

ActivityResultDetector.ISSUE

)

}

82

83 of 142

HTML

83

Tinkoff.ru

84 of 142

IDE

84

Tinkoff.ru

85 of 142

IDE

85

Tinkoff.ru

86 of 142

Проблема

86

Tinkoff.ru

87 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (Activity.RESULT_OK == requestCode) {

Uri resultUri = result.getUri();

// ...

}

}

87

88 of 142

@Override

void onActivityResult(int requestCode, int resultCode, Intent data) {

// ...

if (Activity.RESULT_OK == requestCode) {

Uri resultUri = result.getUri();

// ...

}

}

88

89 of 142

val operand =

comparison.leftOperand as? UReferenceExpression

89

90 of 142

val operand: UReferenceExpression? =

comparison.leftOperand as? UReferenceExpression

getAnotherOperand(comparison, reference)

90

91 of 142

private fun getAnotherOperand(

comparison: UBinaryExpression,

referenceToField: UReferenceExpression

): UReferenceExpression? {

return if (comparison.leftOperand == referenceToField) {

comparison.rightOperand

} else {

comparison.leftOperand

} as? UReferenceExpression

}

91

92 of 142

IDE

92

Tinkoff.ru

93 of 142

Проблема №2

93

Tinkoff.ru

94 of 142

94

95 of 142

val method = reference.getParentOfType(UMethod::class.java)

if (!isOnActivityResult(context, method)) {

return

}

95

96 of 142

fun isOnActivityResult(

context: JavaContext,

method: UMethod

): Boolean {

return context.evaluator.methodMatches(

method,

"android.app.Activity",

false,

"int", "int", "android.content.Intent"

)

}

96

97 of 142

Utils

97

Tinkoff.ru

98 of 142

SdkConstants

98

Tinkoff.ru

99 of 142

Utils

  • LintUtils
  • UastLintUtils
  • extenstions

99

Tinkoff.ru

100 of 142

Отладка

100

Tinkoff.ru

101 of 142

fun UElement.asRecursiveLogString(): String

101

102 of 142

UMethod (name = onActivityResult)

UAnnotation (fqName = java.lang.Override)

UParameter (name = requestCode)

UParameter (name = resultCode)

UParameter (name = data)

UBlockExpression

UIfExpression

UBinaryExpression (operator = ===)

USimpleNameReferenceExpression (id = requestCode)

UQualifiedReferenceExpression

USimpleNameReferenceExpression (id = Activity)

USimpleNameReferenceExpression (id = RESULT_OK)

102

103 of 142

PsiViewer plugin

103

Tinkoff.ru

104 of 142

# ~/.AndroidStudio3.x/config/idea.properties

idea.is.internal=true

104

105 of 142

View PSI structure

105

Tinkoff.ru

106 of 142

Отладка

  • -Dorg.gradle.debug=true

106

Tinkoff.ru

107 of 142

$ ./gradle lintDebug \

-Dorg.gradle.debug=true \

--no-daemon

107

108 of 142

Отладка

  • -Dorg.gradle.debug=true
  • Run → Attach to Process

108

Tinkoff.ru

109 of 142

Отладка

109

Tinkoff.ru

110 of 142

LintFix

110

Tinkoff.ru

111 of 142

val paramResultCode = method.parameterList.parameters[1]

val fix = LintFix.create()

.replace()

.text(paramRequestCode.name)

.with(paramResultCode.name)

.build()

111

112 of 142

val paramResultCode = method.parameterList.parameters[1]

val fix = LintFix.create()

.replace()

.text(paramRequestCode.name)

.with(paramResultCode.name)

.build()

112

113 of 142

context.report(

issue = ActivityResultDetector.ISSUE,

location = context.getLocation(comparison),

message = "Error!",

quickfixData = fix

)

113

114 of 142

LintFix

114

Tinkoff.ru

115 of 142

LintFix

115

Tinkoff.ru

116 of 142

Тесты

116

Tinkoff.ru

117 of 142

val testData = """

import android.app.Activity;

import android.content.Intent;

class MyActivity extends Activity {

@Override

public void onActivityResult(<...>) {

if (requestCode == Activity.RESULT_OK) {

}

}

}

""".trimIndent()

117

118 of 142

val expectedText = """

|src/MyActivity.java:7: Error: Error!

| if (requestCode == Activity.RESULT_OK) {

| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

|1 errors, 0 warnings

""".trimMargin()

118

119 of 142

@Test

fun basicScenario() {

val testData = "<...>"

val expectedText = "<...>"

TestLintTask.lint()

.files(java(testData))

.issues(ActivityResultDetector.ISSUE)

.run()

.expect(expectedText)

}

119

120 of 142

val expectedFixDiffs = """

|Fix for src/MyActivity.java line 7: Replace:

|@@ -7 +7

|- if (requestCode == Activity.RESULT_OK) {

|+ if (resultCode == Activity.RESULT_OK) {

""".trimMargin()

120

121 of 142

@Test

fun quickfix() {

val testData = "<...>"

val expectedFixDiffs = "<...>"

TestLintTask.lint()

.files(java(testData))

.issues(ActivityResultDetector.ISSUE)

.run()

.expectErrorCount(1)

.expectFixDiffs(expectedFixDiffs)

}

121

122 of 142

Решение проблем

122

Tinkoff.ru

123 of 142

java.lang.AssertionError: This test requires an Android SDK: No SDK configured.

123

124 of 142

Запуск тестов

  • Run → Edit configurations

124

Tinkoff.ru

125 of 142

class MyClass {

@MyAnnotation

fun foobar() {

}

}

125

126 of 142

UFile (package = )

UClass (name = MyClass)

UAnnotationMethod (name = foobar)

UBlockExpression

UAnnotationMethod (name = MyClass)

126

127 of 142

UFile (package = )

UClass (name = MyClass)

UAnnotationMethod (name = foobar)

UBlockExpression

UAnnotationMethod (name = MyClass)

127

128 of 142

Решения

  • скопировать код в тест (stubs)

128

Tinkoff.ru

129 of 142

val myAnnotation = "annotation class MyAnnotation"

TestLintTask.lint()

.files(kotlin(testData), kotlin(myAnnotation))

.issues(ISSUE)

.run()

.expect(expectedText)

129

130 of 142

val myAnnotation = "annotation class MyAnnotation"

TestLintTask.lint()

.files(kotlin(testData), kotlin(myAnnotation))

.issues(ISSUE)

.run()

.expect(expectedText)

130

131 of 142

UFile (package = )

UClass (name = MyClass)

UAnnotationMethod (name = foobar)

UAnnotation (fqName = MyAnnotation)

UBlockExpression

UAnnotationMethod (name = MyClass)

131

132 of 142

UFile (package = )

UClass (name = MyClass)

UAnnotationMethod (name = foobar)

UAnnotation (fqName = MyAnnotation)

UBlockExpression

UAnnotationMethod (name = MyClass)

132

133 of 142

Решения

  • скопировать код в тест (stubs)
  • подключить JAR

133

Tinkoff.ru

134 of 142

val targetPath = "libs/my-lib.jar"

val producer = object : TestFile.BytecodeProducer() {

override fun produce() = // read bytes from resources

}

val myLib = bytecode(targetPath, producer)

TestLintTask.lint()

.files(kotlin(file), classpath(targetPath), myLib)

.issues(ISSUE)

.run()

.expect(expectedText)

134

135 of 142

Недостатки

135

Tinkoff.ru

136 of 142

Недостатки

  • мало документации
  • высокий порог входа
  • рефакторинг может принести проблем
  • требуется поддержка
  • баги

136

Tinkoff.ru

137 of 142

Заключение

137

Tinkoff.ru

138 of 142

Заключение

  • чем раньше выявим баг - тем раньше его исправим
  • то, что можно автоматизировать - нужно автоматизировать
  • lint - не панацея

138

Tinkoff.ru

139 of 142

Полезные материалы

  • Kotlin Static Analysis with Android Lint
  • Safe(r) Kotlin Code - Static Analysis Tools for Kotlin
  • Coding in Style: Static Analysis with Custom Lint Rules
  • lint-dev - Google Groups

139

Tinkoff.ru

140 of 142

Android Lint performance probe

140

Tinkoff.ru

141 of 142

Вопросы?

t.me/italankin

github.com/italankin

141

142 of 142

142