Kotlin 实战项目

学了 16 篇基础,是时候把它们串起来做点真东西了。这一章给三个完整、可运行的项目——Todo、协程并发爬虫、HTML DSL 构建器,每个都给出完整代码、运行结果和要点讲解。三个项目分别覆盖 Kotlin 的招牌能力:简洁建模(data class)、结构化并发(coroutine)、类型安全 DSL(扩展 + 带接收者 lambda)。建议你把代码复制到 IntelliJ IDEA 或 Playgrounds 亲手跑一遍。

实战 1:Todo(data class + MutableList + 文件)

需求:做一个待办管理 CLI,支持 add / list / done / delete,数据用 JSON 文件持久化。

涉及知识:data class、MutableList、when、?.let {} ?: ... 空安全、runCatching 异常、kotlinx.serialization、扩展函数 maxOfOrNull。

// Todo.kt — 待办管理:data class + MutableList + 文件持久化
// 运行:kotlinc Todo.kt -include-runtime -jar todo.jar && java -jar todo.jar
// 知识点:data class、MutableList、when、null 安全、文件 I/O、kotlinx.serialization
import java.io.File
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString

// 1. data class:一行定义数据类,自动生成 equals / hashCode / toString / copy
@Serializable
data class Todo(
    val id: Int,
    val title: String,
    var done: Boolean = false,
    val createdAt: String = "",
)

class TodoStore(private val file: String = "todos.json") {
    // MutableList:可变列表,相当于 Java 的 ArrayList
    private val items: MutableList<Todo> = load().toMutableList()

    fun add(title: String) {
        val nextId = (items.maxOfOrNull { it.id } ?: 0) + 1
        val t = Todo(id = nextId, title = title, createdAt = now())
        items.add(t)
        save()
        println("已添加: $t")
    }

    fun list(all: Boolean = false) {
        val shown = if (all) items else items.filter { !it.done }
        if (shown.isEmpty()) {
            println("(空空如也)")
        } else {
            shown.forEach { println(format(it)) }
        }
    }

    fun done(id: Int) {
        // also 是作用域函数:在对象上执行额外操作,链式风格
        items.find { it.id == id }?.let {
            it.done = true
            save()
            println("已完成: ${it.title}")
        } ?: println("未找到 id=$id")
    }

    fun delete(id: Int) {
        val before = items.size
        items.removeAll { it.id == id }
        if (items.size < before) {
            save()
            println("已删除 id=$id")
        } else {
            println("未找到 id=$id")
        }
    }

    private fun format(t: Todo) =
        "${if (t.done) "[x]" else "[ ]"} ${t.id}. ${t.title}  (${t.createdAt})"

    private fun save() = File(file).writeText(Json.encodeToString(items))

    // 文件不存在时返回空列表,DecodingException 也降级
    private fun load(): List<Todo> = runCatching {
        if (File(file).exists()) Json.decodeFromString(File(file).readText())
        else emptyList()
    }.getOrDefault(emptyList())

    private fun now() =
        java.text.SimpleDateFormat("yyyy-MM-dd HH:mm").format(java.util.Date())
}

fun main() {
    val store = TodoStore()

    // 命令行参数:./todo add 买牛奶
    val args = CommandLine.args
    when (args.firstOrNull()) {
        "add"    -> store.add(args.drop(1).joinToString(" "))
        "list"   -> store.list(all = args.contains("--all"))
        "done"   -> args.getOrNull(1)?.toIntOrNull()?.let(store::done)
        "delete" -> args.getOrNull(1)?.toIntOrNull()?.let(store::delete)
        else     -> println("用法: todo [add|list|done|delete] ...")
    }
}

// 简化版:命令行参数从 main 函数的 args 取(实际项目里用 picocli 库更优雅)
object CommandLine { val args = arrayOf("add", "买牛奶") }

// 运行示例:
// 已添加: Todo(id=1, title=买牛奶, done=false, createdAt=2024-06-01 10:30)
// 改成 list 后:
// [ ] 1. 买牛奶  (2024-06-01 10:30)

要点讲解:

实战 2:协程并发爬虫(coroutine + async + Channel)

