【Trae+AI】和Trae学习搭建App_2.2.2:第4章·安卓APP调用Express后端实:2:网络请求工具封装(OkHttp3)

📚前言

跟着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 字符串

    • 示例:若 paramsUser("张三", 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)

    典型调用场景

    复制代码
    ```Kotlin
    // 定义目标数据类
    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格式如下:

Kotlin 复制代码
// 登录成功
{
  "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字段名):
Kotlin 复制代码
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接口正常运行,示例代码:

Kotlin 复制代码
// 后端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实现登录:

Kotlin 复制代码
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)打下基础。
相关推荐
阿巴斯甜7 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker8 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95279 小时前
Andorid Google 登录接入文档
android
黄林晴10 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab1 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿1 天前
Android MediaPlayer 笔记
android
Jony_1 天前
Android 启动优化方案
android
阿巴斯甜1 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇1 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android