Men build too many walls and not enough bridges.
Joseph Fort Newton
Domain-specific languages (DSLs) regularly come up in discussions related to modern languages like Kotlin and Scala because these allow creating simple DSLs quickly within the language. Such internal DSLs can greatly improve code readability, development efficiency, and changeability. This chapter introduces DSLs and how to create them in Kotlin. It also explores the two most popular Kotlin DSLs on Android, namely a Kotlin DSL for Gradle build configurations and one for creating Android layouts.
DSLs are by no means new; research goes back decades1 and is only increasing in recent times, from language modeling in general2 to DSLs in particular.3 Today, DSLs are pervasive in software development. This section explains the term and discusses how DSLs can help improve your code.
1. https://onlinelibrary.wiley.com/doi/abs/10.1002/spe.4380070406
3. http://www.se-rwth.de/publications/MontiCore-a-Framework-for-Compositional-Development-of-Domain-Specific-Languages.pdf
As the name implies, a domain-specific language is a language focused on, and often restricted to, a certain application domain. You are likely familiar with many DSLs already, such as SQL, LaTeX,4 or regular expressions. These are in contrast to general-purpose languages like Kotlin, Java, C++,5 Python,6 and others.
4. https://www.latex-project.org/
5. http://www.stroustrup.com/C++.html
Focusing on a particular domain allows a more focused and clean syntax and hence better solutions. This is a general trade-off between generic approaches and specific approaches, and it is the reason why languages like COBOL7 and Fortran8 were initially created with a specific domain in mind: business processing and numerical computation, respectively.
7. http://archive.computerhistory.org/resources/text/Knuth_Don_X4100/PDF_index/k-8-pdf/k-8-u2776-Honeywell-mag-History-Cobol.pdf
8. http://www.ecma-international.org/publications/files/ECMA-ST-WITHDRAWN/ECMA-9,%201st%20Edition,%20April%201965.pdf
However, these are different from DSLs written in Kotlin because they are stand-alone languages. When you create a DSL in Kotlin, it is called an embedded DSL—it is embedded into a general-purpose language. This has several consequences that come into play when we discuss benefits and potential drawbacks of these DSLs.
As with any approach or tool, there are both pros and cons to consider when developing or using DSLs. Van Deursen, Klint, and Visser9 provide a good overview of both in their literature survey. Here, we view them under the light of embedded DSLs written in Kotlin.
9. http://sadiyahameed.pbworks.com/f/deursen+-+DSL+annotated.pdf
The main benefits mentioned by van Deursen et al. are the following:
Solutions can be expressed on the abstraction level of the domain, enabling domain experts to understand, validate, optimize, and often even develop their own solutions in the DSL. This also applies to Kotlin DSLs but (in any case) requires an adequately designed DSL.
DSL code is concise, expressive, and reusable. This certainly applies to Kotlin DSLs just like to other idiomatic Kotlin code.
DSLs represent domain knowledge and thus facilitate documentation and reuse of this knowledge. Again, this applies to any adequately designed DSL.
In addition to these, there are several advantages specific to a Kotlin DSL:
It does not add a new technology to your stack because it is simply Kotlin code.
As an embedded DSL, it allows you to use any of Kotlin’s language features such as storing recurring values in variables or using loops inside your DSL.
Because it is pure Kotlin code, your DSL is statically typed, and the excellent tool support automatically works for your DSL, including autocomplete, jumping to declarations, showing docs, and code highlighting.
In summary, DSLs are particularly useful where they can embody domain knowledge and reduce complexity by offering a clean, readable API for developers and domain experts.
In their survey, van Deursen et al. also mention several drawbacks of DSLs that we will again examine in the light of Kotlin DSLs. Potential drawbacks of DSLs in general are as follows:
DSLs can be hard to design, implement, and maintain. This partially applies to Kotlin DSLs as well, especially the design process for a good DSL must involve domain experts.10 However, as you will see in the examples in this chapter, developers themselves are the domain experts for the DSLs discussed in this chapter (such as the Kotlin Gradle DSL).
10. http://www.se-rwth.de/publications/Design-Guidelines-for-Domain-Specific-Languages.pdf
Educating DSL users can be costly. In the case of Kotlin DSLs, users are either Kotlin developers who already know the language or domain experts who may need educating but for whom the DSL greatly facilitates understanding compared to non-DSL code.
Finding the proper scope for a DSL can be difficult. This is independent of whether you use a Kotlin DSL or not. Depending on your domain, it may be hard to choose the proper scope. One way that Kotlin may help is that implementing a prototypical DSL is fast and thus allows evaluating a DSL with a certain scope and making adjustments to its scope as needed.
It can be difficult to balance between domain-specific and general-purpose constructs. This issue is mostly alleviated in Kotlin DSLs (and any embedded DSL) because you can focus on the domain-specific aspects and still tap into all of Kotlin’s general-purpose constructs. In effect, finding the balance is shifted from DSL design to DSL usage because users may use general-purpose language features to any extent they choose (which can hamper understandability if overused).
DSLs may be less efficient than hand-coded software. Fortunately, this does not apply to Kotlin DSLs because they typically compile to the more verbose code you would write without the DSL. Because Kotlin DSLs make heavy use of higher-order functions, using inline functions is essential to avoid any overhead.
Another drawback of embedded DSLs in particular is their limited expressiveness. Being embedded into a general-purpose language, they are naturally constrained in their syntax and in what they can express. For Kotlin DSLs in particular, this chapter focuses on DSLs that allow constructing objects more easily.
All in all, most of the common drawbacks of DSLs do not apply to Kotlin DSLs or are at least alleviated. Still, DSLs are not a golden hammer. Not every object creation or configuration must be wrapped into a DSL. But if it can be used to encapsulate boilerplate, reduce complexity, or help domain experts contribute, a DSL can greatly benefit your project. Embedded DSLs, such as in Kotlin, remove most of the effort involved in creating DSLs and thus greatly reduce the barrier to introducing DSLs to your workflow (but they are also limited in their capabilities).
In this section, you will write your own DSL in Kotlin from scratch to understand the underlying structure. Kotlin DSLs are primarily based on higher-order functions, lambdas with receivers, default values, and extension functions. The resulting DSL should allow you to create user objects with the syntax shown in Listing 9.1.
user {
username = "johndoe"
birthday = 1 January 1984
address {
street = "Main Street"
number = 42
postCode = "12345"
city = "New York"
}
}
As you can see, this syntax to create objects almost resembles JSON. It hides Kotlin as the underlying language (because it avoids using general-purpose language constructs) and hence does not require users to know Kotlin. Nontechnical team members would be able to understand, validate, and change the user.
To explore how such a DSL works, you will build one from the top down. The entry point to the DSL is the user
function. You know this trick by now. It is simply a higher-order function that accepts a lambda as its last parameter, thus allowing the syntax from Listing 9.1. The user
function can be declared as shown in Listing 9.2.
fun user(init: User.() -> Unit): User {
val user = User()
user.init()
return user
}
The function accepts a lambda with receiver so that the lambda effectively becomes an extension of the User
class. This allows accessing properties such as username
and birthday
directly from within the lambda. You may notice that you can write this function a lot better using Kotlin’s apply
function, as shown in Listing 9.3.
fun user(init: User.() -> Unit) = User().apply(init)
The User
class is a simple data class, and for this first version of the DSL, it partly uses nullable fields to keep things simple. Listing 9.4 shows its declaration.
import java.time.LocalDate
data class User(
var username: String = "",
var birthday: LocalDate? = null,
var address: Address? = null
)
The code so far accounts for the creation of users with username and birthday in DSL syntax, but there’s currently no way to nest an address object as in the initial example. Adding the address is simply a matter of repeating the process, this time with a member function of User
as implemented in Listing 9.5.
data class User(…) {
fun address(init: Address.() -> Unit) {
address = Address().apply(init)
}
}
data class Address(
var street: String = "",
var number: Int = -1,
var postCode: String = "",
var city: String = ""
)
The address
function follows the same concept as the user
function, but additionally assigns the created address to the user’s address
property. With these few lines of code, you are now able to create a user in DSL syntax as in Listing 9.1 (except for the date).
However, there are several downsides to the current implementation.
It relies on angelic developers who carefully initialize every field. But it is easy to forget to initialize one or assign an invalid value.
Working with the resulting User
object is inconvenient due to its nullable properties.
The DSL allows undesired nesting of its constructs.
To illustrate this last issue, Listing 9.6 shows an undesirable but currently valid use of the DSL.
user {
address {
username = "this-should-not-work"
user {
address {
birthday = LocalDate.of(1984, Month.JANUARY, 1)
}
}
}
}
This construct mitigates the DSL advantages of readability and reducing complexity. Properties of the outer scope are visible so that username
can be set inside the address
block, and both can be nested arbitrarily. This issue is addressed later in this chapter.
The first aspect to be improved are the nullable and mutable properties. The desired DSL will effectively be a type-safe builder.11 Accordingly, builders12 are used in the underlying implementation to accumulate object data, validate the data, and ultimately build the user object. First, write down the desired data classes, as shown in Listing 9.7.
11. https://kotlinlang.org/docs/reference/type-safe-builders.html
12. https://sourcemaking.com/design_patterns/builder
import java.time.LocalDate
data class User(val username: String, val birthday: LocalDate, val address: Address)
data class Address(
val street: String,
val number: Int,
val postCode: String,
val city: String
)
This way, because both LocalDate
and Address
are immutable, the User
class is immutable, too. Objects of these two classes are now constructed by builders so the next step is to add those, starting with the UserBuilder
shown in Listing 9.8.
class UserBuilder {
var username = "" // Gets assigned directly in DSL => public
var birthday: LocalDate? = null // Gets assigned directly in DSL => public
private var address: Address? = null // Is built via builder => private
fun address(init: AddressBuilder.() -> Unit) { // Nested function to build address
address = AddressBuilder().apply(init).build()
}
fun build(): User { // Validates data and builds user object
val theBirthday = birthday
val theAddress = address
if (username.isBlank() || theBirthday == null || theAddress == null)
throw IllegalStateException("Please set username, birthday, and address.")
return User(username, theBirthday, theAddress)
}
}
The builder differentiates between properties that should be assigned directly (and are thus public) and properties that are created using a nested DSL syntax (the address) that are thus private. The address
function is moved from the User
class to the UserBuilder
, and now accepts a lambda that has AddressBuilder
as its receiver. Also, you can perform arbitrary validation before finally building the object in build
. You could use this to implement optional properties as well. Next, you need a builder for address objects, as shown in Listing 9.9.
class AddressBuilder {
var street = ""
var number = -1
var postCode = ""
var city = ""
fun build(): Address {
if (notReady())
throw IllegalStateException("Please set street, number, postCode, and city.")
return Address(street, number, postCode, city)
}
private fun notReady()
= arrayOf(street, postCode, city).any { it.isBlank() } || number <= 0
}
In this case, there is no more nested construct under address, so the builder only has public properties for all required data and a build
method along with simple validation, but no more nested builders.
Lastly, the entry point to the DSL must be adapted as in Listing 9.10 to use the builder.
fun user(init: UserBuilder.() -> Unit) = UserBuilder().apply(init).build()
The lambda’s receiver is now UserBuilder
. Accordingly, the init
function is applied to the UserBuilder
, and calling build
is required. So inside init
, you can initialize the public properties directly or call a DSL function to build a more complex object, such as an address.
The current DSL allows adding an arbitrary number of address
blocks, but each one would override the address from the previous. So a user can currently only have a single address, but multiple may be desired. There are different ways to design this part of the DSL.
Check in your DSL if the address
function was already called and disallow another call so that users can only have a single address and the DSL allows only one address
block.
Allow multiple calls to the address
function and add each new address to a list.
Implement a dedicated addresses
block that contains all addresses.
For now, let’s assume a user can indeed have multiple addresses but there is no dedicated block to hold them (the second possibility). One way to achieve this easily is to give the data class a list of addresses, and then in address
, add the built object to that list as done in Listing 9.11.
data class User(…, val addresses: List<Address>)
class UserBuilder {
// …
private val addresses: MutableList<Address> = mutableListOf()
fun address(init: AddressBuilder.() -> Unit) {
addresses.add(AddressBuilder().apply(init).build())
}
fun build(): User { … }
}
This now lets you add multiple address
blocks, and each one adds another address to the user object. Next, a dedicated addresses
block should encompass all addresses, yielding the syntax shown in Listing 9.12.
user {
username = "johndoe"
birthday = LocalDate.of(1984, Month.JANUARY, 1)
addresses { // New dedicated addresses block
address { // All address blocks must be placed here
street = "Main Street"
number = 42
postCode = "12345"
city = "New York"
}
address {
street = "Plain Street"
number = 1
postCode = "54321"
city = "York"
}
}
}
Here, addresses can only be created inside the addresses
block. To implement such an additional nesting, you need a helper class, an object of which is built by the addresses
block. Listing 9.13 adds such a class that simply represents a list of addresses.
class UserBuilder {
// …
private val addresses: MutableList<Address> = mutableListOf()
inner class Addresses : ArrayList<Address>() {
fun address(init: AddressBuilder.() -> Unit) {
add(AddressBuilder().apply(init).build())
}
}
fun addresses(init: Addresses.() -> Unit) { // ‘Addresses’ is the receiver now
addresses.addAll(Addresses().apply(init))
}
fun build(): User {
val theBirthday = birthday
if (username.isBlank() || theBirthday == null || addresses.isEmpty()) throw …
return User(username, theBirthday, addresses)
}
}
This is all you need to enable the syntax shown in Listing 9.12. In general, to allow arbitrarily deep nesting, you must introduce the appropriate helper classes and methods, like Addresses
and the addresses
function in this case. Once you are familiar with creating DSLs like this, you could even generate (most of) the underlying code because the structure always follows the same pattern. In fact, JetBrains does this with a React DSL used internally.13
13. Source: “Create Your Own DSL in Kotlin” by Victor Kropp (https://youtu.be/tZIRovCbYM8)
@DslMarker
This small DSL is now mostly finished, but the problems of arbitrary nesting and accessing properties of an outer scope remain (see Listing 9.6). To help alleviate the problem of accessing the outer scope, Kotlin 1.1 introduced the @DslMarker
annotation. It’s a meta-annotation that can be used only on other annotations, such as @UserDsl
shown in Listing 9.14.
@DslMarker
annotation class UserDsl
Now, whenever there are two implicit receivers of a lambda (for instance, when nesting deeper than one level in your DSL), and both are annotated with @UserDsl
, only the innermost receiver is accessible. So if you annotate all your DSL classes with it, you can no longer access User
properties inside the address
block (or addresses
block). It also prevents nesting an address
block into another address
block because the address
function belongs to the outer receiver (UserBuilder
) and not the innermost one.
What is not prevented this way is calling the user
function again from somewhere within the DSL because it is just a top-level function and thus accessible everywhere. However, this is a mistake developers are unlikely to make accidentally. If you want to prevent it, add a deprecated member method to UserBuilder
that overshadows the top-level function, as shown in Listing 9.15.
@Deprecated("Out of scope", ReplaceWith(""), DeprecationLevel.ERROR)
fun user(init: UserBuilder.() -> Unit): Nothing = error("Cannot access user() here.")
With this, your IDE immediately complains if you try to call user
from within another call to user
. It will not compile, either.
Note
You can follow this same procedure to implement a type-safe builder DSL even if you don’t own the classes (or if they are implemented in Java) by using extension functions.
Only in the case of annotations, it is a little more tricky because you cannot annotate a thirdparty class. Instead, you can annotate the lambda receiver:
fun user(init: (@UserDsl UserBuilder).() -> Unit)
= UserBuilder().apply(init).build()
To enable annotating types as done above, add the corresponding annotation target (also, retaining the annotation info after compilation is not necessary):
@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE) // Can be used on types
@Retention(AnnotationRetention.SOURCE)
annotation class UserDsl
As embedded DSLs, your Kotlin DSLs can make use of Kotlin’s stack of language features. For example, you could use variables within your DSL naturally and without any extra effort (see Listing 9.16).
user {
// …
val usercity = "New York"
addresses {
address {
// …
city = usercity
}
address {
// …
city = usercity
}
}
}
You could also use conditionals, loops, and other constructs. This automatically allows DSL users to avoid code duplication within the DSL but requires programming knowledge. Other powerful features—such as extensions, infix functions, and operators—can be used to make the code read even more naturally. As an example, Listing 9.17 enables a more natural way to write dates with an infix extension.
infix fun Int.January(year: Int) = LocalDate.of(year, Month.JANUARY, this)
user {
username = "johndoe"
birthday = 1 January 1984
// …
}
In reality, you will need 12 extensions to cover all the months, but it can be worth it if your DSL users have to denote many dates because it prevents potential mistakes from zero-indexing versus one-indexing the days and months.
With the addition of this extension function, you can now write code as shown in the initial Listing 9.1, or with a dedicated addresses
block if you prefer that convention. But there is still room for improvement because the DSL currently comes with overhead for lambda objects created. You can resolve this issue by inlining the higher-order functions (see Listing 9.18).
@UserDsl
class UserBuilder {
// …
val addresses: MutableList<Address> = mutableListOf()
inner class Addresses : ArrayList<Address>() {
inline fun address(init: AddressBuilder.() -> Unit) { … } // Now inlined
}
inline fun addresses(init: Addresses.() -> Unit) { … } // Now inlined
// …
}
inline fun user(init: UserBuilder.() -> Unit) = // Now inlined
UserBuilder().apply(init).build()
Note that, to inline the addresses
function, the addresses
property it uses must become public as well. Otherwise, it may not be accessible in the place where it’s inlined. The other two higher-order functions can be inlined without further ripple effects. Now, using the DSL has no performance overhead compared with using the builders directly.
A fantastic use case for such a DSL are layouts. Here, the underlying object created by the type-safe builder DSL is a root view that encompasses a layout, such as a linear layout or a constraint layout. Creating a layout programmatically like this has several advantages over XML layouts.
DSL layouts provide type safety and null safety.
Building the layout is more efficient compared to XML because it costs less CPU time and battery life.
DSL layouts are more reusable; with XML, you would usually at least have to adjust the element IDs.
In this section, you learn how to programmatically create layouts the hard way first and then familiarize yourself with Anko Layouts, part of JetBrains’ utility library for Android called Anko.14 To do so, this section covers how to rewrite the layout for the AddTodoActivity
from the Kudoo app using Anko.
14. https://github.com/Kotlin/anko
Before introducing Anko, it is worth mentioning that you could create layouts programmatically without any library. Listing 9.19 shows the code required to implement the AddTodoActivity
layout programmatically without Anko.
class AddTodoActivity : AppCompatActivity() {
// …
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(createView()) // No inflating of an XML layout
viewModel = getViewModel(TodoViewModel::class)
}
private fun createView(): View {
val linearLayout = LinearLayout(this).apply { // Sets up the linear layout
orientation = LinearLayout.VERTICAL
}
val etNewTodo = EditText(this).apply { // Sets up the EditText
hint = getString(R.string.enter_new_todo)
textAppearance = android.R.style.TextAppearance_Medium
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
val btnAddTodo = Button(this).apply { // Sets up the Button
text = getString(R.string.add_to_do)
textAppearance = android.R.style.TextAppearance
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply { gravity = Gravity.CENTER_HORIZONTAL }
setOnClickListener {
val newTodo = etNewTodo.text.toString()
launch(DB) { viewModel.add(TodoItem(newTodo)) }
finish()
}
}
return linearLayout.apply { // Adds views to the linear layout and returns it
addView(etNewTodo)
addView(btnAddTodo)
}
}
}
You can see that, even though Kotlin’s apply
function helps simplify the code quite a bit, creating a layout like this is quite verbose. There’s no support around setting layout parameters, defining listeners, or using string resources to set texts. Luckily, you can do better using Anko.
The first way to include Anko in your Gradle project is to use a metadependency that incorporates all of Anko’s features. Apart from Anko Layouts, this includes Anko Commons, Anko SQLite, and more. Listing 9.20 shows the corresponding Gradle dependency.
def anko_version = "0.10.5"
implementation "org.jetbrains.anko:anko:$anko_version" // Includes all of Anko
You likely do not need all of Anko’s features, so there are smaller dependencies available. For the Anko Layout discussed in this section, all you need is given in Listing 9.21.
implementation "org.jetbrains.anko:anko-sdk25:$anko_version"
implementation "org.jetbrains.anko:anko-sdk25-coroutines:$anko_version"
Anko has many fine-grained dependencies for Android support libraries and coroutines, all of which are listed on GitHub.15
15. https://github.com/Kotlin/anko#gradle-based-project
Anko uses the same idea you used in the previous section to make layout creation a lot easier. For instance, creating a vertical linear layout that contains a button with a listener is achieved with a few lines of code, as shown in Listing 9.22.
verticalLayout {
button {
text = "Receive reward"
onClick { toast("So rewarding!") }
}
}
The verticalLayout
function is a utility to create a LinearLayout
with vertical orientation. By nesting another view inside the lambda, it is automatically added to the containing view. Here, the button becomes part of the linear layout. Setting the text and adding a click listener is as simple as assigning the property and using onClick
, respectively. The toast
function is also one of Anko’s many utilities, and it works just like the one you wrote yourself.
Properties like width, height, margin, and padding are set via layout parameters, for short lparams
in Anko. These are chained to the declaration of a view to define how it should be laid out inside its parent. As an example, Listing 9.23 makes the button span its parent’s width and adds margins on all sides to it.
verticalLayout {
button { … }.lparams(width = matchParent) {
margin = dip(5)
}
}
Here, the lparams
function is chained to the button
call. There are many overloads of lparams
, one of which allows setting layout width and height directly as arguments (inside parentheses). Both are set to wrapContent
by default so, in contrast to XML, you can skip those if the default works for you; Anko provides a matchParent
property for the other cases.
Inside the lparams
lambda, you can set margin and padding. As in XML, there are convenient properties for margin
(all sides), verticalMargin
, and horizontalMargin
(the same goes for padding
). The dip
function is used for dp values (density-independent pixels), and there is also an sp
function for text sizes (scale-independent pixels).
Tip
With the Anko Support plugin for Android Studio, you can preview your Anko layouts in Android Studio’s Design View—if you modularize them into an Anko component:
class ExampleComponent : AnkoComponent<MainActivity> {
override fun createView(ui: AnkoContext<MainActivity>): View = with(ui) {
verticalLayout {
button { … }.lparams(width = matchParent) { … }
}
}
}
Unfortunately, the preview requires a rebuild, so the feedback cycle is significantly slower than with XML layouts. Also, it currently seems to work with activities only, not fragments. To get the benefits of both XML and Anko, you can create your layout in XML initially and then migrate it to Anko. The process is straightforward, and Anko even comes with an XML-to-Anko converter that you can find in the menu under Code and then Convert to Anko Layouts DSL while inside an XML layout.
AddTodoActivity
to Anko LayoutsLet’s put these concepts into practice by rewriting part of the layout for the Kudoo app. The simple examples shown so far already cover most of what you need to create a proper layout with Anko. Following the same structure, you can build up the basic layout as it is used in the AddTodoActivity
, as shown in Listing 9.24.
class AddTodoActivity : AppCompatActivity() {
// …
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(createView()) // Still no inflating of an XML layout
viewModel = getViewModel(TodoViewModel::class)
}
private fun createView() = verticalLayout { // Sets up vertical linear layout
val etNewTodo = editText { // Sets up EditText and adds it to the linear layout
hintResource = R.string.enter_new_todo
textAppearance = android.R.style.TextAppearance_Medium
}.lparams(width = matchParent, height = wrapContent) {
margin = dip(16)
}
button(R.string.add_to_do) { // Sets up Button and adds it to the linear layout
textAppearance = android.R.style.TextAppearance
}.lparams(width = wrapContent, height = wrapContent) {
gravity = Gravity.CENTER_HORIZONTAL
}.setOnClickListener { // Could also use onClick inside button {…} instead
val newTodo = etNewTodo.text.toString()
launch(DB) { viewModel.add(TodoItem(newTodo)) }
finish()
}
}
}
Here, the layout makes use of variables and assignments naturally within the DSL. Again, this is a benefit of an embedded DSL. Second, it uses one of the helper properties provided by Anko to assign resources directly, namely hintResource
. This way, you can avoid calls to methods like getString
to read the value of an Android resource. Note that the views no longer require IDs for this layout; views that are required outside of the DSL code can be made accessible by assigning them to variables from an outer scope.
The code from Listing 9.24 creates a view (a LinearLayout
) that you can directly use inside the activity’s onCreate
method instead of a layout inflater. However, a better approach is to use AnkoComponent
to modularize the code, as shown in Listing 9.25.
class AddTodoActivity : AppCompatActivity() {
// …
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(AddTodoActivityUi().createView(AnkoContext.create(ctx, this)))
viewModel = getViewModel(TodoViewModel::class)
}
private inner class AddTodoActivityUi : AnkoComponent<AddTodoActivity> {
override fun createView(ui: AnkoContext<AddTodoActivity>): View = with(ui) {
verticalLayout {
val etNewTodo = editText {
hintResource = R.string.enter_new_todo
textAppearance = android.R.style.TextAppearance_Medium
}.lparams(width = matchParent, height = wrapContent) {
margin = dip(16)
}
button(R.string.add_to_do) {
textAppearance = android.R.style.TextAppearance
}.lparams(width = wrapContent, height = wrapContent) {
gravity = Gravity.CENTER_HORIZONTAL
}.setOnClickListener {
val newTodo = etNewTodo.text.toString()
launch(DB) { viewModel.add(TodoItem(newTodo)) }
finish()
}
}
}
}
}
Anko comes with builder functions for all of Android’s views, but what if you have a custom view? How can you incorporate that into the layout DSL? Fortunately, Anko is extensible in this regard so that you can extend it with your custom view via extension functions, and the syntax looks familiar. Assume you have a custom frame layout like the one in Listing 9.26 that ensures that it always has the same width as height.
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
class SquareFrameLayout(
context: Context,
attributes: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attributes, defStyleAttr) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, widthMeasureSpec) // Equal width and height
}
}
You can incorporate this into the Anko Layout DSL by adding an extension function on Android’s ViewManager
that handles its creation, as shown in Listing 9.27.
import android.view.ViewManager
import org.jetbrains.anko.custom.ankoView
inline fun ViewManager.squareFrameLayout(init: SquareFrameLayout.() -> Unit) =
ankoView({ SquareFrameLayout(it) }, theme = 0, init = init)
As you can see, the function signature closely resembles the ones you used to build the User DSL before; the lambda parameter becomes an extension of your SquareFrameLayout
. The ankoView
function is used to create the view, it can optionally apply a theme, and it handles further initialization based on the lambda expression that is passed in. Its implementation is not much different from the builder methods in the User DSL. Its first parameter represents a factory so that you can tell it how to construct the initial object before applying init
, here just using SquareFrameLayout(
it
)
. You could also add a theme parameter to your extension function and pass it along to ankoView
to allow users to set a theme. With this, you can use squareFrameLayout
from within the Anko DSL to build an object of this custom view.
Anko Layouts have several benefits over XML layouts as listed at the beginning of this section—most notably, type safety and improved performance while saving battery. However, there are also drawbacks when compared with XML. In parts, they have already become apparent throughout this section but I will list them again here.
XML layouts provide faster preview in Android Studio’s design view, hence speeding up the feedback cycle, which is crucial while working on a layout.
Autocompletion works faster in XML because the search space is a lot smaller.
Layouts are automatically separated from business logic. With Anko, you are responsible for keeping these concerns separated.
In the end, which one is the better choice for your project depends on which of these points you prioritize. In any case, I recommend starting out with an XML layout until you are satisfied with it. After that, you can evaluate the possibility of migrating it to Anko.
In 2016, Gradle announced a DSL based on Kotlin as an alternative to Groovy to write build scripts, and so the Gradle Kotlin DSL16 was born. The main reason for this decision was Kotlin’s static typing that enables better tool support in Gradle, from code completion and navigation to the ability to use all of Kotlin’s language features,17 thus making it easier to write build scripts from scratch.
16. https://github.com/gradle/kotlin-dsl
17. https://blog.gradle.org/kotlin-meets-gradle
The Gradle Kotlin DSL is a fair alternative to the usual Groovy build scripts and has its advantages and drawbacks. It is certainly not a tool you have to use today (at the time of writing); it still has its weaknesses and not the best documentation. But it is worth exploring, especially in the context of Kotlin DSLs. So in this section, you’ll rewrite your Nutrilicious build scripts using the Gradle Kotlin DSL.
Based on the existing build scripts, you will migrate to the Gradle Kotlin DSL step by step in this section. This will uncover many similarities and some differences between the Kotlin DSL and the Groovy DSL.
Note
At the time of writing, Android Studio may not immediately recognize the Gradle Kotlin DSL. In that case, try Refresh All Gradle Projects in the Gradle view, and if that does not help try restarting Android Studio.
Start migrating the simplest Gradle file, the settings.gradle
. For the Nutrilicious app, its definition in Groovy is just the one line shown in Listing 9.28.
include ":app"
As opposed to Groovy, Kotlin does not allow skipping the parentheses of such method calls, thus its equivalent in the Kotlin DSL uses parentheses, as shown in Listing 9.29.
include(":app")
You have to rename the file to settings.gradle.kts
to indicate that you are using a Kotlin script. Nothing else is required, so the project should still build successfully.
Although not as complex as the app module’s build script, the root build script introduces several new concepts of the Gradle Kotlin DSL.
The buildscript
section defines an extra for the Kotlin version along with repositories and dependencies. Its Groovy code is shown again for reference in Listing 9.30.
buildscript {
ext.kotlin_version = '1.2.50' // Extra that stores Kotlin version
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.1.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
This block looks similar using the Gradle Kotlin DSL shown in Listing 9.31.
buildscript {
extra["kotlin_version"] = "1.2.50"
repositories {
jcenter()
google()
}
dependencies {
classpath("com.android.tools.build:gradle:3.1.3")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${extra["kotlin_version"]}")
}
}
The only notable difference is that extras are defined using extra["key"] = value
, and accordingly, they must also be accessed via the extra. Also, you cannot omit the parentheses when calling the classpath
function.
The allprojects
block is in fact the exact same in both DSLs (see Listing 9.32).
allprojects {
repositories {
jcenter()
google()
}
}
The syntax to create tasks is slightly different. In Groovy, the clean
task is defined as shown in Listing 9.33.
task clean(type: Delete) {
delete rootProject.buildDir
}
Kotlin uses a higher-order function to create tasks. It accepts the task type as a generic parameter, the task name as a string, and a lambda defining the task (see Listing 9.34).
task<Delete>("clean") {
delete(rootProject.buildDir)
}
Other than that, the only difference is again the syntax for method calls. This is all that’s required to migrate the root build.gradle
file to the Gradle Kotlin DSL. To make it work, rename the file to build.gradle.kts
. Android Studio should recognize it as a Gradle build script.
The module’s build script is longer than the other scripts, but the migration is mostly straightforward.
The syntax to apply plugins is quite different. Instead of writing apply plugin: 'my-plugin'
on the top level for each one, Kotlin introduces a plugins
block, as shown in Listing 9.35.
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-android-extensions")
id("kotlin-kapt")
}
Using id
, you can use the same string as in Groovy to identify the plugins. Alternately, you could use kotlin
, which prepends "org.jetbrains.kotlin."
to the given plugin. For instance, you could use kotlin("android")
instead of id("kotlin-android")
. A full list of plugins under org.jetbrains.kotlin
is available via the Gradle plugin search.18 Personally, I prefer the consistent look of using only id
.
18. https://plugins.gradle.org/search?term=org.jetbrains.kotlin
Next comes the android
block. As shown in Listing 9.36, its definition in Kotlin is similar to the Groovy way.
android {
compileSdkVersion(27)
defaultConfig {
applicationId = "com.example.nutrilicious"
minSdkVersion(19)
targetSdkVersion(27)
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
proguardFiles("proguard-rules.pro")
}
}
}
It is not always obvious which properties can be set using property access and which require a method call. Fortunately, autocompletion helps with this. In the case of SDK versions, the Kotlin DSL requires method calls because the property type is ApiVersion
under the hood and the methods are helpers to create these objects from the given integer.
Note that existing build types are accessed using getByName
. Creating a new build type works the same way using create("buildtype") { … }
. Product flavors are accessed and created with these same two methods—but in a productFlavors
block—as you can find out using autocomplete thanks to static typing.
Adding dependencies in the Gradle Kotlin DSL is straightforward: Here, we omit dependencies that don’t show a new concept and focus on a few different ones, shown in Listing 9.37.
dependencies {
val kotlin_version: String by rootProject.extra // Uses extra from root script
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version")
// …
val moshi_version = "1.6.0"
implementation("com.squareup.moshi:moshi:$moshi_version")
kapt("com.squareup.moshi:moshi-kotlin-codegen:$moshi_version")
// …
testImplementation("junit:junit:4.12")
androidTestImplementation("com.android.support.test:runner:1.0.2")
androidTestImplementation("com.android.support.test.espresso:espresso-core:3.0.2")
}
Extras defined in the root build script are accessed by using the rootProject.extra
map as a delegate (remember you can use maps as delegates). Other than that, you simply use val
instead of def
and add parentheses to method calls. The method names themselves are the same as in Groovy—for example, implementation
and kapt
—so they are easily discoverable.
The last block in the script enables experimental Kotlin Android Extensions. Unfortunately, at the time of writing, enabling experimental Android extensions does not work in the Kotlin DSL as expected. But the DSL allows you to inject Groovy closures at any point, meaning you can always fall back to Groovy to circumvent such issues, as done in Listing 9.38.
androidExtensions {
configure(delegateClosureOf<AndroidExtensionsExtension> { // Injects Groovy code
isExperimental = true
})
}
The given lambda is transformed to a Groovy Closure<AndroidExtensionsExtension>
, where the AndroidExtensionsExtension
is also the receiver of the androidExtensions
lambda. So the closure is simply passed along. By the time you read this, the issue may have been resolved so I recommend trying it without the configure
call first.
buildSrc
in GradleOne way to modularize your Gradle builds is to make use of Gradle’s buildSrc
directory. If you add it under the project root, Gradle will compile it and add all its declarations to the classpath of your build scripts. The buildSrc
directory is placed in the project’s root directory (next to the app
directory). Its structure is the same as for any module, including a Gradle build file of its own and the directory buildSrc/src/main/java
. Inside this java
directory, add a new file GradleConfig.kt
. Any declarations in this file will be available in your build script so you can extract all your versions and dependencies into this file, as shown in Listing 9.39.
private const val kotlinVersion = "1.2.50"
private const val androidGradleVersion = "3.1.3"
private const val supportVersion = "27.1.1"
private const val constraintLayoutVersion = "1.1.0"
// All versions as in build.gradle.kts…
object BuildPlugins {
val androidGradle = "com.android.tools.build:gradle:$androidGradleVersion"
val kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
}
object Android {
val buildToolsVersion = "27.0.3"
val minSdkVersion = 19
val targetSdkVersion = 27
val compileSdkVersion = 27
val applicationId = "com.example.nutrilicious"
val versionCode = 1
val versionName = "1.0"
}
object Libs {
val kotlin_std = "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
val appcompat = "com.android.support:appcompat-v7:$supportVersion"
val design = "com.android.support:design:$supportVersion"
// All dependencies as in build.gradle.kts…
}
This file now encapsulates all the concrete versions and dependencies of the app. To scope the properties, you can place them in corresponding objects.
Next, to enable Kotlin in this module, add the build.gradle.kts
script from Listing 9.40 directly into the buildSrc
directory.
plugins {
`kotlin-dsl` // Uses ticks: ``
}
All declarations from the GradleConfig
file can now be used in the build script. For the sake of brevity, Listing 9.41 only shows snippets, but the full source is available in the GitHub repository for this book.19
19. https://github.com/petersommerhoff/kotlin-for-android-app-development
android {
// …
targetSdkVersion(Android.targetSdkVersion) // Uses the ‘Android’ object
versionCode = Android.versionCode
// …
}
dependencies {
// …
implementation(Libs.moshi) // Uses the ‘Libs’ object
kapt(Libs.moshi_codegen)
// …
}
The BuildPlugins
object can be used in the same way in the root build script. Note that you don’t need to import anything to use the declarations from buildSrc
in your build scripts.
The benefits of the Gradle Kotlin DSL were already mentioned and are based on tool support through static typing. Android Studio can autocomplete available functions for further nesting, and which other properties and methods can be called. This is especially helpful when writing or extending a build script.
On the other hand, at the time of writing, Android Studio may not correctly discover the DSL methods when migrating to the Gradle Kotlin DSL. As mentioned, a restart usually solves this problem. Unfortunately, at the time of writing, Android Studio also does not seem to pick up on the settings.gradle.kts
file correctly and does not show it under Gradle Scripts.
When using Gradle’s buildSrc
, Android Studio currently needs a rebuild to reflect updates to the files and provide the correct autocompletions. Also, Android Studio will no longer provide hints to indicate that newer versions of dependencies are available (but there is a Gradle plugin to do this20). If you currently use the root build script to store version and dependency info, following this approach can significantly clean up your root build script.
20. https://github.com/ben-manes/gradle-versions-plugin
Most of these issues refer to Android Studio and should be resolved in future releases. In summary, it is convenient to use Kotlin as a single language (that you know well) for both logic and build scripts. But considering the current tool limitations, sticking to Groovy is a reasonable choice.
You can now create your own simple Kotlin DSLs from scratch by combining higher-order functions, extensions, infix functions, and other language features. You have also seen how this concept is applied to Android layouts with Anko and Gradle build scripts with the Gradle Kotlin DSL. These two are currently the most prevalent Kotlin DSLs for Android development. Both have their advantages and drawbacks you have to weigh before deciding on which approach to use. In any case, Kotlin DSLs are a powerful tool to add to your toolbox to create even cleaner APIs to build complex objects or configurations.