📚前言

跟着trae规划的学习开发安卓手机app的教程,一步一步学习实践,总规划见下面文档

【Trae+AI】和Trae学习搭建App_00:总项目规划_trae 封装apk-CSDN博客

下面开始规划的第四章的第二部分

👀回顾

第四章整体教学规划(语言Kotlin)

本章核心目标:基于Kotlin语言(Android官方首选开发语言),从零基础到完整调用后端所有核心接口(登录、任务CRUD、分类管理),兼顾「手把手操作步骤」和「底层原理」,整体分为6个部分,适配初学者节奏:

模块 核心内容 学习目标
第一部分 安卓端网络通信基础准备(Kotlin项目创建+权限+核心依赖) 搭建Kotlin版安卓基础项目,解决“安卓能访问后端”的前提问题(权限、依赖)
第二部分 安卓网络请求工具封装(OkHttp3 + Kotlin协程) 掌握Kotlin环境下的主流网络库封装,用协程替代传统子线程,简化异步逻辑
第三部分 登录接口调用(获取JWT令牌)+ 令牌本地存储(Kotlin版) 实现“账号密码→后端验证→获取令牌”全流程,理解JWT在Kotlin端的使用逻辑
第四部分 带令牌调用认证接口(任务/分类查询) 掌握“请求头携带JWT”的Kotlin实现,适配后端auth中间件验证逻辑
第五部分 业务接口CRUD(任务/分类的增、删、改、查) 覆盖完整业务场景,掌握不同请求方式(GET/POST/PUT/DELETE)的Kotlin实现
第六部分 网络异常处理+调试技巧(Kotlin端+后端联调) 解决实战中常见问题,掌握Kotlin环境下的调试方法

🌟第四章(第二部分):Kotlin版网络请求工具封装(OkHttp3 + 协程 + 单例)

一、本部分学习目标

  1. 理解「封装网络工具类」的核心意义(解决重复代码、解耦业务与网络逻辑);
  2. 掌握Kotlin单例模式实现OkHttp客户端(避免重复创建连接,节省资源);
  3. 封装通用的GET/POST请求方法(适配后端所有接口,无需重复写请求逻辑);
  4. 结合Gson和Kotlin数据类,实现JSON自动解析(替代手动解析的繁琐);
  5. 用封装后的工具类调用后端登录接口(验证封装有效性)。

二、封装的核心必要性(初学者必懂)

上一部分我们直接在MainActivity中写了网络请求代码,存在3个核心问题:

  • 代码冗余:每个接口(登录、任务查询)都要写「创建客户端→构建请求→协程切换线程」的重复代码;
  • 耦合严重:网络逻辑和UI逻辑混在一起,后期维护困难;
  • 扩展性差:新增请求头(如JWT令牌)、统一处理超时/异常时,需要修改所有接口代码。

封装的核心目标:把「网络请求」相关逻辑抽离为独立工具类(HttpUtil),业务层(如MainActivity)只需调用工具类的方法,无需关心底层实现。

三、步骤1:设计网络工具类的核心结构

我们要封装的HttpUtil需包含以下核心能力:

功能 实现方式
OkHttp客户端单例 Kotlin饿汉式单例(保证全局唯一)
通用请求方法 协程+withContext(Dispatchers.IO)
GET/POST专用方法 基于通用方法封装,简化调用
JSON自动解析 Gson+Kotlin数据类
统一异常处理 封装异常类型,返回清晰结果

四、步骤2:实现单例OkHttp客户端(核心基础)

操作流程

  1. 在Android Studio中,右键点击包名com.example.androidexpressdemo → 「New」→ 「Kotlin Class/File」;
  2. 命名为HttpUtil,选择「Class」,点击「OK」或回车;
  3. 编写单例OkHttp客户端代码(带超时配置,避免请求卡死):
package com.example.androidexpressdemo

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import com.google.gson.Gson
import java.util.concurrent.TimeUnit

/**
 * 网络请求工具类(Kotlin单例模式)
 * 核心:全局唯一的OkHttpClient,避免重复创建连接池
 */
object HttpUtil {
    // 1. 定义常量(媒体类型、超时时间)
    private const val TIME_OUT = 10L // 超时时间:10秒
    private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
    
    // 2. Gson实例(全局唯一,用于JSON解析)
    val gson = Gson()
    
    // 3. OkHttpClient单例(饿汉式单例,Kotlin object天然是单例)
    private val okHttpClient: OkHttpClient by lazy {
        OkHttpClient.Builder()
            .connectTimeout(TIME_OUT, TimeUnit.SECONDS) // 连接超时
            .readTimeout(TIME_OUT, TimeUnit.SECONDS)    // 读取超时
            .writeTimeout(TIME_OUT, TimeUnit.SECONDS)   // 写入超时
            .retryOnConnectionFailure(true) // 连接失败时重试
            .build()
    }

