100、项目实战:从零搭建一个完整的Kotlin项目(后端+Android)
终于到了第100章。说实话,写到这里我自己也挺感慨的。
前面99章我们拆解了Kotlin的方方面面——从语法糖到协程,从DSL到跨平台。但很多朋友学完后问我:「哥,我学完了,然后呢?怎么把这些东西串起来?」
嗯,这章就是答案。
咱们要干一件完整的事:从零搭建一个同时包含后端(Ktor)和Android客户端的Kotlin全栈项目。涵盖需求分析、架构设计、编码实现、测试部署。说白了,就是让你看看一个真实项目是怎么从0走到1的。
项目背景:一个极简的「待办事项」应用(Todo App)。后端提供RESTful API,Android端消费API并展示数据。麻雀虽小,五脏俱全。
一、需求分析——别急着写代码
我见过太多人上来就开干,写到一半发现「哎呀,这个接口设计有问题」。我自己也踩过这个坑。
所以第一步,咱们先理清楚要做什么。
1.1 功能需求
- 用户注册/登录(JWT认证)
- 创建待办事项(标题、描述、截止日期)
- 查看待办列表(分页)
- 更新待办状态(已完成/未完成)
- 删除待办事项
1.2 非功能需求
- 后端使用Ktor框架 + Exposed ORM + PostgreSQL
- Android端使用Jetpack Compose + Retrofit + ViewModel
- API遵循RESTful规范
- 密码加密存储(BCrypt)
- 接口返回统一格式
我的经验:需求分析阶段花1小时,后面能省10小时。尤其是接口字段定义,前后端一定要对齐。我曾经因为「日期格式」没统一,联调时浪费了一整天。
二、架构设计——画好图纸再施工
架构设计说白了就是「分层」。把不同职责的代码隔离开,以后改起来才不头疼。
2.1 后端架构分层
| 层级 | 职责 | 技术选型 |
|---|---|---|
| Route层 | 定义API端点,接收请求 | Ktor Routing |
| Service层 | 业务逻辑处理 | 纯Kotlin类 |
| Repository层 | 数据访问抽象 | Exposed DAO |
| Database层 | 数据库连接与迁移 | Exposed + Flyway |
2.2 Android端架构分层
| 层级 | 职责 | 技术选型 |
|---|---|---|
| UI层 | Compose界面 + 状态管理 | Jetpack Compose + StateFlow |
| ViewModel层 | 业务逻辑 + 生命周期管理 | AndroidX ViewModel |
| Repository层 | 数据源统一入口 | 自定义Repository |
| Network层 | HTTP请求封装 | Retrofit + OkHttp |
你想想看,这样分层之后,如果以后要把PostgreSQL换成MySQL,只需要改Database层。如果要把Retrofit换成Ktor Client,只需要改Network层。互不干扰。
2.3 整体架构图
三、编码实现——动手写代码
图纸画好了,咱们开始砌砖。我会挑最核心的部分展示,完整的代码你可以在配套源码里找到。
3.1 后端:Ktor项目初始化
我个人习惯用Gradle Kotlin DSL来配置。核心依赖如下:
// build.gradle.kts
val ktorVersion = "2.3.7"
val exposedVersion = "0.44.1"
dependencies {
// Ktor
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-server-auth:$ktorVersion")
implementation("io.ktor:ktor-server-auth-jwt:$ktorVersion")
// 数据库
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.postgresql:postgresql:42.7.1")
// 工具
implementation("at.favre.lib:bcrypt:0.10.2")
implementation("com.google.guava:guava:33.0.0-jre")
}
3.2 后端:用户注册接口
这是Route层的代码。注意看我是怎么把认证、参数校验、统一响应串起来的:
// UserRoutes.kt
fun Application.configureUserRoutes() {
routing {
post("/api/auth/register") {
val request = call.receive<RegisterRequest>()
// 参数校验
if (request.username.isBlank() || request.password.length < 6) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error("用户名或密码不合法"))
return@post
}
try {
val user = userService.register(request.username, request.password)
call.respond(HttpStatusCode.Created, ApiResponse.success(user.toDto()))
} catch (e: UserAlreadyExistsException) {
call.respond(HttpStatusCode.Conflict, ApiResponse.error("用户名已存在"))
}
}
}
}
注意:密码绝对不能明文存储!我这里用了BCrypt加密。曾经有个项目因为偷懒用了MD5,结果被脱库后用户密码全泄露,那叫一个惨。
3.3 Android端:Retrofit接口定义
// ApiService.kt
interface ApiService {
@POST("api/auth/register")
suspend fun register(@Body request: RegisterRequest): Response<ApiResponse<UserDto>>
@POST("api/auth/login")
suspend fun login(@Body request: LoginRequest): Response<ApiResponse<TokenResponse>>
@GET("api/todos")
suspend fun getTodos(
@Query("page") page: Int = 1,
@Query("size") size: Int = 20
): Response<ApiResponse<List<TodoDto>>>
@POST("api/todos")
suspend fun createTodo(@Body request: CreateTodoRequest): Response<ApiResponse<TodoDto>>
}
3.4 Android端:ViewModel中的状态管理
// TodoViewModel.kt
class TodoViewModel(
private val todoRepository: TodoRepository
) : ViewModel() {
private val _todos = MutableStateFlow<List<Todo>>(emptyList())
val todos: StateFlow<List<Todo>> = _todos.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
fun loadTodos() {
viewModelScope.launch {
_isLoading.value = true
try {
val result = todoRepository.getTodos()
_todos.value = result
} catch (e: Exception) {
// 错误处理
} finally {
_isLoading.value = false
}
}
}
}
你想想看,用StateFlow的好处是什么?界面层只需要collect这个Flow,数据一变,UI自动更新。省去了手动通知的麻烦。
四、测试——别让Bug上线
我承认,写测试确实不如写功能来得爽。但每次线上出Bug时,我都会后悔「为什么不多写几个测试」。
4.1 后端单元测试(使用Ktor Test Host)
// UserRoutesTest.kt
class UserRoutesTest {
@Test
fun `should register user successfully`() = testApplication {
application {
configureSerialization()
configureUserRoutes()
}
val response = client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest("testuser", "password123"))
}
assertEquals(HttpStatusCode.Created, response.status)
}
}
4.2 Android端单元测试
// TodoViewModelTest.kt
class TodoViewModelTest {
@Test
fun `loadTodos should update state`() = runTest {
val mockRepo = mock<TodoRepository>()
`when`(mockRepo.getTodos()).thenReturn(listOf(Todo("测试任务")))
val viewModel = TodoViewModel(mockRepo)
viewModel.loadTodos()
assertEquals(1, viewModel.todos.value.size)
assertEquals("测试任务", viewModel.todos.value[0].title)
}
}
我的习惯:先写接口测试,再写单元测试。接口测试保证「路通了」,单元测试保证「逻辑对了」。两者缺一不可。
五、部署——让项目跑起来
部署这块,我推荐用Docker。原因很简单——环境一致性。你在本地跑得通,到服务器上跑不通?多半是环境问题。Docker直接打包整个环境,省心。
5.1 后端Dockerfile
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY build/libs/todo-backend-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
5.2 docker-compose.yml
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_DB: todo_db
POSTGRES_USER: todo_user
POSTGRES_PASSWORD: todo_pass
ports:
- "5432:5432"
backend:
build: .
ports:
- "8080:8080"
depends_on:
- db
environment:
DATABASE_URL: jdbc:postgresql://db:5432/todo_db
Android端就简单了——生成APK或AAB,上传到应用市场或者直接侧载安装。
六、避坑指南
最后,分享几个我踩过的坑:
- 数据库连接池泄漏:曾经有一次忘记关闭数据库连接,导致生产环境连接数飙满,服务直接挂掉。后来加了HikariCP的连接泄漏检测才解决。
- Android端协程作用域:在ViewModel里用viewModelScope,在Activity里用lifecycleScope。千万别用GlobalScope,否则Activity销毁了协程还在跑,轻则内存泄漏,重则崩溃。
- JWT过期处理:Android端要拦截401响应,自动刷新Token。我见过不少App在Token过期后直接闪退,用户体验极差。
- 接口版本管理:API路径加上版本号,比如/api/v1/todos。这样以后升级接口时,旧版客户端还能用。
好了,这一章的内容就到这。从需求分析到部署上线,咱们走完了全流程。你可能会觉得内容有点多,但没关系——代码可以复制,经验需要积累。慢慢来,比较快。
公众号:蓝海资料掘金营,微信deep3321