news 2026/10/11 15:30:25

Android对话机器人实战:5分钟跑通HTTP+RecyclerView+Gson消息流

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android对话机器人实战:5分钟跑通HTTP+RecyclerView+Gson消息流

简介:本资源是一份面向Android初学者与进阶开发者的实战型学习资料,聚焦于使用Android Studio开发具备基础交互能力的小型对话机器人App,解决移动端接入AI对话接口的典型工程问题。压缩包为单个105KB的PDF文档,完整呈现了从项目初始化、UI布局(含RecyclerView消息列表与msg_item模板)、接口封装(RobotManager+GetConnection回调)、HttpURLConnection网络请求实现,到消息动态渲染与日志调试的全流程代码与关键注释。内容基于免费青云客机器人API(http://api.qingyunke.com/)构建,涵盖GET请求、BufferedReader数据读取、StringBuilder响应拼接、Handler线程通信等核心知识点,代码结构清晰、注释详实,适合作为Android网络通信与UI联动的入门范例。目前已有3765人学习下载,读者可直接复现完整功能,掌握移动端调用外部AI接口的标准化开发模式。

1. 用 Android Studio 5 分钟跑通一个能聊天气、讲笑话的对话机器人 App:不是 Demo,是真能发请求、收 JSON、渲染消息流的可运行实例

你有没有试过在 Android Studio 里点开一个“对话机器人”项目,结果发现它只是个带两个 TextView 的静态界面?或者更糟——一运行就NetworkOnMainThreadException直接崩?这篇不是那种“教你新建 Hello World”的入门课,而是我去年帮实习生快速验证 AI 接口可行性时拆出来的真实可跑、带网络请求、带 RecyclerView 消息流、带 Gson 解析、带错误兜底的最小可行对话机器人 App。它调用的是青云客(qingyunke.com)这个长期稳定、无需申请、返回结构清晰的免费中文对话接口,不是 mock 数据,不是本地模拟,是真发 HTTP GET 请求、真收 JSON、真把“今天北京天气怎么样”变成“[04月08日] 北京天气:晴,白天 23℃,夜晚 12℃,微风,<3级”。适合三类人:刚学完 Activity 和 Layout 想实战练手的新手;需要快速验证某个聊天 API 是否可用的后端/算法同学;以及被RecyclerView + Handler + HttpURLConnection这套老但稳的组合搞晕、想抄一份干净骨架的中级开发者。别被“小型”二字骗了——它没用 Retrofit、没用协程、没用 ViewModel,恰恰是因为这些“省事”方案在调试网络链路时反而成了黑匣子。而本文这套写法,每一步你都能Log.e("url", ...)看到真实 URL,Log.e("result", ...)看到原始响应,Log.e("getResult", ...)看到 Gson 解析后的字段,所有中间态都裸露在外,翻车时你能立刻定位是 URL 拼错了、还是 JSON 字段名对不上、还是主线程更新 UI 报错了。这才是工程落地的第一课:先让链路透明,再谈封装优雅。

2. 从零建项目到消息列表渲染:RecyclerView + MsgAdapter 的四步闭环实现

2.1 新建 Empty Activity 项目并配置基础依赖

打开 Android Studio(我用的是 Giraffe | 2022.3.1,但 2021.3.x 及以上版本完全兼容),选择New Project → Empty Activity,包名设为com.example.chatbot(后续所有代码路径以此为准),Minimum SDK 选API 21 (Android 5.0)—— 青云客接口不涉及高版本特性,没必要拉高兼容门槛。创建完成后,第一件事是添加 Gson 依赖,因为我们要解析 JSON 响应。打开app/build.gradle,在dependencies块中加入:

implementation 'com.google.code.gson:gson:2.10.1'

提示:Gson 2.10.1 是截至 2024 年中与 Android Gradle Plugin 8.x 兼容性最好的稳定版本,避免使用 2.9.x(有已知泛型解析 bug)或 2.11+(部分旧版 AGP 报错)。同步后,你会看到External Libraries下多了gson-2.10.1.jar。

接着,确认AndroidManifest.xml中已声明网络权限(青云客是 HTTP 接口,非 HTTPS,必须显式声明):

<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

这两行必须放在<application>标签之外、<manifest>标签之内,否则权限不生效。这是新手最常漏写的一步,漏了直接java.net.UnknownHostException。

2.2 设计消息数据模型:Msg 类与 TYPE_SENT/TYPE_RECEIVED 的语义约定

对话机器人 App 的核心交互单元是“一条消息”,它必须携带两个关键信息:内容文本和发送方身份(用户 or 机器人)。我们不直接用 String 或 Map,而是定义一个 POJO 类Msg,它位于app/src/main/java/com/example/chatbot/Msg.java:

package com.example.chatbot; public class Msg { public static final int TYPE_RECEIVED = 0; // 机器人回复 public static final int TYPE_SENT = 1; // 用户发送 private String content; private int type; public Msg(String content, int type) { this.content = content; this.type = type; } public String getContent() { return content; } public int getType() { return type; } }

注意这里TYPE_RECEIVED和TYPE_SENT的数值定义(0 和 1)不是随意的,它直接决定了后续MsgAdapter中onBindViewHolder的分支逻辑。为什么不用 boolean?因为RecyclerView.Adapter的getItemViewType()方法要求返回int,且0和1是最安全的 viewType 值(负数可能被系统内部占用,过大数字影响性能)。这个类极简,没有 Builder、没有 Lombok,就是为了在调试时一眼看清字段流转路径。

2.3 构建消息列表 UI:activity_main.xml 与 msg_item.xml 的布局分工

activity_main.xml是主容器,采用ConstraintLayout保证兼容性,核心组件是RecyclerView(替代已废弃的ListView)和底部输入区:

<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/msg_recycler_view" android:layout_width="0dp" android:layout_height="0dp" android:padding="8dp" app:layout_constraintBottom_toTopOf="@id/input_layout" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <LinearLayout android:id="@+id/input_layout" android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="4dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> <EditText android:id="@+id/input_text" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:hint="输入消息..." android:inputType="text" android:maxLines="2" /> <Button android:id="@+id/send" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="发送" /> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>

关键点:RecyclerView的layout_height设为0dp并用constraintBottom_toTopOf锚定到输入区上方,这是 ConstraintLayout 实现“列表占满剩余空间”的标准写法。EditText的inputType="text"防止弹出数字键盘,maxLines="2"避免单行过长遮挡按钮。

msg_item.xml是每条消息的模板,位于res/layout/下,采用左右双布局设计,通过View.setVisibility()控制显示:

<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="8dp"> <LinearLayout android:id="@+id/left_layout" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="start" android:background="#E0E0E0" android:orientation="vertical" android:padding="12dp" android:visibility="gone"> <TextView android:id="@+id/left_msg" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#000000" /> </LinearLayout> <LinearLayout android:id="@+id/right_layout" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="end" android:background="#2196F3" android:orientation="vertical" android:padding="12dp" android:visibility="gone"> <TextView android:id="@+id/right_msg" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textColor="#FFFFFF" /> </LinearLayout> </LinearLayout>

注意left_layout和right_layout的visibility="gone"是初始状态,由MsgAdapter动态控制。android:layout_gravity="start/end"确保消息靠左(机器人)或靠右(用户)对齐,这是对话 UI 的基本视觉契约。

2.4 实现消息适配器:MsgAdapter 如何根据 TYPE 动态切换布局可见性

MsgAdapter是连接数据(List<Msg>)与 UI(msg_item.xml)的桥梁,位于app/src/main/java/com/example/chatbot/MsgAdapter.java。它的核心逻辑在onBindViewHolder中,根据msg.getType()决定显示哪个布局:

package com.example.chatbot; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import java.util.List; public class MsgAdapter extends RecyclerView.Adapter<MsgAdapter.ViewHolder> { private List<Msg> mMsgList; static class ViewHolder extends RecyclerView.ViewHolder { LinearLayout leftLayout; LinearLayout rightLayout; TextView leftMsg; TextView rightMsg; ViewHolder(View view) { super(view); leftLayout = view.findViewById(R.id.left_layout); rightLayout = view.findViewById(R.id.right_layout); leftMsg = view.findViewById(R.id.left_msg); rightMsg = view.findViewById(R.id.right_msg); } } public MsgAdapter(List<Msg> msgList) { mMsgList = msgList; } @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()) .inflate(R.layout.msg_item, parent, false); return new ViewHolder(view); } @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { Msg msg = mMsgList.get(position); if (msg.getType() == Msg.TYPE_RECEIVED) { // 显示机器人消息:左侧布局可见,右侧隐藏 holder.leftLayout.setVisibility(View.VISIBLE); holder.rightLayout.setVisibility(View.GONE); holder.leftMsg.setText(msg.getContent()); } else if (msg.getType() == Msg.TYPE_SENT) { // 显示用户消息:右侧布局可见,左侧隐藏 holder.rightLayout.setVisibility(View.VISIBLE); holder.leftLayout.setVisibility(View.GONE); holder.rightMsg.setText(msg.getContent()); } } @Override public int getItemCount() { return mMsgList.size(); } }

这里的关键细节:

  • onCreateViewHolder中inflate(..., parent, false)的false参数至关重要,它告诉 LayoutInflater不要将新 View 添加到 parent,否则RecyclerView会因重复添加而崩溃。
  • onBindViewHolder中holder.leftLayout.setVisibility(View.VISIBLE)和holder.rightLayout.setVisibility(View.GONE)必须成对出现,不能只设一个。否则上一条消息的布局残留会导致 UI 错乱(比如机器人消息旁边突然冒出用户气泡)。
  • getItemCount()直接返回mMsgList.size(),不要加 null 判断——因为mMsgList在MainActivity中初始化为new ArrayList<>(),永远非空,加判断反而掩盖了数据源异常。

3. 对话引擎核心:RobotManager + MyConnection + GetConnection 的三层网络调用架构

3.1 RobotManager:URL 模板化拼接与 key/appid 的硬编码合理性

RobotManager是整个对话链路的起点,它不负责网络请求,只做一件事:把用户输入的字符串安全地嵌入预设 URL 模板。位于app/src/main/java/com/example/chatbot/RobotManager.java:

package com.example.chatbot; public class RobotManager { // 青云客免费接口地址,key=free, appid=0 是公开凭证 private static final String BASE_URL = "http://api.qingyunke.com/api.php?key=free&appid=0&msg="; public static String getUrl(String question) { // 关键:URL 编码,防止用户输入含空格、中文、特殊符号导致 400 Bad Request try { return BASE_URL + java.net.URLEncoder.encode(question, "UTF-8"); } catch (java.io.UnsupportedEncodingException e) { // UTF-8 在 Android 上必然支持,此 catch 纯为编译通过 return BASE_URL + question; } } }

注意三点:

  • BASE_URL使用private static final而非private static String,避免运行时被意外修改。
  • getUrl()中调用URLEncoder.encode(question, "UTF-8")是强制要求。青云客接口对未编码的中文、空格、问号等字符返回{"result":100000,"content":"参数错误"}。我第一次没编码,调试半小时才定位到这行。
  • key=free&appid=0是青云客官方文档明确公布的免费凭证,无需申请,也无需替换。网上有些教程让你去注册拿 key,纯属误导——这个接口就是为快速验证设计的。

3.2 GetConnection:回调接口定义与 onSuccess/onError 的职责边界

GetConnection是一个典型的回调接口,位于app/src/main/java/com/example/chatbot/GetConnection.java:

package com.example.chatbot; public interface GetConnection { /** * 网络请求成功时调用,response 是原始 JSON 字符串 * @param response 服务器返回的完整 JSON 响应体 */ void onFinish(String response); /** * 网络请求失败时调用,e 包含具体异常信息 * @param e 可能是 IOException(连接超时、DNS 失败)、JSONException(JSON 格式错误)等 */ void onError(Exception e); }

这个接口的设计哲学是:只传递原始响应,不做任何解析或业务判断。onFinish收到的是 raw JSON string,onError收到的是 raw Exception。这样做的好处是解耦——网络层不关心result==0才算成功,UI 层也不用处理IOException的具体类型。所有业务逻辑(如判断result字段、提取content字段)都在MainActivity中统一处理,符合单一职责原则。

3.3 MyConnection:HttpURLConnection 异步线程封装与超时参数的实测值

MyConnection是真正的网络执行者,位于app/src/main/java/com/example/chatbot/MyConnection.java。它用Thread开启异步任务,调用HttpURLConnection发送 GET 请求:

package com.example.chatbot; import android.util.Log; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; public class MyConnection { private static final String TAG = "MyConnection"; public static void getResponse(final String url, final GetConnection getConnection) { new Thread(new Runnable() { @Override public void run() { HttpURLConnection connection = null; try { URL requestUrl = new URL(url); connection = (HttpURLConnection) requestUrl.openConnection(); connection.setRequestMethod("GET"); connection.setConnectTimeout(3000); // 连接超时:3秒(青云客通常 200ms 内响应) connection.setReadTimeout(5000); // 读取超时:5秒(防服务器卡死) connection.setDoInput(true); connection.setInstanceFollowRedirects(false); // 不自动重定向,便于调试 int responseCode = connection.getResponseCode(); if (responseCode == HttpURLConnection.HTTP_OK) { InputStream in = connection.getInputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(in)); StringBuilder response = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { response.append(line); } reader.close(); in.close(); Log.e(TAG, "HTTP OK, Response: " + response.toString()); if (getConnection != null) { getConnection.onFinish(response.toString()); } } else { // HTTP 错误码(如 404, 500)也视为 onError,交由上层统一处理 throw new IOException("HTTP Error: " + responseCode); } } catch (IOException e) { Log.e(TAG, "IO Exception: ", e); if (getConnection != null) { getConnection.onError(e); } } finally { if (connection != null) { connection.disconnect(); // 必须释放连接,否则内存泄漏 } } } }).start(); } }

关键参数说明:

  • setConnectTimeout(3000):连接建立超时设为 3 秒。青云客服务器响应极快,设太短(如 1000)易因网络抖动误判失败;设太长(如 10000)会让用户干等。
  • setReadTimeout(5000):读取响应体超时设为 5 秒。比连接超时稍长,覆盖慢网场景。
  • setInstanceFollowRedirects(false):禁用自动重定向。青云客接口无重定向,开启它反而增加不可控跳转风险,且getResponseCode()在重定向后返回 200,掩盖真实状态。
  • finally块中的connection.disconnect()是强制要求。HttpURLConnection不像 OkHttp 自动回收,不手动断开会导致 socket fd 泄漏,App 运行一段时间后java.net.SocketException: Too many open files。

3.4 ContentBean:Gson 解析目标类与 result/content 字段的精准映射

青云客返回的 JSON 结构固定为:

{"result":0,"content":"你好呀!"}

或错误时:

{"result":100000,"content":"参数错误"}

因此ContentBean必须严格匹配字段名和类型:

package com.example.chatbot; public class ContentBean { private int result; private String content; public int getResult() { return result; } public void setResult(int result) { this.result = result; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } }

注意:

  • 字段名result和content必须与 JSON 中完全一致(大小写敏感)。若写成Result或CONTENT,Gson 解析后result为 0,content为 null。
  • result用int而非Integer,避免空指针;content用String,Gson 会自动处理 null 值(返回null而非抛异常)。
  • 此类不需要 setter/getter 注解,Gson 默认通过 public 字段或标准 getter/setter 访问。这里用标准 getter/setter 是为了兼容性。

4. 页面逻辑串联:MainActivity 中 Handler + RecyclerView + 输入事件的协同工作流

4.1 初始化与绑定:RecyclerView、EditText、Button 的 findViewById 顺序

MainActivity是整个 App 的调度中心,其onCreate()方法必须按严格顺序初始化组件,否则NullPointerException无法避免:

@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // Step 1: 初始化消息列表(必须在 setAdapter 之前) initMsgs(); // Step 2: 绑定 UI 组件(顺序无关,但习惯先绑 RecyclerView) msgRecyclerView = findViewById(R.id.msg_recycler_view); inputText = findViewById(R.id.input_text); send = findViewById(R.id.send); // Step 3: 配置 RecyclerView(LayoutManager + Adapter) LinearLayoutManager layoutManager = new LinearLayoutManager(this); msgRecyclerView.setLayoutManager(layoutManager); adapter = new MsgAdapter(msgList); msgRecyclerView.setAdapter(adapter); // Step 4: 设置发送按钮点击监听(最后,因依赖 inputText 和 msgList) send.setOnClickListener(v -> { String content = inputText.getText().toString().trim(); if (!content.isEmpty()) { // 添加用户消息到列表 Msg userMsg = new Msg(content, Msg.TYPE_SENT); msgList.add(userMsg); adapter.notifyItemInserted(msgList.size() - 1); msgRecyclerView.scrollToPosition(msgList.size() - 1); inputText.setText(""); // 清空输入框 // 发起网络请求 getInter(content); } }); }

关键点:

  • initMsgs()必须在setAdapter()之前调用,否则adapter拿到的是空msgList,notifyItemInserted会因索引越界崩溃。
  • inputText.getText().toString().trim()的trim()防止用户只输入空格触发请求。
  • send.setOnClickListener()使用 Lambda 表达式(v -> { ... })而非匿名内部类,代码更简洁,且v参数明确指向被点击的 Button。

4.2 Handler 机制:为什么不用 runOnUiThread?主线程更新 RecyclerView 的安全路径

Android 规定 UI 操作必须在主线程执行。网络请求在子线程完成,如何把响应结果安全地更新到RecyclerView?Handler是最直接、最可控的方式。在MainActivity类成员中定义:

private Handler handler = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(@NonNull Message msg) { switch (msg.what) { case 1: // 机器人回复成功 Bundle data = msg.getData(); String result = data.getString("result"); Msg robotMsg = new Msg(result, Msg.TYPE_RECEIVED); msgList.add(robotMsg); adapter.notifyItemInserted(msgList.size() - 1); msgRecyclerView.scrollToPosition(msgList.size() - 1); break; case 2: // 保留扩展位,如加载中状态 break; default: break; } } };

注意:

  • Handler(Looper.getMainLooper())显式指定主线程 Looper,避免在子线程中误创建 Handler 导致Can't create handler inside thread that has not called Looper.prepare()。
  • msg.what == 1是自定义消息码,case 1分支处理机器人回复,case 2预留未来扩展(如显示“正在思考...”占位符)。
  • adapter.notifyItemInserted(...)和msgRecyclerView.scrollToPosition(...)必须成对调用,前者通知 Adapter 数据变化,后者滚动到最新消息——如果只调 notify 而不 scroll,新消息会出现在屏幕外,用户看不到。

4.3 getInter() 方法:网络请求发起与 Gson 解析的原子操作

getInter(String content)是连接 UI 与网络层的胶水方法,它调用MyConnection,并在onFinish回调中完成 Gson 解析和 Handler 发送:

private void getInter(String content) { String requestUrl = RobotManager.getUrl(content); Log.e("getInter", "Request URL: " + requestUrl); // 调试用,上线可删 MyConnection.getResponse(requestUrl, new GetConnection() { @Override public void onFinish(String response) { Log.e("getInter", "Raw Response: " + response); // 原始 JSON,必看! try { Gson gson = new Gson(); ContentBean contentBean = gson.fromJson(response, ContentBean.class); Message msg = new Message(); Bundle data = new Bundle(); if (contentBean.getResult() == 0) { data.putString("result", contentBean.getContent()); msg.what = 1; } else { // 青云客 result != 0 时 content 是错误提示,直接显示 data.putString("result", contentBean.getContent()); msg.what = 1; } msg.setData(data); handler.sendMessage(msg); } catch (Exception e) { Log.e("getInter", "Gson Parse Error", e); // 解析失败时,发一条默认错误消息 Message msg = new Message(); msg.what = 1; Bundle data = new Bundle(); data.putString("result", "解析失败,请重试"); msg.setData(data); handler.sendMessage(msg); } } @Override public void onError(Exception e) { Log.e("getInter", "Network Error", e); // 网络错误时,发一条友好提示 Message msg = new Message(); msg.what = 1; Bundle data = new Bundle(); data.putString("result", "网络错误,请检查网络连接"); msg.setData(data); handler.sendMessage(msg); } }); }

这里体现了一个重要工程实践:所有外部依赖(网络、JSON 解析)都包裹在 try-catch 中,并提供 fallback。Gson.fromJson()可能因 JSON 格式异常(如服务器返回 HTML 错误页)而抛JsonSyntaxException;MyConnection的onError可能是UnknownHostException(DNS 失败)或SocketTimeoutException(超时)。每个异常分支都构造一条用户友好的Msg并发送给 Handler,确保 App 永远不会因异常而静默失败。

4.4 initMsgs() 与首次问候:如何让机器人一启动就打招呼

initMsgs()不仅是初始化空列表,更是设置对话开场白的地方:

private void initMsgs() { msgList.clear(); // 确保清空,避免 Activity 重建时重复添加 Msg welcomeMsg = new Msg("你好!我是菲菲,一个能聊天气、讲笑话的小机器人~\n试试问我:今天北京天气?或者:讲个笑话?", Msg.TYPE_RECEIVED); msgList.add(welcomeMsg); }

注意msgList.clear()—— 如果不加,当用户旋转屏幕(Activity 重建)时,onCreate()会被再次调用,initMsgs()会重复添加欢迎消息,导致列表顶部堆叠多条相同问候。这是ArrayList在 Activity 生命周期中常见的坑。

5. 避坑指南:五个血泪经验总结的高频翻车点与排查路径

5.1 现象:App 启动后点击发送按钮无反应,Logcat 无任何输出

原因:AndroidManifest.xml中缺失INTERNET权限,或权限声明位置错误(写在<application>标签内而非<manifest>标签下)。Android 6.0+ 虽需动态申请,但INTERNET是普通权限,只需在 Manifest 中声明即可生效。
解决:检查AndroidManifest.xml,确认两行权限代码位于<manifest>和<application>之间,且拼写完全正确(android.permission.INTERNET,不是android.permission.INTERNET_PERMISSION或其他变体)。同步 Gradle 后重启 App。

5.2 现象:Logcat 显示java.net.UnknownHostException: api.qingyunke.com

原因:设备网络不通,或 DNS 解析失败。青云客域名api.qingyunke.com在某些企业内网、校园网或老旧路由器下可能被屏蔽或解析异常。
解决:

  1. 在手机浏览器中手动访问http://api.qingyunke.com/api.php?key=free&appid=0&msg=hello,确认能否返回 JSON;
  2. 若浏览器也无法访问,换用移动数据网络测试;
  3. 若仅 WiFi 不通,尝试在路由器 DNS 设置中添加8.8.8.8(Google DNS)或114.114.114.114(国内 DNS);
  4. 临时绕过方案:将RobotManager.BASE_URL中的域名替换为 IP 地址(需先ping api.qingyunke.com获取当前 IP,如118.24.128.177),但 IP 可能变动,仅作调试用。

5.3 现象:发送消息后,机器人回复显示为{"result":0,"content":"你好呀!"}而非纯文本

原因:ContentBean类中content字段名与 JSON 中不一致(如写成Content或msg),导致 Gson 解析失败,contentBean.getContent()返回null,data.putString("result", null)存入 Bundle 后Bundle.getString()返回"null"字符串。
解决:

  1. 在getInter()的onFinish回调中,Log.e("getInter", "Raw Response: " + response)确认原始 JSON 正确;
  2. 检查ContentBean.java,确保字段名content小写,且getContent()方法返回类型为String;
  3. 在onFinish中加一行Log.e("getInter", "Parsed content: " + contentBean.getContent()),确认解析后内容非 null;
  4. 若仍为 null,用gson.fromJson(response, Object.class)解析为Map,打印map.keySet()查看实际字段名。

5.4 现象:消息列表滚动到底部后,新消息不自动显示,需手动滑动

原因:msgRecyclerView.scrollToPosition(msgList.size() - 1)调用时机过早,RecyclerView尚未完成notifyItemInserted的动画布局计算,导致滚动目标位置无效。
解决:将scrollToPosition改为smoothScrollToPosition,并确保在notifyItemInserted之后调用:

adapter.notifyItemInserted(msgList.size() - 1); // 等待 Adapter 完成布局,再平滑滚动 msgRecyclerView.smoothScrollToPosition(msgList.size() - 1);

smoothScrollToPosition内部会等待布局完成,比scrollToPosition更可靠。若仍不生效,可在notifyItemInserted后加msgRecyclerView.post(() -> msgRecyclerView.smoothScrollToPosition(...)),将滚动操作 post 到主线程消息队列末尾。

5.5 现象:连续快速点击发送按钮,出现多条重复机器人回复,或IndexOutOfBoundsException

原因:getInter()方法未做防抖(debounce),用户快速点击多次,触发多个并发网络请求,全部成功后依次回调onFinish,导致msgList.add(robotMsg)被多次执行,而notifyItemInserted(msgList.size() - 1)的索引基于当时msgList长度,但msgList已被前序回调修改。
解决:在send.setOnClickListener中添加简单防抖,禁用按钮直到请求完成:

send.setOnClickListener(v -> { String content = inputText.getText().toString().trim(); if (!content.isEmpty()) { send.setEnabled(false); // 禁用发送按钮 // ... 添加用户消息、调用 getInter ... // 在 getInter 的 onFinish/onError 回调末尾恢复按钮 // send.setEnabled(true); } });

并在getInter()的onFinish和onError回调末尾添加send.setEnabled(true)。这是最轻量级的防抖,避免复杂 RxJava 或 Coroutine 依赖。

6. 进阶技巧:从青云客迁移到自定义 API 的三步改造法与 Gson 解析健壮性加固

6.1 替换 API 的三步改造清单:URL、ContentBean、错误码映射

青云客接口虽好,但作为学习案例,最终你需要接入自己的后端服务。假设新 API 地址为https://your-api.com/chat,返回 JSON 为{"code":200,"message":"success","data":{"reply":"你好!"}},改造只需三处:

Step 1:更新 RobotManager

private static final String BASE_URL = "https://your-api.com/chat?query="; // GET 参数 // 或 POST 方式(需改 MyConnection 为 POST) public static String getUrl(String question) { return BASE_URL + java.net.URLEncoder.encode(question, "UTF-8"); }

Step 2:重构 ContentBean

public class ContentBean { private int code; // 新字段:状态码 private String message; // 新字段:描述 private DataBean data; // 新字段:数据体 public static class DataBean { private String reply; // 新字段:机器人回复内容 public String getReply() { return reply; } public void setReply(String reply) { this.reply = reply; } } public int getCode() { return code; } public void setCode(int code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public DataBean getData() { return data; } public void setData(DataBean data) { this.data = data; } }

Step 3:更新 MainActivity 中的解析逻辑

if (contentBean.getCode() == 200 && contentBean.getData() != null) { data.putString("result", contentBean.getData().getReply()); } else { data.putString("result", "服务异常:" + contentBean.getMessage()); }

注意:contentBean.getData()可能为 null(如code=500时data字段不存在),必须判空,否则NullPointerException。

6.2 Gson 解析健壮性加固:用 TypeToken 处理泛型与空值容忍

青云客返回结构简单,但真实 API 常有嵌套泛型(如{"list":[{"id":1,"name":"A"},{"id":2,"name":"B"}]})。此时Gson.fromJson(json, List.class)会丢失泛型信息,返回ArrayList但元素为LinkedTreeMap。正确做法是用TypeToken:

// 解析泛型 List Type type = new TypeToken<List<YourItem>>(){}.getType(); List<YourItem> list = <p> <a href="https://download.csdn.net/download/weixin_38671628/12726430" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 15:30:14

摩天大楼源码:大型Java系统依赖分析与渐进式重构实践

简介&#xff1a;《摩天大楼源码》是一份面向Android中高级开发者的学习型3D游戏项目&#xff0c;聚焦OpenGL ES图形渲染、游戏架构与交互逻辑实现&#xff0c;特别适合希望系统掌握Android平台3D游戏开发全流程的实践者。资源包共108个文件&#xff0c;含24个Java源文件&#…

作者头像 李华
网站建设 2026/10/11 15:27:01

跨模型协同实战:用MCP串联AI全自动生成吉卜力风格分镜

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/11 15:26:23

Linux命令与终端快捷键:从入门到高效排查实战

搞 Linux 的朋友都有过这种经历&#xff1a;刚接触那会儿&#xff0c;命令抄了满满几页纸&#xff0c;一到真要干活的时候脑子一片空白&#xff1b;快捷键更是只用 CtrlC 和 CtrlV&#xff0c;连中断程序用的 CtrlC 都是在网上搜“怎么终止卡死的命令”才学会的。我用 Linux 当…

作者头像 李华