简介:这份课程设计报告面向移动应用开发课程学习者与 Android 入门开发者,围绕在 Android 平台上实现智能聊天机器人展开,涵盖实验目的、实验内容、程序设计与说明、界面设计思路、类说明及运行截图,可作为课程设计、作业习题与项目实践参考。压缩包仅含 1 个 doc 文件,大小约 1.46MB,正文完整呈现从图灵机器人 API 申请、聊天界面布局,到 ChatBean、ChatAdapter、ListView 数据适配、OkHttp 网络请求与 Json 解析的实现脉络,并对 RobotActivity 中初始化、显示、发送、获取回复和更新界面等方法作出说明。已有 2708 人学习,适合需要理解 Android UI、数据存储、四大组件与网络编程综合运用的读者对照梳理项目结构、撰写报告和复盘关键实现。
1. 从一份课设报告说起:这个 Android 聊天机器人到底怎么跑起来的
去年帮一个学弟看他的移动应用开发课设,界面画得挺像样,一点发送就闪退,Logcat 里一行NetworkOnMainThreadException把他整懵了。这类安卓 app 课设最常见的问题不是 UI 画不出来,而是网络请求和线程切换没接上。这份资源是一份完整的课程设计报告加配套源码,核心是一个用第三方机器人开放平台 API 做的聊天界面:左边机器人头像和消息、右边用户头像和消息,底部输入框加发送按钮,消息用 ListView 承载。它适合两类人:正在做作业习题、需要一份报告和代码能对得上的课设参考的同学,以及刚接触 OkHttp + ListView、想找一个完整可运行示例的初级开发。整套东西不复杂,但把“输入→请求→解析→更新界面”这条最容易翻车的链路讲得比较透。
2. 项目骨架与技术选型:OkHttp + 机器人 API + ListView 的三层结构
2.1 目录结构与资源文件清单
拿到源码先别急着点运行,把目录扫一遍,知道哪个文件管哪块,后面改起来才不迷路。这套工程的目录是典型的课设级结构,没有分包分层,三个 Java 类加几个布局文件就撑起了整个应用:
app/src/main/ ├── java/com/example/chatrobot/ │ ├── MainActivity.java # 聊天主界面,五个核心方法都在这里 │ ├── ChatBean.java # 消息实体,state + content │ └── ChatAdapter.java # ListView 适配器,负责左右两种布局 ├── res/ │ ├── layout/ │ │ ├── activity_main.xml # 整体聊天界面 │ │ ├── chatting_left_item.xml # 机器人消息气泡 │ │ └── chatting_right_item.xml # 用户消息气泡 │ ├── drawable/ │ │ ├── chat_left_selector.xml # 机器人气泡背景 │ │ ├── chat_right_selector.xml # 用户气泡背景 │ │ ├── robot_head.png # 机器人头像 │ │ └── myhead.png # 用户头像 │ └── values/ │ ├── strings.xml # 欢迎语数组、应用名 │ └── styles.xml # chat_content_style 气泡文字样式 └── AndroidManifest.xml # INTERNET 权限别忘加这个结构对应的是“Activity 一把梭”的写法:网络请求、JSON 解析、UI 更新全在 MainActivity 里。好处是新手能顺着一个文件读到底,不用在 MVP、MVVM 的层级里绕;坏处是后面加功能会越来越臃肿,第 6 章会讲怎么往外抽。我一般会先看 ChatBean 和 ChatAdapter 的职责边界,再回头看 MainActivity 的五个方法,最后对照布局文件,这样读代码不会乱。
提示:AndroidManifest.xml 里必须声明
<uses-permission android:name="android.permission.INTERNET" />,少这一行请求会直接抛异常,报错信息还不一定指向权限。
2.2 技术选型:为什么是 OkHttp 3.12.0 + ListView
网络层选 OkHttp 3.12.0,这是课设环境里比较稳的一个版本。原始报告里写的是右键项目 → Open Module Settings → Dependencies → 加com.squareup.okhttp3:okhttp:3.12.0,这是老版本 Android Studio 的操作路径,现在直接在app/build.gradle里加一行就行:
dependencies { implementation 'com.squareup.okhttp3:okhttp:3.12.0' // JSON 解析用 Android 自带的 org.json,不必额外引 Gson }3.12.0 的RequestBody.create(MediaType, String)写法和后面代码对得上,API 稳定。如果换成 OkHttp 4.x,部分方法改成了 Kotlin 属性风格,老代码直接抄会编译不过,这是版本选择上要先对齐的地方。
列表控件用 ListView 而不是 RecyclerView,原因很现实:课设报告成文早,BaseAdapter加getView的代码量少,用getItemViewType控制左右两种布局也直观。代价是notifyDataSetChanged全量刷新,消息条数多了会掉帧;没有默认的增删动画;ViewHolder 得自己手写。如果只是交课程设计报告,这套够用;如果要往能上架的程度改,换成 RecyclerView 加 DiffUtil 是第一步。
Android 9.0 之后默认禁止明文 HTTP 请求。如果开放平台的接口地址是 http 开头,需要在 manifest 的 application 标签里加android:usesCleartextTraffic="true",或者配置 network_security_config。模拟器上跑一般没问题,真机调试时这个坑很常见,表现为请求直接失败、回调里拿到的是 IOException,而不是服务器返回的错误码。
2.3 机器人 API 的接入方式与 Key 申请
第三方机器人开放平台的接入方式是标准的 HTTP POST + JSON。注册账号后创建一个机器人,平台会给一个apiKey,每个 Key 绑定一个机器人实例,请求时放在userInfo里。请求体结构如下:
{ "reqType": 0, "perception": { "inputText": { "text": "你好" } }, "userInfo": { "apiKey": "替换成你自己的KEY", "userId": "demo_user_001" } }reqType: 0表示文本输入;perception.inputText.text是用户说的话;userId用来区分会话上下文,同一个 userId 连续对话时机器人能记住前文。返回体里真正要取的是results[0].values.text:
{ "intent": { "code": 10004 }, "results": [ { "resultType": "text", "values": { "text": "你好呀,有什么可以帮你?" } } ] }有个容易忽略的点:intent.code是意图码,不是 HTTP 状态码。请求成功但机器人没匹配到意图时,results可能为空,或者values.text缺字段,解析时要做空判断,不能直接getJSONObject(0)。免费额度按天算,课设演示够用,压测就别想了。
注意:apiKey 不要硬编码在提交的代码里,课设无所谓,但养成放到
local.properties或 BuildConfig 字段里的习惯,后面改成正式项目不用返工。
3. 核心模块拆解:ChatBean、ChatAdapter 与 MainActivity 的完整链路
3.1 ChatBean:消息状态与内容的封装
ChatBean 是整个数据流的起点,结构简单到只有两个字段,但状态约定必须提前定清楚,不然后面 adapter 里的判断会乱。原始报告里把消息状态定为state,发送消息值为 1,接收消息值为 2。我按这个约定写:
public class ChatBean { private int state; // 1 表示用户发送,2 表示机器人接收 private String content; // 消息正文 public ChatBean(int state, String content) { this.state = state; this.content = content; } public int getState() { return state; } public void setState(int state) { this.state = state; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } }这里用 int 而不是枚举,是课设代码里常见的简化写法,好处是跟 adapter 的getItemViewType返回值直接对应,坏处是可读性差,1 和 2 的含义得靠注释记。如果后面要扩展语音、图片消息,建议改成public static final int TYPE_SEND = 1;这种常量,或者直接用枚举,避免魔法数字散落在各处。
构造方法里直接把 state 和 content 传进来,省掉了先 new 再 set 的样板代码。content用 String 存,不存 JSON 原文,是因为这个场景只需要展示文本,原始返回里的 intent 码、resultType 在 UI 层用不上。如果要做消息重发或者失败标记,可以再加一个status字段,跟state分开,一个管方向,一个管发送状态。
3.2 ChatAdapter:getView 里的 if-else 与多布局复用
Adapter 是这套代码里最值得细看的一块。原始报告说在getView里用 if/else 判断当前消息是发送还是接收,接收就加载左边布局,发送就加载右边布局。这个思路对,但只写getView不够,必须同时重写getItemViewType和getViewTypeCount,否则 ListView 的回收机制会把两种布局混着复用,出现“用户消息跑到左边”的经典错位。
public class ChatAdapter extends BaseAdapter { private List<ChatBean> data; private Context context; public ChatAdapter(Context context, List<ChatBean> data) { this.context = context; this.data = data; } @Override public int getCount() { return data == null ? 0 : data.size(); } @Override public Object getItem(int position) { return data.get(position); } @Override public long getItemId(int position) { return position; } // 返回当前 item 的类型,直接复用 ChatBean 的 state @Override public int getItemViewType(int position) { return data.get(position).getState(); } // 必须返回类型总数,这里只有发送和接收两种 @Override public int getViewTypeCount() { return 2; } @Override public View getView(int position, View convertView, ViewGroup parent) { ViewHolder holder; ChatBean bean = data.get(position); if (convertView == null) { holder = new ViewHolder(); if (bean.getState() == 1) { // 用户消息,加载右侧布局 convertView = LayoutInflater.from(context) .inflate(R.layout.chatting_right_item, parent, false); } else { // 机器人消息,加载左侧布局 convertView = LayoutInflater.from(context) .inflate(R.layout.chatting_left_item, parent, false); } holder.tvContent = convertView.findViewById(R.id.tv_content); convertView.setTag(holder); } else { holder = (ViewHolder) convertView.getTag(); } holder.tvContent.setText(bean.getContent()); return convertView; } static class ViewHolder { TextView tvContent; } }几个参数和逻辑要说清楚。getItemViewType返回的是state,所以 ListView 在回收时会按类型分开缓存,类型 1 的 convertView 不会拿去渲染类型 2 的 item。getViewTypeCount返回 2,告诉 ListView 一共有几种类型,这个方法不重写的话默认是 1,所有 item 会被当成同一种类型复用,左右布局就会串。convertView == null时才 inflate,配合 ViewHolder 减少findViewById的调用次数,这是 ListView 的标准优化写法。
LayoutInflater.from(context).inflate(..., parent, false)第三个参数传 false 很关键。传 true 或者不传,布局的 layout 参数会失效,气泡宽度可能被撑满或者被压扁,显示效果跟 XML 里写的对不上。attachToRoot为 false 表示只解析布局、不挂到 parent 上,由 ListView 自己管理挂载。
3.3 MainActivity 五个方法的调用时序
MainActivity 里有五个核心方法:initView()、showData()、sendData()、getDataFromServer()、updateView(),再加上重写的onKeyDown()。这五个方法不是并列关系,而是有明确的调用顺序,理清了这条链路,调试的时候就知道该在哪个方法里打断点。
public class MainActivity extends AppCompatActivity { private ListView lvChat; private EditText etInput; private Button btnSend; private List<ChatBean> data; private ChatAdapter adapter; private Handler handler = new Handler(Looper.getMainLooper()); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); initView(); // 第一步:拿控件、建数据源、设 adapter showData(); // 第二步:塞欢迎语 } private void initView() { lvChat = findViewById(R.id.lv_chat); etInput = findViewById(R.id.et_input); btnSend = findViewById(R.id.btn_send); data = new ArrayList<>(); adapter = new ChatAdapter(this, data); lvChat.setAdapter(adapter); btnSend.setOnClickListener(v -> sendData()); } private void showData() { // 欢迎语从 strings.xml 的 string-array 里随机取一条 String[] welcomes = getResources().getStringArray(R.array.welcome_messages); String welcome = welcomes[new Random().nextInt(welcomes.length)]; data.add(new ChatBean(2, welcome)); adapter.notifyDataSetChanged(); } private void sendData() { String text = etInput.getText().toString().trim(); if (text.isEmpty()) { return; // 空消息不处理 } data.add(new ChatBean(1, text)); adapter.notifyDataSetChanged(); etInput.setText(""); lvChat.setSelection(data.size() - 1); // 滚到底部 getDataFromServer(text); } private void updateView(String reply) { data.add(new ChatBean(2, reply)); adapter.notifyDataSetChanged(); lvChat.setSelection(data.size() - 1); } }initView负责控件绑定和数据源初始化,showData在onCreate里紧接着调用,把欢迎语塞进列表。sendData绑定在发送按钮上,先做空判断,再把用户消息加入列表并刷新,然后清空输入框、滚动到底部,最后发起网络请求。updateView是网络回调回来之后调用的,负责把机器人回复追加到列表。
lvChat.setSelection(data.size() - 1)这行容易被忽略。ListView 默认不会自动滚到最新一条,消息多了之后用户发完消息看不到自己的输入,体验很怪。加上这行之后每次新增消息都会滚到底部。
onKeyDown里的返回键处理,原始报告的做法是直接退出应用。实现上判断keyCode == KeyEvent.KEYCODE_BACK,然后finish()。如果要做“再按一次退出”的提示,可以用一个long lastBackTime记录上次按下的时间,两次间隔小于 2 秒才真正退出,这是常见做法。
3.4 getDataFromServer:OkHttp POST + JSON 解析的完整实现
网络请求这块是整套代码的核心,也是新手最容易翻车的地方。OkHttp 的enqueue是异步的,回调在子线程执行,不能直接在里面更新 UI。
private void getDataFromServer(final String text) { OkHttpClient client = new OkHttpClient(); MediaType JSON = MediaType.parse("application/json; charset=utf-8"); try { // 按平台要求的格式拼请求体 JSONObject inputText = new JSONObject(); inputText.put("text", text); JSONObject perception = new JSONObject(); perception.put("inputText", inputText); JSONObject userInfo = new JSONObject(); userInfo.put("apiKey", "替换成你自己的KEY"); userInfo.put("userId", "demo_user_001"); JSONObject body = new JSONObject(); body.put("reqType", 0); body.put("perception", perception); body.put("userInfo", userInfo); Request request = new Request.Builder() .url("https://openapi.example.com/openapi/api/v2") .post(RequestBody.create(JSON, body.toString())) .build(); client.newCall(request).enqueue(new Callback() { @Override public void onFailure(Call call, IOException e) { // 网络失败也要回主线程更新 UI handler.post(() -> updateView("网络开小差了:" + e.getMessage())); } @Override public void onResponse(Call call, Response response) throws IOException { if (response.body() == null) { return; } String result = response.body().string(); final String reply = parseReply(result); // 回调在子线程,必须切回主线程 handler.post(() -> updateView(reply)); } }); } catch (JSONException e) { e.printStackTrace(); } } private String parseReply(String json) { try { JSONObject obj = new JSONObject(json); JSONArray results = obj.optJSONArray("results"); if (results != null && results.length() > 0) { JSONObject values = results.getJSONObject(0).optJSONObject("values"); if (values != null) { return values.optString("text", "我没听懂,换个说法试试"); } } } catch (JSONException e) { e.printStackTrace(); } return "解析失败"; }MediaType.parse("application/json; charset=utf-8")这行里的 charset 必须写。不写的话,中文请求体在某些服务器上会被按 ISO-8859-1 解析,发出去变乱码,机器人回你一串问号。RequestBody.create(JSON, body.toString())第一个参数是 MediaType,第二个是 JSON 字符串,顺序别搞反。
enqueue的onResponse回调里,response.body().string()只能调用一次,调两次会抛异常。拿到字符串之后立刻交给parseReply解析,不要在外面再读一遍。parseReply里用optJSONArray和optString而不是getJSONArray、getString,是为了字段缺失时返回 null 或默认值,不直接抛 JSONException 把回调线程打崩。
handler.post不能省。OkHttp 的回调线程是它自己的线程池,不是主线程,直接在里面调adapter.notifyDataSetChanged()会触发CalledFromWrongThreadException。用new Handler(Looper.getMainLooper())或者runOnUiThread都行,前者更适合在非 Activity 类里用。
4. 布局与样式:activity_main、chatting_left_item、chatting_right_item 三份 XML 怎么配合
4.1 activity_main 的嵌套布局与控件层级
主界面用 LinearLayout 竖向排列,上面一个标题 TextView,下面一个 RelativeLayout 撑满剩余空间,里面放 ListView 和底部输入区。
<?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="match_parent" android:orientation="vertical"> <TextView android:layout_width="match_parent" android:layout_height="48dp" android:gravity="center" android:text="智能聊天机器人" android:textSize="18sp" android:textStyle="bold" /> <RelativeLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"> <ListView android:id="@+id/lv_chat" android:layout_width="match_parent" android:layout_height="match_parent" android:divider="@null" android:scrollbars="none" /> <RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:padding="8dp"> <EditText android:id="@+id/et_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_toLeftOf="@+id/btn_send" android:hint="输入聊天内容" /> <Button android:id="@+id/btn_send" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true" android:text="发送" /> </RelativeLayout> </RelativeLayout> </LinearLayout>RelativeLayout的高度用0dp加layout_weight="1",表示占满标题栏剩下的所有空间。ListView 的layout_height是match_parent,会铺满整个 RelativeLayout,输入区通过layout_alignParentBottom="true"叠在它上面。这里有个视觉问题:ListView 的最后一条消息可能被输入框挡住,需要在 ListView 上加android:paddingBottom或者用android:clipToPadding="false"配合底部 padding,让滚动到底部时最后一条能露出来。
EditText用layout_toLeftOf="@+id/btn_send"把发送按钮挤到右边,自己占剩余宽度。layout_width="match_parent"配合toLeftOf,在 RelativeLayout 里是有效的,等价于“从左边一直延伸到按钮左边”。如果写成wrap_content,输入框会缩成一小截,用起来很别扭。
注意:如果 ListView 的 item 根布局高度写死,或者
chatting_left_item里的头像用了match_parent,消息少的时候气泡会被拉高,看起来像空了一大块。item 根布局用wrap_content最稳。
4.2 气泡背景 selector 与 style 复用
左右两个气泡布局结构几乎一样,区别在头像图片和背景 selector。以左侧机器人为例:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="8dp"> <ImageView android:id="@+id/iv_head" android:layout_width="40dp" android:layout_height="40dp" android:src="@drawable/robot_head" /> <TextView android:id="@+id/tv_content" style="@style/chat_content_style" android:layout_toRightOf="@+id/iv_head" android:layout_marginLeft="8dp" android:background="@drawable/chat_left_selector" /> </RelativeLayout>右侧布局把 ImageView 换成myhead,TextView 用layout_toLeftOf靠右对齐,背景换成chat_right_selector。两边共用同一个chat_content_style:
<!-- res/values/styles.xml --> <style name="chat_content_style"> <item name="android:layout_width">wrap_content</item> <item name="android:layout_height">wrap_content</item> <item name="android:padding">10dp</item> <item name="android:textSize">16sp</item> <item name="android:maxWidth">240dp</item> <item name="android:textColor">#333333</item> </style>maxWidth是必须加的。不加的话,用户发一段长文本,气泡会横向撑满整个屏幕,把头像挤没。240dp 大约是一屏宽度的三分之二,超过这个宽度文字自动换行,气泡高度增加,看起来才像聊天软件。
selector 里用state_pressed做一个按压态变色,虽然聊天气泡一般不需要点击反馈,但课设报告里这么写了,加上也无妨:
<!-- res/drawable/chat_left_selector.xml --> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_pressed="true"> <shape android:shape="rectangle"> <solid android:color="#D6D6D6" /> <corners android:radius="8dp" /> </shape> </item> <item> <shape android:shape="rectangle"> <solid android:color="#F5F5F5" /> <corners android:radius="8dp" /> </shape> </item> </selector>shape里的solid是填充色,corners是圆角半径。左右两个 selector 只差颜色,右侧用户气泡可以用一个偏蓝的底色跟机器人区分开,视觉上更容易分辨谁在说话。
5. 踩坑与排查:从欢迎语不显示到回调线程崩溃的 5 个血泪记录
5.1 现象:一打开就闪退,Logcat 报 NetworkOnMainThreadException
原因很直接:请求写成了client.newCall(request).execute()同步调用,而且放在主线程。Android 从 3.0 开始不允许主线程做网络 IO,检测到就直接抛异常。
解决办法是改用enqueue异步回调。如果确实需要同步请求,放到子线程或者用StrictMode临时放开,但课设场景没必要,异步写法更标准。异步回调里更新 UI 记得切主线程,否则会触发下一节的错误。
5.2 现象:机器人已经回复了,但界面不刷新,ListView 还是旧内容
原因是在 OkHttp 的onResponse回调里直接调了adapter.notifyDataSetChanged()。这个回调运行在 OkHttp 的子线程,ListView 的刷新必须在主线程做,跨线程操作要么不生效,要么抛CalledFromWrongThreadException。
解决就是加Handler(Looper.getMainLooper())或者runOnUiThread,把updateView包一层再调用。我习惯在 Activity 里声明一个成员handler,所有网络回调统一走它,不在回调里直接碰 View。
5.3 现象:消息气泡左右错乱,用户发的消息显示在机器人一侧
原因是 Adapter 只重写了getItemViewType,没重写getViewTypeCount。默认getViewTypeCount返回 1,ListView 把所有 item 当成同一种类型复用,回收来的 convertView 可能是左边布局,直接拿去渲染右边消息就串了。
解决是把两个方法成对重写:getItemViewType返回ChatBean.getState(),getViewTypeCount返回 2。另外,getItemViewType的返回值必须从 0 开始连续,如果 state 直接用了 1 和 2,类型 0 是空的,不影响功能但不够规范,改成 0 和 1 更稳。
5.4 现象:中文发出去变成乱码,机器人回复一串问号
原因是MediaType没指定 charset,或者 JSON 拼接时用了平台默认编码。OkHttp 的RequestBody.create如果 MediaType 是application/json不带 charset,默认按 UTF-8 处理,但某些服务器端会按请求头里的编码解析,两边对不上就乱码。
解决是显式写MediaType.parse("application/json; charset=utf-8"),并且body.toString()本身是 Java String,不存在编码转换问题。如果从文件读 JSON 或者用getBytes()手动转,要确保用 `StandardCharsets.UTF
本文还有配套的精品资源,点击获取