ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android Studio免费接口对话机器人:RecyclerView与网络请求实战

Android Studio免费接口对话机器人:RecyclerView与网络请求实战 简介一套面向安卓初学者的对话机器人实战文档以安卓开发环境为基础演示从创建空项目开始逐步完成可交互的小型聊天程序。资源压缩包内仅含一个 PDF 文件大小约一百零五千字节属于轻量级资料便于随时查阅。文档从项目初始化讲起详细说明主页面和消息条目两个布局文件的编写并采用循环视图展示对话消息同时自定义适配器驱动列表更新。在网络请求部分文档封装管理类拼接请求地址使用网络连接对象发起 GET 请求并通过缓冲读取器、字符串构建器解析返回数据再借助消息处理器将回复刷新到界面。关键环节包括回调接口设计、列表刷新、日志调试和异常处理配有可运行思路的实例代码便于读者理解接口调用与页面刷新的配合方式。资料已有三千七百六十四人学习下载适合作为毕业设计或开发练手的起步参考还可继续扩展多轮对话、语音输入等进阶功能。1. 用免费接口做 Android Studio 对话机器人这个 demo 的含金量在接口接入思路一个能跑的对话机器人 app 实例对刚学完 Android 基础的开发者来说比看十篇 RecyclerView 教程都管用。这个项目调的是青云客的免费问答接口整个流程是用户输入一句话 → 把文本拼进请求 URL → 用 HttpURLConnection 发 GET 请求 → 拿到 JSON 后用 Gson 解析成对象 → 再通过 Handler 切回主线程刷新 RecyclerView。代码量不大但把网络请求、JSON 解析、子线程通信、列表刷新这几件 Android 开发里最容易翻车的事全串起来了。适合做课程设计、入门练手或者想快速验证某个免费接口能不能用的人。对老手来说这个项目代码结构比较随意但接口接入的思路和几个隐蔽的坑仍然值得扫一眼。2. 页面骨架与消息适配器RecyclerView 为什么比 ListView 更适合聊天场景2.1 两个布局文件的分工activity_main 与 msg_item打开项目第一步是看 layout 目录。这个工程里只有两个布局文件activity_main.xml 是主页面msg_item.xml 是每条消息的模板。二者是「容器 子项」的关系聊天界面所有展示逻辑都靠这两层撑起来。activity_main.xml 用垂直方向的 LinearLayout 包了三样东西RecyclerView、EditText、Button。RecyclerView 占满剩余高度输入框和发送按钮横排在底部。我复现时最常用的写法是这样?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical androidx.recyclerview.widget.RecyclerView android:idid/msg_recycler_view android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal EditText android:idid/input_text android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:hint说点什么吧 / Button android:idid/send android:layout_widthwrap_content android:layout_heightwrap_content android:text发送 / /LinearLayout /LinearLayout布局里最关键的是 RecyclerView 的layout_weight1它让消息列表自动占满输入框以上的所有空间。如果不加这个权重键盘弹出来时输入框会被顶到屏幕外面这是聊天页面最常见的布局翻车点。msg_item.xml 的结构稍微讲究一点。它内部有两个 LinearLayout一个靠左、一个靠右分别承载机器人消息和用户消息。初始状态下两个布局都在运行时由适配器根据消息类型决定隐藏哪一个?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding8dp LinearLayout android:idid/left_layout android:layout_widthwrap_content android:layout_heightwrap_content android:layout_gravityleft android:backgrounddrawable/msg_left_bg TextView android:idid/left_msg android:layout_widthwrap_content android:layout_heightwrap_content android:padding10dp android:textSize16sp / /LinearLayout LinearLayout android:idid/right_layout android:layout_widthwrap_content android:layout_heightwrap_content android:layout_gravityright android:backgrounddrawable/msg_right_bg TextView android:idid/right_msg android:layout_widthwrap_content android:layout_heightwrap_content android:padding10dp android:textSize16sp / /LinearLayout /LinearLayoutmsg_item 里需要读者自己补的是两个背景 drawable一个圆角气泡偏左一个偏右。原文说「有一些图片、类库需要自行导入」指的就是这类资源。我当时是直接在 drawable 下建了两个 shape 文件左侧白底灰边、右侧绿底效果跟主流聊天软件差不多。2.2 Msg 实体用 type 字段区分左右消息消息实体 Msg 是整个列表的数据模型代码很短但约定很关键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; } }这里用 int 常量而不是 String 或 boolean是为了后续在适配器里做 switch 判断时更清晰。TYPE_RECEIVED 对应机器人说的话显示在左侧TYPE_SENT 对应用户发送的话显示在右侧。两个常量值故意错开 0 和 1跟后面 ContentBean 里 result 字段的取值规则对齐这样从接口层拿到数据转成 Msg 时心智负担最小。我在实际开发里习惯把 type 字段再拆出一个 TYPE_SYSTEM 用来显示「对方正在输入」或者时间分隔条但这个 demo 不需要两个值够用。2.3 MsgAdapterViewHolder 里的左右布局切换MsgAdapter 继承自 RecyclerView.AdapterViewHolder 内部持有两个布局和两个 TextViewpublic class MsgAdapter extends RecyclerView.AdapterMsgAdapter.ViewHolder { private ListMsg mMsgList; static class ViewHolder extends RecyclerView.ViewHolder { LinearLayout leftLayout; LinearLayout rightLayout; TextView leftMsg; TextView rightMsg; public 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(ListMsg msgList) { mMsgList msgList; } Override public ViewHolder onCreateViewHolder(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(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(); } }这段代码的重点在setVisibility的切换逻辑。每次 bind 时同一个 ViewHolder 里的两个布局只有一个可见另一个必须是 GONE 而不是 INVISIBLE。区别在于 GONE 不占布局空间INVISIBLE 虽然看不见但位置还在会导致消息气泡之间出现大块空白。我在早期项目里吃过这个亏列表看起来稀疏得奇怪后来才发现是 VISIBILITY 的细节。另外注意 onCreateViewHolder 里的parent, false参数第三个参数不能传 true。传 true 表示 inflate 后立即 attach 到父布局RecyclerView 会自己管理 attach重复 attach 会抛异常。这个细节网上讨论很多但初学者经常在这翻车。2.4 把消息列表滚到底部scrollToPosition 的时机聊天列表跟普通列表最大的区别是「新消息要可见」。普通列表新数据插入后停在原位置就行聊天列表必须滚到底部。项目里每个插入点后面都跟了这行代码adapter.notifyItemInserted(msgList.size() - 1); msgRecyclerView.scrollToPosition(msgList.size() - 1);notifyItemInserted通知适配器第 N 个位置插入了新数据scrollToPosition把列表定位到最后一行。两行代码必须成对出现而且顺序不能反。先通知后滚动RecyclerView 知道新数据长的什么样滚动位置才准确反过来会滚到一个旧位置或者直接不滚。这段逻辑出现了两次一次在点击发送按钮后一次在 Handler 收到机器人回复后。两个位置都是在数据已经 add 进 msgList 之后才调用顺序是对的。有读者问为什么不用notifyDataSetChanged那个方法也可以用但会丢失 RecyclerView 的动画效果而且会让整个列表重建数据量大时有明显卡顿。聊天场景用notifyItemInserted更合适。滚动这个动作还有一个隐藏时机问题如果在数据还没准备好时就调用 scrollToPosition列表长度没变滚动到末尾就会失败。所以必须先 add 再 notify 再 scroll三步缺一不可。3. 接口调用三件套RobotManager、GetConnection 与 MyConnection 的配合方式3.1 RobotManager用字符串替换拼接请求 URLRobotManager 是整个网络层最薄的类只干一件事把免费接口的 URL 模板里的占位符替换成用户输入的内容public class RobotManager { private static String url http://api.qingyunke.com/api.php?keyfreeappid0msg!!; public static String getUrl(String question) { String real_url url.replace(!!, question); return real_url; } }接口地址里msg!!的!!是占位符replace(!!, question)会把用户输入替换进去拼出完整请求地址。原项目直接用 String 替换有一个隐患如果用户输入包含中文或特殊符号这个 URL 会非法。实际调用时青云客接口能正常处理中文是因为服务端做了解码。但项目里如果换成其他免费接口这一步大概率出事。我在复现时习惯把 getUrl 改成这样public static String getUrl(String question) { try { String encoded URLEncoder.encode(question, UTF-8); return url.replace(!!, encoded); } catch (UnsupportedEncodingException e) { e.printStackTrace(); return url.replace(!!, question); } }URLEncoder.encode会把中文转成%E4%BD%A0%E5%A5%BD这种百分号编码空格转成这样拼出来的 URL 才是合法的。原项目没做这步也不影响青云客接口但这是一个通用套路今后接任何 GET 接口都用得上。注意catch分支里至少要让程序不崩哪怕退回原始替换也比闪退强。3.2 GetConnection 接口回调设计的两个方法GetConnection 是个只有两个方法的回调接口专门用来把网络请求的结果从子线程传回主线程public interface GetConnection { void onFinish(String response); // 请求成功返回 JSON 字符串 void onError(Exception e); // 请求失败返回异常信息 }这个接口的设计思路跟 Retrofit 的 Callback 一致成功走 onFinish失败走 onError调用方不用关心网络层怎么实现的只需要在两个回调里写自己的逻辑。原文刻意把成功和失败分开是因为这两个分支下一步的处理方式完全不同——成功要解析 JSON 更新 UI失败要打日志给用户提示。我在复现时会建议在 onError 里加一个e.printStackTrace()这样调试时能直接看到异常栈。这个接口本身没有加泛型返回的是原始 String好处是通用坏处是调用方每次都得自己用 Gson 解析。对小项目来说这种简单粗暴的方式反而更容易理解。3.3 MyConnection子线程请求的完整实现MyConnection 是网络层的核心用 HttpURLConnection 发 GET 请求把返回的 JSON 字符串通过回调传出去public class MyConnection { public static void getResponse(final String url, final GetConnection getConnection) { new Thread(new Runnable() { Override public void run() { HttpURLConnection connection; try { URL Url new URL(url); connection (HttpURLConnection) Url.openConnection(); connection.setRequestMethod(GET); // GET 方法 connection.setConnectTimeout(2000); // 连接超时 2 秒 connection.setReadTimeout(3000); // 读取超时 3 秒 connection.setDoInput(true); // 允许读取输入流 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); } Log.e(result, response.toString()); // 调试输出 if (getConnection ! null) { getConnection.onFinish(response.toString()); } else { Log.e(WeiNull, WeiNull); // 测试时用的不用管 } } catch (IOException io) { if (getConnection ! null) { getConnection.onError(io); } } } }).start(); } }这段代码有四个地方值得细说。第一整个请求被包在new Thread(...).start()里这符合 Android 的硬性规定网络请求不允许出现在主线程。Android 从 4.0 开始在主线程做网络请求会直接抛 NetworkOnMainThreadException。这个项目从设计上就规避了这个问题。第二setConnectTimeout(2000)和setReadTimeout(3000)分别控制连接建立和读取数据的超时时间。2 秒和 3 秒对免费接口来说够用但如果后续接口响应慢可以把 readTimeout 提到 5 秒。超时时间设太短容易误杀正常请求设太长用户会以为卡死了。我当时按这个参数跑青云客接口响应基本都在 1 秒内。第三setDoInput(true)表示要从连接读取数据。对 GET 请求来说这个值默认就是 true但显式写出来能让读代码的人一眼看明白意图。真正要留意的是connection.getInputStream()这行连接没建立成功时这里抛 IOException 而不是返回 null所以 catch 分支不处理 NullPointerException只处理 IOException逻辑是闭环的。第四返回结果是逐行读取然后拼到 StringBuilder 里。用 StringBuilder 而不是 String 做拼接是因为循环里每执行一次response response line都会新建一个字符串对象接口返回的 JSON 虽然不大但这是一个好习惯。等整个 JSON 拼完再一次性传给 onFinish 回调。这个类没有在 getResponse 结束前关闭 connection这在短请求场景里问题不大因为请求结束连接对象就失去引用了。但如果要复用连接或者做大文件下载一定要加disconnect()。此处是为了教学清晰省略了连接释放。3.4 ContentBean 与 Gson把 JSON 变成对象接口返回的数据是 JSON 格式需要解析成 Java 对象才能方便取值。项目用 Gson 库来解析对应的 Bean 类是 ContentBeanpublic class ContentBean { private int result; // 0 表示请求成功 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:0,content:你好我是菲菲}字段名跟 ContentBean 完全对应。Gson 解析时靠字段名匹配result映射到 intcontent映射到 String不需要任何注解。使用 Gson 要先在 build.gradle 里加依赖。原文项目用的是老式 Android Studio 结构在 app 模块的 dependencies 里加一行implementation com.google.code.gson:gson:2.x最终版本号以 Android Studio 自动拉取到的为准。加完依赖记得 Sync 一下工程否则 Gson 类会报红。解析调用只有两行Gson gson new Gson(); contentBean gson.fromJson(response, ContentBean.class);注意fromJson的第二个参数是 Class 类型如果你的 JSON 是一个数组这里要传TypeToken而不是直接传类。这个项目返回的是单个对象所以直接传ContentBean.class就行。如果接口返回格式变了比如 content 变成了嵌套对象Bean 里对应的字段类型也要跟着改否则解析出来是 null。Gson 解析还有一个隐蔽的坑JSON 里多了字段Gson 不会报错会静默忽略少了字段对应属性就是默认值。所以解析完一定要检查contentBean.getContent()是否为空不要急着塞进 UI。4. MainActivity 页面逻辑Handler 线程切换与消息动态插入4.1 初始化与发送按钮监听MainActivity 承担了页面所有逻辑初始化部分把输入框、发送按钮、RecyclerView 和适配器串起来Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initMsgs(); // 初始化欢迎消息 inputText findViewById(R.id.input_text); send findViewById(R.id.send); msgRecyclerView findViewById(R.id.msg_recycler_view); LinearLayoutManager layoutManager new LinearLayoutManager(this); msgRecyclerView.setLayoutManager(layoutManager); adapter new MsgAdapter(msgList); msgRecyclerView.setAdapter(adapter); send.setOnClickListener(new View.OnClickListener() { Override public void onClick(View v) { String content inputText.getText().toString(); if (!.equals(content)) { Msg msg new Msg(content, Msg.TYPE_SENT); msgList.add(msg); getInter(content); // 发起机器人接口请求 adapter.notifyItemInserted(msgList.size() - 1); msgRecyclerView.scrollToPosition(msgList.size() - 1); inputText.setText(); // 清空输入框 } } }); }初始化顺序有个讲究先 initMsgs 把欢迎语填进 msgList再创建适配器。如果反过来适配器创建时 msgList 还是空的界面上一开始就看不到「我是菲菲」的欢迎消息。msgList 用的是 ArrayList初始构造时直接传入适配器后续 add 数据后适配器能感知到变化因为持有的是同一个引用。发送按钮的监听里有一个空字符串判断!.equals(content)。这里没有用content.isEmpty()是因为.equals(content)在 content 为 null 时不会抛异常返回 false比content.isEmpty()更安全。虽然 EditText 的 getText 理论上不会返回 null但这种防御写法值得保留。inputText.setText()用来清空输入框。少了这行用户每次发完消息内容还留在输入框里要点一下手动删掉体验很差。不过 setText 之后光标位置会重置到最前面如果后续要保留输入习惯可以加一句inputText.setSelection(0)把光标移到开头。4.2 getInter从点击到接口返回的完整链路getInter 是连接页面和网络层的枢纽方法。它接收用户输入拼 URL发起异步请求并通过回调把结果用 Handler 发出去private void getInter(String content) { MyConnection.getResponse(RobotManager.getUrl(content), new GetConnection() { Override public void onFinish(String response) { ContentBean contentBean new ContentBean(); Log.e(getResult, response); Message msg new Message(); Bundle data new Bundle(); Gson gson new Gson(); contentBean gson.fromJson(response, ContentBean.class); if (contentBean.getResult() 0) { data.putString(result, contentBean.getContent()); } else { data.putString(result, 我听不懂你在说什么呀); } msg.setData(data); msg.what 1; handler.sendMessage(msg); } Override public void onError(Exception e) { e.printStackTrace(); } }); }这段代码是整篇文章里最值得反复读的部分它把一个完整的异步流程压缩在十几行里。流程是用户点击发送 → getInter 被调用 → RobotManager 生成带参数的 URL → MyConnection 在子线程发请求 → 请求成功回调 onFinish → 在回调里用 Gson 解析 JSON → 把解析结果放进 Message → handler.sendMessage 把消息发到主线程队列。注意 onFinish 这个方法本身跑在子线程里因为它是在 MyConnection 的 run 方法里被调用的。Android 不允许在子线程直接修改 UI所以这里没有直接操作 RecyclerView而是把数据包装成 Message 交给 Handler。这是整个项目里线程边界最清晰的地方网络层和解析层在子线程UI 更新在主线程。contentBean.getResult() 0判断接口业务状态。青云客接口约定 0 是成功其他值是无法理解的问题。这里设计了一个降级文案「我听不懂你在说什么呀」避免接口返回异常结果时把空内容展示到气泡里。msg.what 1是一个消息类型标记Handler 里根据这个值区分不同的业务场景。原文还有 case 2 是空分支说明作者预留了扩展位比如将来加一个「请求失败」的 UI 反馈可以走 case 2。4.3 Handler 的 switch 处理成功与失败分支Handler 在主线程创建handleMessage 在主线程执行专门负责接收子线程传来的消息并更新 UIprivate Handler handler new Handler() { 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 是 Android 老生常谈的机制这里用到的只是最表层的能力sendMessage 发送消息handleMessage 接收消息。关键是理解为什么不能省掉这一层——onFinish 回调跑在子线程直接调用 adapter.notifyItemInserted 轻则界面不刷新重则抛 CalledFromWrongThreadException这是 Android 的硬约束没有例外。case 1 的处理逻辑跟按钮点击后的逻辑类似都是 add 数据、notify、scroll 三步。区别在于这里创建的是 Msg.TYPE_RECEIVED 类型显示在左侧气泡里。一个完整的对话过程就是用户消息走按钮监听机器人消息走 Handler两套代码最终汇聚到同一个消息列表里。case 2分支在项目里是个空壳原文没有写任何代码。我复现时建议在这里补一个 Toast 提示「网络开小差了」否则请求失败时用户只会看到自己发出去的消息孤零零挂在右边没有任何反馈。Toast 的代码可以直接写在 case 2 里因为 handler 跑在主线程允许直接弹 Toast。还有一个性能细节值得注意Handler 里每次收到消息都调getData()拿 Bundle。如果一次消息里要传多个字段推荐的做法是先定义常量 key 名统一管理避免在 handleMessage 里魔法字符串满天飞。这个项目只有一个 result 字段可以不用做这个抽象。5. 避坑与排查网络权限、中文编码、线程阻塞与 JSON 解析5.1 现象一运行就闪退或者点击发送无反应现象项目跑起来后直接闪退Logcat 里报的是java.lang.SecurityException: Permission denied (missing INTERNET permission?)或者不闪退但点击发送按钮没有任何反应。原因AndroidManifest.xml 里没有声明网络权限。从 Android 6.0 开始高危权限需要运行时申请但 INTERNET 属于普通权限只需要在 Manifest 里声明就能用。没有这个声明HttpURLConnection 在建立连接时直接被系统拒绝。点击发送无反应的情况通常是由网络请求抛异常引起的比如 timeout但因为没有日志输出看起来像按钮没绑上监听。解决在 AndroidManifest.xml 的manifest标签下、application之前加一行uses-permission android:nameandroid.permission.INTERNET /如果项目跑在 Android 9API 28以上还需要检查是否允许明文 HTTP 流量。青云客接口是 http 而不是 https默认情况下 Android 9 会拦截明文请求在application标签上加一行application android:usesCleartextTraffictrue ...这两个问题往往一起出现先报权限异常加上权限后又报明文流量异常。我在复现时被这个组合拳坑过一次第一次只加了权限请求还是失败排查半天才想起是明文流量被拦了。5.2 现象机器人回复全是乱码或者 Toast 里全是问号现象请求成功接口也返回了内容但页面上显示的机器人消息是乱码类似ä½ å¥½这种或者中文变成一排问号。原因读取输入流时没有指定字符集。青云客接口返回的是 UTF-8 编码而new InputStreamReader(in)默认使用系统平台编码。Windows 环境下这个默认值经常是 GBK用 GBK 解码 UTF-8 文本必然乱码。解决在创建 InputStreamReader 时显式指定字符集BufferedReader reader new BufferedReader(new InputStreamReader(in, UTF-8));把 MyConnection 里的new InputStreamReader(in)改成new InputStreamReader(in, UTF-8)即可。注意UTF-8字符串要全大写带横线写utf-8或者UTF8在某些 Java 版本上会抛 UnsupportedEncodingException。字符集问题在 Android 上不太常见因为大多数手机默认 UTF-8但如果用模拟器或者在 Windows 上跑 Java 测试代码就很容易翻车。5.3 现象请求一直抛 IOException连不上接口地址现象Logcat 里打印的异常是java.net.ConnectException: Failed to connect to api.qingyunke.com或者SocketTimeoutException而且每次请求都这样不是偶发。原因三种可能。第一设备没联网模拟器网络断开或者手机没连 Wi-Fi。第二接口地址拼错了比如占位符没有替换整个 URL 里带了个!!字符串。第三请求用的是 http 明文但系统版本默认禁止明文流量前面 5.1 里说过的场景。还有一种隐蔽情况是模拟器连不上宿主机网络但青云客是公网接口不在这个范围内。解决先在浏览器里直接访问拼好的 URL比如http://api.qingyunke.com/api.php?keyfreeappid0msg你好如果能返回 JSON说明接口本身是通的。然后把 RobotManager.getUrl 的结果打出来确认!!确实被替换了。最后确认 Manifest 里的网络权限和明文流量开关。一个排查技巧在 MyConnection 里加一行Log.e(requestUrl, url);把实际请求的 URL 打出来看比猜快得多。5.4 现象消息重复插入或者列表停在原地不滚动现象点一次发送按钮界面上出现两条相同消息或者新消息已经 add 进列表了但 RecyclerView 还停在旧位置看不到最新消息。原因重复插入大概率是 onFinish 回调被触发了两次。检查一下 getInter 是不是被调用多次比如按钮监听里既调了 getInter 又调了 getResponse或者在 Handler 又发了一次。列表不滚动则是因为scrollToPosition调用时机太早RecyclerView 还没来得及测量新 item 的高度滚动位置到了但内容没渲染出来看起来像没滚动。解决重复插入的问题可以在点击发送后加一个互斥锁或布尔标记比如isWaitingResponse true等 onFinish 回调回来后再置为 false期间屏蔽重复点击。列表不滚动的问题用msgRecyclerView.scrollToPosition(msgList.size() - 1)无效时可以换成msgRecyclerView.smoothScrollToPosition(msgList.size() - 1);注意传入的 position 是适配器里的位置索引要从 0 数起。如果 msgList 有 10 条消息最后一条的索引是 9不是 10。scrollToPosition传 10 会越界或者滚到错误位置。我调试时习惯在滚动前先打印一下msgList.size() - 1的值确认与数据量一致。5.5 现象接口正常返回但机器人一直在说「我听不懂你在说什么」现象请求成功了JSON 也解析出来了但不管问什么界面上都是那句降级文案。原因两个方向。第一个方向是contentBean.getResult() ! 0接口认为这次查询不合法这种情况通常发生在请求频率过高或者内容包含敏感词时。第二个方向是contentBean.getContent()本身返回内容为空但result是 0页面逻辑把空串塞进了气泡。Gson 解析字段名不匹配时content 字段会返回 nullputString(result, null)在 Bundle 里会变成 null最终气泡里显示空白或「null」字样。解决在解析后加一行日志把result和content都打出来Log.e(ParseResult, result contentBean.getResult() , content contentBean.getContent());如果 content 是 null检查接口返回的 JSON 字段名是不是变了。如果 result 非 0检查是不是请求频率太高免费接口通常有调用频率限制短时间连续发几十条就会被拉黑几分钟。我当时遇到过这个情况间隔几秒再发就恢复了。还有一种可能用户输入包含特殊字符导致 URL 编码有问题接口返回了一个错误结果但 result 恰好不是 0。6. 把 demo 往前再推一步URL 编码、时间戳与超时重试复现这个项目之后我建议至少再改三处代码让它在真实环境里更像一个能用的对话机器人。第一处是把 RobotManager 的 URL 拼接改成编码后再替换。原项目的直接 replace 在青云客接口上没问题但换一个免费接口大概率踩坑。我之前接另一个天气接口时中文城市名没编码请求直接 400。改成URLEncoder.encode(question, UTF-8)之后再拼 URL问题立刻消失。第二处是给消息加上时间戳。原项目的 Msg 类只有 content 和 type界面上看不出消息的时间。加一个private String time;字段在构造时用SimpleDateFormat格式化当前时间然后在 msg_item 布局里加一个小的 TextView 显示时间。注意 SimpleDateFormat 的线程安全问题在 Adapter 的 onBindViewHolder 里格式化而不是在消息实体里存字符串更稳妥。第三处是超时重试机制。原项目的超时参数是 2 秒 / 3 秒免费接口在高峰时段偶尔会慢超时后 onError 只打了日志用户界面完全没反应。我在 onError 回调里加了一个计数器连续失败超过 3 次就弹 Toast 提示「网络不稳定」而不是默默重试。重试时把 readTimeout 从 3 秒提升到 5 秒给接口更多响应时间。改完这三处整个项目从「能跑的 demo」变成了「敢装到手机上给别人演示的小工具」。我至今保留着一个习惯跑通任何一个开源聊天 demo第一件事就是把请求的 URL 和返回的 JSON 完整打印出来确认数据和代码假设一致再去看界面效果。这个习惯帮我避开了无数「明明照着抄却跑不通」的奇怪问题——大多数情况下问题根本不在代码而在接口返回的数据格式跟预期对不上。如果你拿到这个项目文末的源码照着文章里的节奏走一遍会发现最大的收获不是学会了 RecyclerView 或者 Gson而是理解了 Android 里网络请求、线程切换、UI 更新这三者的配合方式。这三个点打通了后面再学 Retrofit、协程、MVVM 都会顺畅很多。希望这个项目能帮到你。本文还有配套的精品资源点击获取
返回列表