스마트한 개발을 위한 Android Studio 플러그인 템플릿

Android Studio 4.1+ 플러그인 템플릿 샘플 만들기

김영훈
2021.08.23

도입 배경

네이티브 안드로이드 앱 개발을 하게 되면 기본적으로 Android Studio를 많이 사용합니다.

Android Studio에서 제공되는 템플릿은 Activity, Fragment, 기타 등을 생성할 때 기본적인 세팅을 해주는 템플릿이기 때문에, 이것만으로는 프로젝트를 진행하는데 있어 상당히 불편합니다. 이는 많은 보일러 플레이트 코드들이 존재하기 때문인데, 예를 들면 RecyclerView를 사용하게 되는 화면이나 MVVM, MVP 등의 디자인 패턴을 적용한 코드를 작성하려고 하면 추가적으로 만들어야 하는 Class가 생기고 XML 세팅도 해야 합니다. 또한 여러 사람이 함께 작업 하는 경우 미리 스타일을 정의한다 해도 다양한 스타일의 코드가 나올 수 있기 때문에 리팩토링 등을 할 때 놓치고 가는 화면이 있을 수 있습니다.

만약 화면을 개발할 때 한번에 모든 Class와 XML 파일을 세팅해 줄 수 있다면 더 빠르고 효율적인 개발이 가능할텐데, 이런 고민을 플러그인 템플릿이 도와줄 수 있습니다. 이 글에서는 RecyclerView 액티비티 생성 시 Activity, Activity XML, RecyclerAdapter, ItemViewHolder, Item XML, ViewModel을 한 번에 생성하는 Custom 플러그인 템플릿 만드는 과정을 알아보도록 하겠습니다.

플러그인 프로젝트 구성

.
├── .run                    Predefined Run/Debug Configurations
├── CHANGELOG.md            Full change history.
├── LICENSE                 License, MIT by default
├── README.md               README
├── build/                  Output build directory
├── build.gradle.kts        Gradle configuration
├── detekt-config.yml       Detekt configuration
├── gradle
│   └── wrapper/            Gradle Wrapper
├── gradle.properties       Gradle configuration properties
├── gradlew                 *nix Gradle Wrapper binary
├── gradlew.bat             Windows Gradle Wrapper binary
└── src                     Plugin sources
    └── main
        ├── kotlin/         Kotlin source files
        └── resources/      Resources - plugin.xml, icons, messages

샘플 플러그인 만들기

Android Studio ArcticFox(203.7717.56) 버전을 사용하였습니다.

IntelliJ Platform Plugin Tempalte 페이지에서 Use this template을 클릭하면 Github에 기본 플러그인 프로젝트 세팅을 하게 되고, Initial commit 메세지로 프로젝트가 main 브랜치에 push 됩니다. 이 때 Template Cleanup Github Action이 동작하고, Action이 완료 되면 Template cleanup 메세지로 push가 올라온 걸 확인할 수 있습니다.

gradle.properties

  • Template cleanup Commit에서 id, name 등 기본정보가 변경되는데 이는 변경하지 않아도 무관합니다.

  • StudioCompilePath, StudioRunPath는 IDE 실행 시 Intellij 대신 Android Studio를 사용하기 위해 경로를 세팅합니다.

  • platformPlugins 에서 우리가 사용할 java, android, kotlin 을 세팅합니다.

# IntelliJ Platform Artifacts Repositories
# -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html

pluginGroup = com.github.test.template
pluginName = Test Template
pluginVersion = 0.10.1

# See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
# for insight into build numbers and IntelliJ Platform versions.
pluginSinceBuild = 201
pluginUntilBuild = 211.*

# Plugin Verifier integration -> https://github.com/JetBrains/gradle-intellij-plugin#plugin-verifier-dsl
# See https://jb.gg/intellij-platform-builds-list for available build versions.
pluginVerifierIdeVersions = 2020.2.4, 2020.3.4, 2021.1.1

platformType = IC
platformVersion = 2020.2.4
platformDownloadSources = true

# Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html
# Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22
platformPlugins = java, com.intellij.java, org.jetbrains.android, android, org.jetbrains.kotlin

# Opt-out flag for bundling Kotlin standard library.
# See https://plugins.jetbrains.com/docs/intellij/kotlin.html#kotlin-standard-library for details.
kotlin.stdlib.default.dependency = false

StudioCompilePath = /Applications/Android Studio.app/Contents

StudioRunPath=/Applications/Android Studio.app/Contents

build.gradle.kts

  • intellij.localPathStudioRunPath를 세팅해줘야 나중에 runIde 시 Intellij가 아닌 Android Studio가 열립니다.

나머지 세팅은 기본 세팅으로 동일합니다.

