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 整体架构图

项目整体架构图 Android 客户端 UI层 (Jetpack Compose) ViewModel层 Repository层 Network层 (Retrofit) HTTP (JSON) 后端服务 (Ktor) Route层 (API端点) Service层 (业务逻辑) Repository层 (Exposed) Database层 (PostgreSQL) JDBC PostgreSQL

三、编码实现——动手写代码

图纸画好了,咱们开始砌砖。我会挑最核心的部分展示,完整的代码你可以在配套源码里找到。

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