도입 배경
네이티브 안드로이드 앱 개발을 하게 되면 기본적으로 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.localPath
에StudioRunPath
를 세팅해줘야 나중에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

김영훈
딜리셔스 안드로이드 개발자
"기록하고 기억하자."