intellij {
    pluginName.set(properties("pluginName"))
    version.set(properties("platformVersion"))
    type.set(properties("platformType"))
    downloadSources.set(properties("platformDownloadSources").toBoolean())
    updateSinceUntilBuild.set(true)


    intellij.localPath.set(properties("StudioRunPath")) //localPath 를 추가 해줘야 intellij가 아닌 Android Studio 가 열림
    // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file.
    plugins.set(properties("platformPlugins").split(',').map(String::trim).filter(String::isNotEmpty))
}

tasks {
    instrumentCode {
        compilerVersion.set("203.7717.56") //현재 사용중인 Android Studio Version
    }
}

그리고 프로젝트 패키지 구조를 pluginGroup 과 동일하게 세팅 합니다.

src/main/resource/META-INF/plugin.xml

  • gradle.properties 에서 정의한 pluginGroup, pluginName을 id와 name에 세팅합니다.
  • vendor는 Dealicious으로 지정했습니다.
<idea-plugin>
    <id>com.github.test.template</id>
    <name>Test Template</name>
    <vendor>Dealicious</vendor>

		<!-- 우리가 사용할 디펜던시를 정의합니다. -->
    <depends>org.jetbrains.android</depends>
    <depends>org.jetbrains.kotlin</depends>
    <depends>com.intellij.modules.java</depends>
    <depends>com.intellij.modules.platform</depends>
    <depends>com.intellij.modules.androidstudio</depends>

    <extensions defaultExtensionNs="com.intellij">
        <applicationService serviceImplementation="com.github.test.template.services.MyApplicationService"/>
        <projectService serviceImplementation="com.github.test.template.services.MyProjectService"/>
    </extensions>

    <!-- WizardTemplateProviderImpl 파일을 생성해줍니다. 해당 클래스에서 실행될 template 을 정의할 예정입니다. -->
    <extensions defaultExtensionNs="com.android.tools.idea.wizard.template">
        <wizardTemplateProvider implementation="com.github.test.template.WizardTemplateProviderImpl" />
    </extensions>

    <applicationListeners>
        <listener class="com.github.test.template.listeners.MyProjectManagerListener"
                  topic="com.intellij.openapi.project.ProjectManagerListener"/>
    </applicationListeners>
</idea-plugin>

MyProjectManagerListener.kt

package com.github.test.template.listeners

import com.github.test.template.services.*
import com.intellij.openapi.components.*
import com.intellij.openapi.project.*

internal class MyProjectManagerListener : ProjectManagerListener {

    //Android Studio에서 프로젝트를 열 때마다 해당 fun이 호출됨
    override fun projectOpened(project: Project) {
        if (project.name.equals("Test", ignoreCase = true)) { 
        //나는 Test 라는 이름의 프로젝트를 만들어서 실행 했기 때문에 Test 라고 셋팅. 내가 사용하려는 프로젝트의 rootProject.name 과 동일하게 세팅 합니다.
            projectInstance = project
        }
        project.service<MyProjectService>()
    }

    //Android Studio에서 프로젝트를 닫을 때마다 해당 fun이 호출됨
    override fun projectClosing(project: Project) {
        if (project.name.equals("Test", ignoreCase = true)) {
            projectInstance = null
        }
        super.projectClosing(project)
    }

    companion object {
        //VirtaulFile 을 가져오기 위해 사용 되는데 프로젝트 네임이 같아야 사용 가능하게 만듦
        var projectInstance: Project? = null
    }
}

WizardTemplateProviderImpl.kt

Template 리스트를 정의합니다.

package com.github.test.template

import com.android.tools.idea.wizard.template.Template
import com.android.tools.idea.wizard.template.WizardTemplateProvider
import com.github.test.template.mvvm.*

class WizardTemplateProviderImpl : WizardTemplateProvider() {
	override fun getTemplates(): List<Template> = listOf(recyclerActivitySetupTemplate)
}

RecyclerActivitySetupTemplate.kt

생성할 템플릿에 대한 기본적인 정보를 정의합니다. Widget을 만들어 받을 파라미터와 Recipe를 정의합니다.

val recyclerActivitySetupTemplate
    get() = template {
        name = "Test RecyclerView Activity"
        description = "리사이클러뷰 액티비티"
        minApi = 16
        category = Category.Other // Check other categories
        formFactor = FormFactor.Mobile
        screens = listOf(
            WizardUiContext.FragmentGallery, WizardUiContext.MenuEntry,
            WizardUiContext.NewProject, WizardUiContext.NewModule
        )

        val packageNameParam = defaultPackageNameParameter
        val className = stringParameter {
            name = "Class Name"
            default = "Titie"
            help = "액티비티 생성 시 사용"
            constraints = listOf(Constraint.NONEMPTY)
        }

        val activityLayoutName = stringParameter {
            name = "Activity Layout Name"
            default = "Titie"
            help = "액티비티 레이아웃 생성 시 사용"
            constraints = listOf(Constraint.LAYOUT, Constraint.UNIQUE, Constraint.NONEMPTY)
            suggest = { activityToLayout(className.value.toSnakeCase()) }
        }

        widgets(
            TextFieldWidget(className),
            TextFieldWidget(activityLayoutName),
            PackageNameWidget(packageNameParam)
        )

        recipe = { data: TemplateData ->
            mvvmRecyclerActivitySetup(
                data as ModuleTemplateData,
                packageNameParam.value,
                className.value,
                activityLayoutName.value
            )
        }
    }