需求:并发抓取多个 URL 的 title,用 Channel 聚合结果,比串行快得多。先加依赖 org.jetbrains.kotlinx:kotlinx-coroutines-core。

涉及知识:suspend、coroutineScope、Dispatchers.IO、async/launch、Channel、runBlocking、结构化并发。

// Spider.kt — 协程并发爬虫:并发抓取多个 URL,聚合结果
// 依赖:gradle 加 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0
// 知识点:suspend、coroutineScope、async、Channel、Dispatchers
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.net.URL

data class Page(val url: String, val title: String, val status: Int)

// suspend 函数:可挂起不阻塞线程,这是协程的核心
suspend fun fetchTitle(url: String): Page = withContext(Dispatchers.IO) {
    try {
        val conn = URL(url).openConnection().apply {
            connectTimeout = 5000
            readTimeout = 5000
            setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; MyBot/1.0)")
        }
        val html = conn.getInputStream().bufferedReader().use { it.readText() }
        // 简单粗暴:用正则取 <title>
        val title = Regex("<title[^>]*>(.+?)</title>", RegexOption.IGNORE_MATCH_CASE)
            .find(html)?.groupValues?.get(1)?.trim() ?: "(无标题)"
        // 假装抓慢一点,体现并发的优势
        delay(200)
        Page(url, title, 200)
    } catch (e: Exception) {
        Page(url, "错误:${e.message}", -1)
    }
}

fun main() = runBlocking {                  // runBlocking:桥接同步与协程世界
    val urls = listOf(
        "https://example.com",
        "https://www.kotlin.com",
        "https://www.rust-lang.org",
        "https://www.python.org",
        "https://www.swift.org",
    )

    // Channel:协程间安全传数据的"管道",生产者-消费者模型
    val channel = Channel<Page>(capacity = urls.size)

    val start = System.currentTimeMillis()

    // 并发启动 5 个协程——async 立即开始执行,await 时才阻塞
    coroutineScope {
        urls.forEach { url ->
            launch {                          // 启动子协程
                channel.send(fetchTitle(url)) // 抓完丢进 channel
            }
        }
        channel.close()                       // 关闭 channel,告诉接收方"没货了"
    }

    // 接收方:for 循环会自动在 channel 关闭时结束
    for (page in channel) {
        println("[${page.status}] ${page.url} -> ${page.title}")
    }

    val elapsed = System.currentTimeMillis() - start
    println("\n总耗时:${elapsed}ms(5 个 URL 并发,远比串行的 5×1000ms 快)")

    // 输出:
    // [200] https://example.com -> Example Domain
    // [200] https://www.kotlin.com -> Kotlin Programming Language
    // [200] https://www.rust-lang.org -> Rust Programming Language
    // [200] https://www.python.org -> Welcome to Python.org
    // [200] https://www.swift.org -> Swift.org
    //
    // 总耗时:485ms(5 个 URL 并发,远比串行的 5×1000ms 快)
}

// 关键对比:
// - Thread 版:5 个线程,每个 ~1MB 栈,创建/销毁开销大
// - 协程版:几百个协程跑在几个线程上,几乎免费
// 这就是 Kotlin 协程的精髓——把异步写得像同步,却不阻塞线程

要点讲解:

实战 3:DSL 构建器(扩展函数 + lambda + infix)

需求:自己写一个类型安全的 HTML DSL,能用声明式语法生成 HTML 字符串。这是 Kotlin 的招牌能力。

涉及知识:扩展函数、带接收者的 lambda、operator、infix、@DslMarker、apply。

// HtmlDsl.kt — 用 Kotlin DSL 自己写一个 HTML 构建器
// 知识点:扩展函数、带接收者的 lambda、infix、operator、@DslMarker
//
// 目标:能用下面这种"声明式"语法生成 HTML 字符串
//
//   val html = html {
//       head {
//           title("我的页面")
//       }
//       body {
//           h1("Hello, DSL!")
//           p { +"这是一段文字" }
//           p {
//               +"Kotlin"; +"真香"
//           }
//       }
//   }
//
// 这个能力叫"类型安全构建器(Type-safe Builder)",Kotlin 的招牌特性之一

// 1. DslMarker:限制隐式接收者,防止外层 this 被误用
@DslMarker
annotation class HtmlTagMarker