    // 4. 通用请求方法(核心,所有GET/POST都基于此)
    /**
     * 通用网络请求方法
     * @param request OkHttp的Request对象
     * @return 响应体字符串(失败返回null)
     */
    private suspend fun executeRequest(request: Request): String? {
        return withContext(Dispatchers.IO) {
            try {
                val response = okHttpClient.newCall(request).execute()
                // 检查响应是否成功(HTTP状态码200-299)
                if (response.isSuccessful) {
                    response.body?.string()
                } else {
                    // 非200状态码,返回错误信息
                    "HTTP错误:${response.code} ${response.message}"
                }
            } catch (e: Exception) {
                // 捕获所有网络异常
                e.printStackTrace()
                null
            }
        }
    }
}

核心原理说明

  1. Kotlin单例(object)object HttpUtil 是Kotlin的「饿汉式单例」,保证全局只有一个实例,避免重复创建OkHttpClient(每个OkHttpClient会创建连接池,重复创建浪费资源);
  2. by lazyokHttpClient 采用懒加载,首次调用时才创建,节省启动内存;
  3. 超时配置:设置10秒超时,避免请求因网络差一直阻塞;
  4. suspend关键字executeRequest 标记为挂起函数,只能在协程中调用(保证异步执行);
  5. withContext(Dispatchers.IO):强制在IO子线程执行,符合安卓网络请求规则。