RecyclerActivitySetupRecipe.kt

mvvmRecyclerActivitySetup 이름의 RecipeExecutor 확장 함수 정의를 합니다.

fun RecipeExecutor.mvvmRecyclerActivitySetup(
    moduleData: ModuleTemplateData,
    packageName: String,
    className: String,
    activityLayoutName: String,
) {
    val (projectData, _, _, manifestOut) = moduleData
    val project = projectInstance ?: return

    addAllKotlinDependencies(moduleData)

    val virtualFiles = ProjectRootManager.getInstance(project).contentSourceRoots
    val virtSrc = virtualFiles.firstOrNull { it.path.contains("app/src/main/java") }?:return 
    val virtRes = virtualFiles.firstOrNull { it.path.contains("app/src/main/res") }?:return  
    val directorySrc = PsiManager.getInstance(project).findDirectory(virtSrc)?:return
    val directoryRes = PsiManager.getInstance(project).findDirectory(virtRes)?:return

    val activityClass = "${className}Activity".capitalize()
    val adapterClass = "${className}RecyclerAdatper".capitalize()
    val viewHolderClass = "${className}ItemViewHolder".capitalize()
    val viewModelClass = "${className}ViewModel".capitalize()

    //AndroidMenifest 에 추가될 정보
    mergeXml(
        manifestTemplateXml(packageName, "${className}Activity"),
        manifestOut.resolve("AndroidManifest.xml")
    )
    
    //각각의 파일이 저장 될 위치와 파일명 등 필요한 정보들을 정의한다.
    createRecyclerActivity(packageName, className, activityLayoutName, projectData)
        .save(directorySrc, packageName, "$activityClass.kt")

    createRecyclerAdapter(packageName, className)
        .save(directorySrc, "$packageName.adapter", "$adapterClass.kt")

    createViewHolder(packageName, className)
        .save(directorySrc, "$packageName.viewHolder", "$viewHolderClass.kt")

    createViewModel(packageName, className)
        .save(directorySrc, "$packageName.viewModel", "$viewModelClass.kt")

    createRecyclerActivityLayout(packageName, className)
        .save(directoryRes, "layout", "${activityLayoutName}.xml")

    createViewHolderLayout()
        .save(directoryRes, "layout", "item_${className.toSnakeCase()}.xml")
}

virtualFile path를 비교할 때 처음에는 src로 테스트를 진행 했는데 여기에 문제가 있었습니다. 원인은 모듈 라이브러리를 사용하고 있었기 때문에 첫번째 VirtualFile이 App의 위치가 아닌 모듈의 위치로 생성했기 때문인데, 이 문제를 해결하기 위해 중복되지 않는 path인 app/src/main/java, app/src/main/res를 세팅해 주었습니다.

Activity.kt

리턴된 String이 그대로 저장되기 때문에 import나 클래스 이름 등의 모든 내용이 정확하게 들어가야 합니다.

package com.github.test.template.mvvm.template.classes

import com.android.tools.idea.wizard.template.ProjectTemplateData

fun createRecyclerActivity(
    packageName: String,
    className: String,
    activityLayoutName: String,
    projectData: ProjectTemplateData
) = """
package $packageName
	
import ${projectData.applicationPackage}.R
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
    
class ${className}Activity : AppCompatActivity() {
}
""".trimIndent()

Gradle Run Plugin을 실행하면 Build/libs 안에 jar 파일을 볼 수 있습니다. 해당 jar 파일을 plugins에서 세팅하면 지금 만든 Template을 사용할 수 있습니다.

Plugin Install

Plugin 사용 예제

마치며

여러 프로젝트에 Custom 템플릿을 도입하게 되면서 초기 세팅 시간을 아끼며 개발 속도를 올릴 수 있었고, 보일러 플레이트 코드가 여러 스타일로 나오지 않도록 관리할 수 있었습니다. 특히 여러 개발자가 협업하는 프로젝트에서 더 유용하게 사용할 수 있으며, 향후 딜리셔스의 모든 프로젝트에 템플릿을 적용해 볼 계획입니다.

References

김영훈

딜리셔스 안드로이드 개발자

"기록하고 기억하자."