A great way to make your library easier to use it to generate code documentation for its public interface. The default way to do this in Kotlin is to generate KDoc using the official Dokka plugin.
This post will cover some challenges in configuring Dokka and explain some neat tricks to improve your documentation.
Introduction#
The equivalent of JavaDoc for Kotlin is called KDoc. While it is very similar to the former, it also supports inline Markup and allows to easily link to other elements using [ ]
brackets.
/**
* A group of *members*.
*
* This class has no useful logic;
* it's just a documentation example.
*
* @param T the type of a member in this group.
* @property name the name of this group.
* @constructor Creates an empty group.
*/
class Group<T>(val name: String) {
/**
* Adds a [member] to this group.
* @return the new size of the group.
*/
fun add(member: T): Int { ... }
}
The documentation generation tool is called Dokka. It comes with a Gradle plugin and can generate documentation in multiple formats such as JavaDoc, HTML and even Markdown optimized for Github pages! Neat!
Basic Dokka configuration#
Adding Dokka requires to define a dependency in your top-level build.gradle
file:
buildscript {
repositories {
jcenter()
}
dependencies {
classpath "org.jetbrains.dokka:dokka-gradle-plugin:0.10.1"
}
}
repositories {
jcenter()
}
And applying the plugin to the build.gradle
of the module(s) for which you would like to generate documentation:
apply plugin: 'org.jetbrains.dokka'
dokka {
outputFormat = 'html' // use 'javadoc' to get standard java docs
outputDirectory = "$buildDir/javadoc"
configuration {
includeNonPublic = false
skipEmptyPackages = true
skipDeprecated = true
reportUndocumented = true
jdkVersion = 8
}
}
Congratulations, you can now start generating documentation for your code:
./gradlew :library:dokka
Challenges#
However, when your library contains several modules, there are a few interesting challenges:
- Required to use a fat AAR plugin to include all modules in the AAR artifact
- There is no visibility modifier to make classes only visible within the project
The first challenge causes Dokka not to include the sources of all submodules. Consequently the resulting [KDoc] only contains documentation for your main library
module.
Note: this is because the fat AAR plugin includes the submodules as
compileOnly
dependencies when using theembed
dependency. (See source code)
The second challenge bloats the documentation with a lot of classes that shouldn’t be part of the API:
- the
internal
modifier is too restrictive as it doesn’t allow modules within the library to use each other’s classes. - the
public
modifier is not restrictive enough and exposes classes to any other project using your library.
Unfortunately, using public modifiers is currently the only way to have multiple module libraries until issue 62121508 gets fixed.
Multi-module libraries#
Luckily there is a way to directly tell Dokka what sources it should include in the documentation via the sourceRoots
attribute.
dokka {
configuration {
...
sourceRoots = ... // ENTER SOURCE ROOTS HERE
}
}
Though this doesn’t take a String
pointing to the sources, instead it requires a wrapper object a SourceRoot
, which has an attribute path
. 🤔
The easiest way to create a SourceRoot
is to create a GradleSourceRootImpl
and set it’s path:
import org.jetbrains.dokka.gradle.GradleSourceRootImpl
def sourceRoot = new GradleSourceRootImpl()
sourceRoot.path = it
And with a bit of business logic on top, we can easily extract all sources from our directories:
// Converts the source path Strings into SourceRoot
private List<GradleSourceRootImpl> getSourceRootsToDocument() {
return getSourceRootsToDocumentAsStrings().collect {
def impl = new GradleSourceRootImpl()
impl.path = it
impl
}
}
private List<String> getSourceRootsToDocumentAsStrings() {
def sources = new ArrayList<>()
sources += "$rootDir/app/src/main/java"
sources += getSourceDirs("$rootDir/features")
sources += getSourceDirs("$rootDir/libraries")
// add other locations of sources here
sources
}
private List<String> getSourceDirs(String directoryPath) {
file(directoryPath).listFiles()
.findAll { it.isDirectory() && it.name != "build" } // Non build subfolders
.collect { "${it.path}/src/main/java" } // path of main sources
.findAll { new File(it).exists() } // only include if path exists
}
Note that these methods only look in the main
source folders and that getSourceDirs
only looks at direct subfolders.
Sadly, this doesn’t work and causes compilation issues when running Dokka. (╯°□°)╯︵ ┻━┻
This can be solved by creating a new Android library module, without any source code and apply the Dokka plugin with reference to all sources there:
import org.jetbrains.dokka.gradle.GradleSourceRootImpl
apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.dokka'
android {
buildToolsVersion BuildConfig.buildTools
compileSdkVersion BuildConfig.compileSdk
defaultConfig {
minSdkVersion BuildConfig.minSdk
targetSdkVersion BuildConfig.targetSdk
}
}
dokka {
configuration {
...
sourceRoots = getSourceRootsToDocument()
}
}
In summary, with a bit of logic, we can make sure source files of new modules are automatically included in the documentation.
Excluding public classes#
Since Kotlin doesn’t have a project internal
visibility modifier, we need a way to exclude public
classes from our documentation that shouldn’t be exposed.
One way of doing that is moving all classes that are internal to your SDK to a package name ending with internal
.
package com.jeroenmols.internal
package com.jeroenmols.api.models.internal
This also gives a clear indication to users of your SDK that these classes aren’t supposed to be used.
Note that you could use proguard on your final AAR to hide non-public classes using obfuscation.
Now that all classes that should be internal are grouped, they can also be excluded from the documentation:
dokka {
configuration {
...
perPackageOption {
prefix = "com.jeroenmols.internal"
suppress = true
}
}
}
And more generically, all packages in each source root that end with internal
can be filtered:
import groovy.io.FileType
private List<String> getInternalPackages() {
def sourceRoots = getSourceRootsToDocumentAsStrings()
def internalPackages = new ArrayList<String>()
for (String root in sourceRoots) {
def subPackages = getAllSubDirectories(new File(root))
.findAll { it.path.contains("internal") }
.collect { it.path.split("src/main/java/")[1].replaceAll("/", ".") }
internalPackages.addAll(subPackages)
}
return internalPackages
}
private List<File> getAllSubDirectories(File directory) {
def list = new ArrayList<String>()
directory.eachFileRecurse (FileType.DIRECTORIES) { file ->
list << file
}
return list
}
And hooking this all together will make sure all internal
classes are excluded:
dokka {
configuration {
...
for (String p in getInternalPackages()) {
perPackageOption {
prefix = p
suppress = true
}
}
}
}
Bringing it all together#
Here’s the full example of a Dokka configuration that includes all source from each submodule and excludes internal classes:
import org.jetbrains.dokka.gradle.GradleSourceRootImpl
apply plugin: 'org.jetbrains.dokka'
dokka {
outputFormat = 'html' // use 'javadoc' to get standard java docs
outputDirectory = "$buildDir/javadoc"
configuration {
includeNonPublic = false
skipEmptyPackages = true
skipDeprecated = true
reportUndocumented = true
jdkVersion = 8
sourceRoots = getSourceRootsToDocument()
for (String p in getInternalPackages()) {
perPackageOption {
prefix = p
suppress = true
}
}
}
}
private List<String> getInternalPackages() {
def sourceRoots = getSourceRootsToDocumentAsStrings()
def internalPackages = new ArrayList<String>()
for (String root in sourceRoots) {
def subPackages = getAllSubDirectories(new File(root))
.findAll { it.path.contains("internal") }
.collect { it.path.split("src/main/java/")[1].replaceAll("/", ".") }
internalPackages.addAll(subPackages)
}
return internalPackages
}
private List<File> getAllSubDirectories(File directory) {
def list = new ArrayList<String>()
directory.eachFileRecurse (FileType.DIRECTORIES) { file ->
list << file
}
return list
}
// Converts the source path Strings into SourceRoot
private List<GradleSourceRootImpl> getSourceRootsToDocument() {
return getSourceRootsToDocumentAsStrings().collect {
def impl = new GradleSourceRootImpl()
impl.path = it
impl
}
}
private List<String> getSourceRootsToDocumentAsStrings() {
def sources = new ArrayList<>()
sources += "$rootDir/app/src/main/java"
sources += getSourceDirs("$rootDir/features")
sources += getSourceDirs("$rootDir/libraries")
// add other locations of sources here
sources
}
private List<String> getSourceDirs(String directoryPath) {
file(directoryPath).listFiles()
.findAll { it.isDirectory() && it.name != "build" } // Non build subfolders
.collect { "${it.path}/src/main/java" } // path of main sources
.findAll { new File(it).exists() } // only include if path exists
}
Wrap-up#
This post covered how to configure Dokka to generate KDoc documentation. It explained how Dokka can be used for multi-module libraries and how public classes of submodules can be excluded.
If you’ve made it this far you should probably follow me on Mastodon. Feel free to leave a comment below!