📌详细说明

  • "application/json; charset=utf-8".toMediaType()
    • .toMediaType():OkHttp 提供的拓展函数:将字符串形式的媒体类型转为 OkHttp 专用的 MediaType 对象(供 RequestBody 使用);
      • toMediaType() 不会返回 null,因此无需做空值处理;
    • 除了 JSON,开发中还会用到其他媒体类型,格式规则一致:

      媒体类型字符串 用途
      multipart/form-data 表单上传(含文件)
      application/x-www-form-urlencoded 普通表单提交(键值对)
      image/jpeg JPG 图片文件
      text/plain; charset=utf-8 纯文本数据
  • Gson()
    • Gson 类是处理 JSON 与 Kotlin/Java 对象相互转换的核心工具(序列化 / 反序列化) 
    • Gson() 创建一个默认配置的 Gson 实例
  • private val okHttpClient: OkHttpClient by lazy {...}
    • 语法说明
      语法部分 核心作用
      private 访问修饰符:限制该变量仅在当前类 / 文件内可见,避免外部随意修改 / 访问,符合 “封装原则”;
      val Kotlin 不可变变量:表示 okHttpClient 初始化后不可被重新赋值(类似 Java final),保证实例唯一性;
      okHttpClient 变量名:自定义的 OkHttpClient 实例引用名;
      : OkHttpClient 显式指定变量类型为 OkHttpClient(Kotlin 可自动推导,但显式声明更易读);
      by lazy Kotlin 懒加载委托:核心语法,实现 “延迟初始化”;
      { OkHttpClient() } 懒加载的初始化代码块:首次调用 okHttpClient 时才执行,返回 OkHttpClient 实例;
    • by lazy 是 Kotlin 的属性委托,专门用于 “延迟初始化”
      • 代码执行到这一行时,不会立即创建 OkHttpClient 实例;
      • 只有首次调用 okHttpClient(如 okHttpClient.newCall(request))时,才会执行 { OkHttpClient() } 代码块,创建实例;
      • lazy 委托默认是线程安全的
        • 多线程环境下,即使同时调用 okHttpClient,也只会创建一个实例
      • OkHttpClient.Builder 自定义配置
      • lazy 委托的生命周期okHttpClient 的生命周期与所属类一致(如 Activity 中的懒加载实例,Activity 销毁后也会销毁)
  • private suspend fun executeRequest(request: Request): String? {
    • suspend 是 Kotlin 协程的核心关键字,用于标记挂起函数(Suspending Function),其核心作用是:让函数能够 “暂停执行”(挂起)并释放线程,等待异步任务完成后 “恢复执行”,且全程无阻塞线程
      • ✅ 强制该函数只能在协程内或其他挂起函数中调用(避免误用在普通函数里);
      • ✅ 挂起时会释放线程(如主线程),让线程可处理其他任务(无 ANR / 阻塞)。
    • : String?    :返回值:可空字符串类型 —— 成功返回响应体字符串,失败返回错误信息 / 空;

五、步骤3:封装GET/POST专用请求方法

HttpUtil中继续添加GET和POST方法,基于通用的executeRequest封装:

// ========== 新增:GET请求封装 ==========
/**
 * GET请求
 * @param url 接口地址(如:http://10.0.2.2:3000/api/tasks)
 * @param headers 请求头(可选,如JWT令牌)
 * @return 响应体字符串(失败返回null)
 */
suspend fun get(
    url: String,
    headers: Map<String, String>? = null
): String? {
    // 构建请求头
    val requestBuilder = Request.Builder().url(url)
    // 添加自定义请求头(如Authorization)
    headers?.forEach { (key, value) ->
        requestBuilder.addHeader(key, value)
    }
    // 执行请求
    return executeRequest(requestBuilder.build())
}

// ========== 新增:POST请求封装(JSON参数) ==========
/**
 * POST请求(JSON格式参数)
 * @param url 接口地址(如:http://10.0.2.2:3000/api/login)
 * @param params JSON参数(Kotlin对象,自动转为JSON字符串)
 * @param headers 请求头(可选)
 * @return 响应体字符串(失败返回null)
 */
suspend fun <T> post(
    url: String,
    params: T,
    headers: Map<String, String>? = null
): String? {
    // 1. 将Kotlin对象转为JSON字符串
    val jsonParams = gson.toJson(params)
    // 2. 构建请求体
    val requestBody = jsonParams.toRequestBody(JSON_MEDIA_TYPE)
    // 3. 构建请求
    val requestBuilder = Request.Builder()
        .url(url)
        .post(requestBody)
    // 4. 添加请求头
    headers?.forEach { (key, value) ->
        requestBuilder.addHeader(key, value)
    }
    // 5. 执行请求
    return executeRequest(requestBuilder.build())
}

// ========== 新增:JSON解析扩展方法(简化调用) ==========
/**
 * 将JSON字符串解析为Kotlin数据类
 * @param T 目标数据类类型
 * @return 解析后的对象(失败返回null)
 */
inline fun <reified T> String?.parseJson(): T? {
    if (this.isNullOrEmpty()) return null
    return try {
        gson.fromJson(this, T::class.java)
    } catch (e: Exception) {
        e.printStackTrace()
        null
    }
}

核心原理说明

  1. GET方法:简化请求头添加逻辑,无需手动构建Request;
  2. POST方法:自动将Kotlin对象转为JSON字符串(适配后端application/json格式),无需手动拼接JSON;
  3. reified泛型+扩展函数parseJson() 是Kotlin扩展函数,reified 让泛型可获取实际类型,实现「一行代码解析JSON」;
  4. 请求头参数:设计为可选参数(headers: Map<String, String>? = null),适配需要JWT令牌的接口(后续会用到)。

📌详细说明

  • suspend fun get
    • 语法部分 核心作用
      suspend 标记为挂起函数:只能在协程 / 其他挂起函数中调用,可配合 withContext 切换线程执行网络请求,无阻塞主线程风险;
      url: String 必传参数:GET 请求的目标 URL(如 https://api.example.com/test),非空字符串类型,强制调用方传入;
      headers: Map<String, String>? = null 可选参数:1. Map<String, String>:存储请求头的键值对(如 AuthorizationContent-Type);2. ?:可空类型,允许传入 null;3. = null:默认参数,调用方不传入时,默认值为 null
      : String? 返回值:可空字符串类型 —— 请求成功返回响应体字符串,失败返回 null/ 错误信息,兼容 “无响应体、请求异常” 等场景;
    • suspend:协程友好的异步 GET 请求
      • 作为挂起函数,可直接在 lifecycleScope/viewModelScope 中调用,无需手动创建线程;
  • suspend fun <T> post(
    • 语法
      语法部分 核心作用
      fun <T> 泛型声明:<T> 表示 “任意类型”,让函数接收任意 Kotlin 对象作为 POST 参数(如 UserMap 等);
      post 函数名:语义化命名,代表 HTTP POST 请求;
      url: String 必传参数:POST 请求的目标 URL(非空,强制调用方传入);
      params: T 必传参数:要提交的 POST 参数(任意类型的 Kotlin 对象),会被 Gson 序列化为 JSON 字符串;
      headers: Map<String, String>? = null 可选参数:自定义请求头(如 AuthorizationContent-Type),默认值 null,调用方可省略;
  •  gson.toJson(params)
    • 将泛型参数 params(任意 Kotlin 对象)序列化为 JSON 字符串
      • 示例:若 params 是 User("张三", 20),则 jsonParams 为 {"name":"张三","age":20}
  • toRequestBody:OkHttp 提供的拓展函数,将字符串转为 RequestBody(POST 请求的请求体);
    • toRequestBody(JSON_MEDIA_TYPE) 既完成了 “字符串 → RequestBody” 的格式转换,又指定了数据的 MIME 类型和编码,确保前后端解析一致
  • inline fun <reified T> String?.parseJson(): T? {
    • 这是 Kotlin 中通用、便捷的 JSON 反序列化拓展函数,语法解释如下:
      语法部分 核心作用
      inline 内联函数:编译时将函数逻辑 “嵌入” 调用处,消除泛型 / 拓展函数的调用开销;同时是 reified 的前置条件(reified 必须配合 inline);
      fun <reified T> 具体化泛型:<T> 是泛型参数,reified 让泛型类型 T 在函数内 “可被获取”(能直接用 T::class.java),突破普通泛型的类型擦除限制;
      String?.parseJson() 拓展函数:给 String?(可空字符串)拓展 parseJson() 方法,JSON 字符串可直接调用;
      : T? 返回值:可空的泛型类型 —— 解析成功返回 T 类型对象,失败 / 空字符串返回 null
    • this.isNullOrEmpty()
      • this:指代调用该拓展函数的 String? 对象(即 JSON 字符串);
      • isNullOrEmpty():Kotlin 内置函数,判断字符串是否为 null 或空字符串("");
    • gson.fromJson(this, T::class.java)
      • fromJson(this, T::class.java):Gson 反序列化核心方法:
        • this:待解析的 JSON 字符串;
        • T::class.java:目标类型的 Class 对象(如 User::class.java),reified 让泛型 T 能直接获取 Class,无需手动传入;
      • 示例:若 jsonStr = "{\"name\":\"张三\",\"age\":20}",调用 jsonStr.parseJson<User>() 会返回 User(name="张三", age=20)
    • 典型调用场景

      • // 定义目标数据类
        data class User(val name: String, val age: Int)
        
        // 模拟后端返回的JSON字符串
        val jsonStr = "{\"name\":\"张三\",\"age\":20}"
        
        // 调用拓展函数解析(无需传入Class,直接指定泛型)
        val user: User? = jsonStr.parseJson<User>()
        
        // 处理结果
        if (user != null) {
            println("用户名:${user.name},年龄:${user.age}")
        } else {
            println("JSON解析失败")
        }

六、步骤4:设计Kotlin数据类(适配后端JSON)

4.1 先明确后端接口的JSON格式

以登录接口/api/login为例,后端返回的JSON格式如下:

// 登录成功
{
  "code": 200,
  "message": "登录成功",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "username": "test"
  }
}

// 登录失败
{
  "code": 401,
  "message": "密码错误",
  "data": null
}

测试实例:

登录成功:

登录失败:

4.2 创建对应的数据类

  1. 右键包名 → 「New」→ 「Kotlin Class/File」→ 命名为LoginResponse → 选择「Data Class」;
  2. 编写数据类(严格匹配后端JSON字段名):
package com.example.androidexpressdemo

/**
 * 登录接口响应数据类(严格匹配后端JSON字段)
 * Kotlin数据类自动生成equals/hashCode/toString,适配Gson解析
 */
data class LoginResponse(
    val code: Int,          // 状态码(200成功,401失败)
    val message: String,    // 提示信息
    val data: LoginData?    // 数据体(成功时有值,失败为null)
)

/**
 * 登录成功的data数据类
 */
data class LoginData(
    val token: String,      // JWT令牌
    val username: String    // 用户名
)

/**
 * 登录请求参数数据类(传给后端的JSON)
 */
data class LoginRequest(
    val username: String,   // 用户名
    val password: String    // 密码
)

核心原理说明

  1. Kotlin数据类(data class):专为存储数据设计,自动生成toString()equals()等方法,Gson能直接将JSON解析为数据类实例;
  2. 字段名严格匹配:数据类的字段名必须和后端JSON的key完全一致(大小写敏感),否则Gson解析为null;
  3. 空安全设计LoginData? 标记为可空,适配后端返回data: null的场景(如登录失败)。

七、步骤5:测试封装后的工具类(调用登录接口)

7.1 准备后端登录接口

确保你的Express后端/api/testlogin接口正常运行,示例代码:

// 后端app.js
app.post('/api/testlogin', async (req, res) => {
  try {
    const { username, password } = req.body;
	console.log("ok");

    // 模拟用户验证(实际需查数据库+bcrypt对比)
    if (username === 'test' && password === '123456') {
      // 生成JWT令牌
	  console.log("ok2");
	  const token ="tocken123456";
		/*
      const token = jwt.sign(
        { username: 'test' },
        'your-secret-key',
        { expiresIn: '1h' }
		
      );*/
      return res.json({
        code: 200,
        message: '登录成功',
        data: { token,username: 'test' }
      });
    }
    res.status(401).json({
      code: 401,
      message: '用户名或密码错误',
      data: null
    });
  } catch (err) {
    res.status(500).json({
      code: 500,
      message: '服务器错误22',
      data: null
    });
  }
});

修改代码,并用hoppscotch.io测试通过:

7.2 安卓端编写测试代码

修改MainActivity.kt,调用封装的HttpUtil实现登录:

package com.example.androidexpressdemo

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.TextView
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {
    private val TAG = "LoginTest"
    // 后端地址(根据你的环境修改:模拟器10.0.2.2/真机用电脑IP)
    private val BASE_URL = "http://192.168.0.57:3000"

    private lateinit var tvResult: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        tvResult = findViewById(R.id.tvResult)

        // 测试登录接口(调用封装的HttpUtil)
        testLogin()
    }

    /**
     * 测试登录接口
     */
    private fun testLogin() {
        // 1. 构建登录请求参数
        val loginRequest = LoginRequest(
            username = "test",
            password = "123456"
        )

        // 2. 启动协程调用POST请求
        lifecycleScope.launch(Dispatchers.Main) {
            // 调用封装的post方法
            val responseStr = HttpUtil.post(
                url = "$BASE_URL/api/testlogin",
                params = loginRequest
            )

            // 3. 解析JSON为数据类
            val loginResponse = responseStr.parseJson<LoginResponse>()

            // 4. 更新UI显示结果
            if (loginResponse != null) {
                val result = when (loginResponse.code) {
                    200 -> "登录成功!\n令牌:${loginResponse.data?.token}\n用户名:${loginResponse.data?.username}"
                    401 -> "登录失败:${loginResponse.message}"
                    else -> "服务器错误:${loginResponse.message}"
                }
                tvResult.text = result
                Log.d(TAG, result)
            } else {
                tvResult.text = "请求失败:网络异常或JSON解析错误"
            }
        }
    }
}

📌代码说明:

  • when (loginResponse.code) { ... }:分支判断
  • loginResponse.data?.token
    • 安全调用操作符(?.):若 data 不为 null,取 token;若 data 为 null,返回 null(不崩溃)
  • ${}:字符串模板
    • 规则:
      • 简单变量:${变量名}(如 ${name});
      • 复杂表达式:${表达式}(如 ${a + b}${obj?.prop ?: "默认值"});

7.3 运行测试

  1. 启动后端服务(node app.js);
  2. 点击Android Studio「Run」按钮,运行APP;
  3. 预期结果:
    • 界面显示:登录成功!令牌:xxx... 用户名:test
    • Logcat中能看到完整的令牌信息;
    • 若输入错误密码(如password = "1234567"),界面显示登录失败:用户名或密码错误

实际执行界面参考:

八、核心原理与注意事项

1. 协程与线程切换

  • lifecycleScope.launch(Dispatchers.Main):在主线程启动协程,保证UI更新;
  • HttpUtil中的withContext(Dispatchers.IO):自动切换到IO子线程执行网络请求,无需手动处理线程;
  • 挂起函数(suspend):保证网络请求不会阻塞主线程,避免ANR(应用无响应)。

2. 空安全处理

  • loginResponse?.code:安全调用符,避免loginResponse为null时崩溃;
  • 数据类中的LoginData?:适配后端返回data: null的场景;
  • responseStr.parseJson<LoginResponse>():解析失败时返回null,需提前判断。

3. 工具类扩展性(后续可用)

若需添加JWT令牌到请求头(如调用/api/tasks),只需在调用get/post时传入headers:

// 示例:带JWT令牌的GET请求
val headers = mapOf("Authorization" to "Bearer $token")
val taskResponse = HttpUtil.get(
    url = "$BASE_URL/api/tasks",
    headers = headers
)

4. 常见问题排查

  • JSON解析为null:检查数据类字段名是否和后端一致(大小写、字段名);
  • 请求失败:确认后端地址正确、服务启动、网络权限配置;
  • 协程报错:确保添加了协程依赖,且挂起函数在协程中调用。

九、本部分学习总结

  1. 完成了Kotlin版网络请求工具类的封装,解决了「代码冗余、耦合严重」的问题;
  2. 掌握了Kotlin核心特性:单例(object)、数据类(data class)、扩展函数、泛型(reified);
  3. 实现了「一行代码调用GET/POST接口 + 自动JSON解析」,适配后端所有接口;
  4. 验证了登录接口的调用流程,为后续调用认证接口(带JWT)打下基础。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