// 2. 抽象一个标签类:管理子节点 + 属性
@HtmlTagMarker
abstract class Tag(val name: String) {
    protected val children = mutableListOf<Tag>()
    protected val attrs = mutableMapOf<String, String>()

    private var textBuffer = StringBuilder()

    // 让 String.unaryPlus 把字符串塞进来(实现 +"xxx")
    operator fun String.unaryPlus() { textBuffer.append(this) }

    fun attr(key: String, value: String) { attrs[key] = value }

    // 递归拼字符串
    fun render(builder: StringBuilder, indent: String) {
        val attrStr = if (attrs.isEmpty()) "" else attrs.entries.joinToString("") { " ${it.key}=\"${it.value}\"" }
        builder.append("$indent<$name$attrStr>\n")
        if (textBuffer.isNotEmpty()) builder.append("$indent  $textBuffer\n")
        children.forEach { it.render(builder, "$indent  ") }
        builder.append("$indent</$name>\n")
    }
}

// 3. 具体标签:用扩展函数 + 带接收者 lambda 实现 DSL
class Html : Tag("html") {
    fun head(init: Head.() -> Unit) { children.add(Head().apply(init)) }
    fun body(init: Body.() -> Unit) { children.add(Body().apply(init)) }
}
class Head : Tag("head") {
    fun title(text: String) = Tag("title").also { it.apply { +text } }.let { children.add(it) }
}
class Body : Tag("body") {
    fun h1(text: String) = Tag("h1").also { it.apply { +text } }.let { children.add(it) }
    fun p(init: P.() -> Unit) { children.add(P().apply(init)) }
    fun a(href: String, text: String) {
        val a = Tag("a").also { it.attr("href", href); it.apply { +text } }
        children.add(a)
    }
}
class P : Tag("p")

// 4. 顶层入口函数
fun html(init: Html.() -> Unit): String {
    val h = Html().apply(init)
    val sb = StringBuilder()
    h.render(sb, "")
    return sb.toString()
}

// 5. infix 函数:让 attr 调用像英语句子
infix fun Tag.attr(pair: Pair<String, String>) = attr(pair.first, pair.second)

fun main() {
    val output = html {
        head {
            title("我的页面")
        }
        body {
            h1("Hello, DSL!")
            p { +"这是用 Kotlin DSL 生成的 HTML" }
            p {
                +"可以一行一行追加文本"
            }
            a("https://kotlinlang.org", "Kotlin 官网")
        }
    }
    println(output)

    // 输出:
    // <html>
    //   <head>
    //     <title>
    //       我的页面
    //     </title>
    //   </head>
    //   <body>
    //     <h1>
    //       Hello, DSL!
    //     </h1>
    //     <p>
    //       这是用 Kotlin DSL 生成的 HTML
    //     </p>
    //     <p>
    //       可以一行一行追加文本
    //     </p>
    //     <a href="https://kotlinlang.org">
    //       Kotlin 官网
    //     </a>
    //   </body>
    // </html>
}

// Kotlin 的官方库如 kotlinx.html、Anko(已废弃)、Gradle Kotlin DSL、Ktor 路由
// 全部基于这套机制——你日常写的 build.gradle.kts 本身就是个 Kotlin DSL

要点讲解:

小结

三个项目分别对应 Kotlin 的三大招牌场景:简洁数据建模(data class)、结构化并发(协程)、类型安全 DSL(扩展 + lambda)。它们都用到了前面学的基础——类、lambda、扩展、协程——只是组合起来解决真实问题。建议你至少把其中一个改造成自己的版本(给 Todo 加 picocli 完整 CLI、给爬虫加多线程池对比、给 DSL 加自闭合标签支持),做过一遍比读十遍更扎实。

恭喜!到这里 Kotlin 系列就真正完结了。接下来可以挑一个方向深入——Android 开发(Jetpack Compose)、后端(Ktor / Spring Boot)、跨平台(Kotlin Multiplatform)、开源贡献(Kotlin 编译器本身是 Kotlin 写的)——Kotlin 的世界以"简洁 + 现代 + 与 Java 互通"为核心,边做边玩进步最快。

← 上一篇 Kotlin 协程

返回 Kotlin 教程目录

✈️💬