【Android】ChatRoom App 技术分析
网络
OkHttp
配合后端上传或拉取资源
OKHTTP 是 Android 流行的网络请求库,性能好且功能强大,聊天室项目主要使用 OKHTTP 来提交和拉取 JSON 数据
public static final String url = "https://api.example.com/example";OkHttpClient client = new OkHttpClient();
RequestBody requestBody = RequestBody.create(json.toString(), MediaType.get("application/json; charset=utf-8")
);
Request request = new Request.Builder().url(url).post(requestBody) // .get() 表示拉取JSON.build();Response response = client.newCall(request).execute();
String result = response.body().string();
// 得到JSON数据
WebSocket
建立网络通信连接
简单的实现方法是 OkHttp 结合 WebSocketListener,首先要重写 WebSocketListener 的 4 种重写方法
通常还要加入 start 开始连接,sendMessage 发送消息和 close 主动关闭的方法,这 3 种方法往往接受 OkHttpClient 和 WebSocket 实例
public class MyWebSocketListener extends WebSocketListener {private Activity activity;private OkHttpClient client;private WebSocket webSocket;public void start(Activity activity, String url) {this.activity = activity;this.viewModel = viewModel;client = new OkHttpClient();Request request = new Request.Builder().url(url).build();webSocket = client.newWebSocket(request, this);}public void sendMessage(String message) { if(webSocket != null) webSocket.send(message); }public void close() { if(webSocket != null) webSocket.close(1000, "Closing"); }@Overridepublic void onOpen(WebSocket webSocket, Response response){super.onOpen(webSocket, response);// 连接成功的回调}@Overridepublic void onFailure(WebSocket webSocket, Throwable t, Response response){super.onFailure(webSocket, t, response);// 连接失败的回调}@Overridepublic void onMessage(WebSocket webSocket, String text) {// 回调处理接收到的JSON数据}@Overridepublic void onClosed(WebSocket webSocket, int code, String reason) {super.onClosed(webSocket, code, reason);// 连接关闭的回调}
}
UI
RecyclerView
消息列表
首先准备数据模型类 Item 和 item 条目的布局文件 item.xml
,定义继承自 RecyclerView.Adapter<VH>
的类,重写构造方法,onCreateViewHolder,onBindViewHolder 和 getItemCount,VH 是同样自定义继承自 RecyclerView.ViewHolder
的类,通常可以写为内部类
public class MyRecyclerViewAdapter extends RecyclerView.Adapter<MyRecyclerViewAdapter.MyViewHolder> {// 用于绑定和管理条目布局控件的ViewHolder内部类public static class MyViewHolder extends RecyclerView.ViewHolder {TextView textView;ImageView imageView;public MyViewHolder(@NonNull View itemView){super(itemView);textView = itemView.findViewById(R.id.text_view);imageView = itemView.findViewById(R.id.image_view);}}private final List<Item> itemList; // 数据模型集合public MyRecyclerViewAdapter(List<Item> itemList){ this.itemList = itemList; }// 创建ViewHolder并加载item布局@NonNull@Overridepublic MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());return new Holder(layoutInflater.inflate(R.layout.item, parent, false));}// LayoutInflater 是用于将布局xml文件转换成View对象的工具// inflate方法用于实际转换并将View放入指定的布局对象parent内,false表示不立即放入而是等待RecylcerView处理// 绑定数据到ViewHolder管理的控件@Overridepublic void onBindViewHolder(@NonNull MyViewHolder holder, int position){Item item = itemList.get(position);// 准备数据holder.textView.setText(item.getText());holder.imageView.setImageResource(item.getImage());}
}
RecyclerView recyclerView = findViewById(R.id.recycler_view);LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
recyclerView.setLayoutManager(linearLayoutManager);MyRecyclerViewAdapter adapter = new MyRecyclerViewAdapter(itemList /* 数据源 */);
recyclerView.setAdapter(adapter);
适应性布局
通过重写 getItemViewType 方法获得不同 viewType 标识符以适应性加载不同条目布局
@Override
public int getItemViewType(int position) {Item item = itemList.get(position);return item.getType();// Type 可以是 public static final int 常量
}@NonNull
@Override
public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());View view;if(viewType == TYPE_A) {view = layoutInflater.inflate(R.layout.item_A);} else if(){// ...}return new MyViewHolder(view);
}
DiffUtil
Android 提供的工具类,用于计算两个列表之间的差异,生成增删改和移动的操作序列,且通知 RecyclerView 更新 UI
DiffUtil 比 notifyDataSetChanged 更高效,只刷新差异的部分
1. DiffUtil.Callback 子类
public class MessageDiffCallback extends DiffUtil.Callback {private final List<item> oldList;private final List<item> newList;public MessageDiffCallback(List<item> oldList, List<item> newList){this.oldList = oldList;this.newList = newList;}@Overridepublic int getOldListSize(){ return oldList.size(); }@Overridepublic int getNewListSize(){ return newList.size(); }@Overridepublic boolean areItemsTheSame(int oldItemPosition, int newItemPosition){return oldList.get(oldItemPosition).getId() == newList.get(newItemPosition).getId();} // 根据特征值(这里是id)判断是否为同一条目@Overridepublic boolean areContentsTheSame(int oldItemPosition. int newItemPosition){return oldList.get(oldItemPosition).equals(newList.get(newItemPosition));}
}
2. 计算差异 更新 Adapter
List<ChatMessageItem> oldList = adapter.getMessages();
List<ChatMessageItem> newList = viewModel.getMessages().getValue();DiffUtil.Callback callback = new MessageDiffCallback(oldList, newList);
DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(callback);adapter.setMessages(newList); // 先更新Adapter内部数据
diffResult.dispatchUpdatesTo(adapter); // 更新UI的核心语句
ViewPager2 & BottomNavigationView
滑动切换页面配合底部导航栏
首先在主布局 xml 文件定义 ViewPager2 和 BottomBavigationView,创建 menu 文件并放入若干 item
定义继承 FragmentStateAdapter 类的 ViewPagerAdapter
public class ViewPagerAdapter extends FragmentStateAdapter {public ViewPagerAdapter(FragmentActivity fragmentActivity){ super(fragmentActivity) }@NonNull@Overridepublic Fragment createFragement(int position){switch (position) {case 0:return new FragmentA();case 1:return new FragmentB();// ...default:return new FragmentDefault();}}@Overridepubilc int getItemCount(){ return 2; /* position(max) + 1 */ }
}
在主页面设置 ViewPager2 和 BottomBavigationView 的逻辑
public class MainActivity extends AppCompatActivity {private ViewPager2 viewPager;private BottomNavigationView bottomNavigationView;@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);viewPager = findViewById(R.id.viewPager);bottomNavigationView = findViewById(R.id.bottomNavigationView);// 设置 AdapterviewPager.setAdapter(new ViewPagerAdapter(this));// BottomNavigationView 点击切换页面bottomNavigationView.setOnItemSelectedListener(item -> {switch (item.getItemId()) {case R.id.nav_home:viewPager.setCurrentItem(0, false);return true;case R.id.nav_dashboard:viewPager.setCurrentItem(1, false);return true;case R.id.nav_notifications:viewPager.setCurrentItem(2, false);return true;}return false;});// ViewPager2 页面切换时更新 BottomNavigationView 选中状态viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {@Overridepublic void onPageSelected(int position) {switch (position) {case 0:bottomNavigationView.setSelectedItemId(R.id.nav_home);break;case 1:bottomNavigationView.setSelectedItemId(R.id.nav_dashboard);break;case 2:bottomNavigationView.setSelectedItemId(R.id.nav_notifications);break;}}});}
}
重要方法有:
底部导航栏的 setOnItemSelectedListener
和 setSelectedItemId
,前者用于监听导航栏菜单项点击逻辑,返回 MenuItem 对象;后者设置导航栏当前 item 更新为正常的选中状态
ViewPager2 的 registerOnPageChangeCallback
和 setCurrentItem
,前者用于监听用户滑动或调用 setCurrentItem 的逻辑;后者直接切换到指定索引页面
真正发挥切换页面作用的是 setCurrentItem
ViewBinding
使用方式
-
在 app build.gradle 的 android 配置块加入
buildFeatures {viewBinding true }
-
ViewBinding 会根据每个 xml 布局文件生成对应的绑定类,生成规则为去掉下划线且将首字母大写 + Binding,例如 activity_main.xml 的绑定类为 ActivityMainBinding
-
ViewBinding 绑定类拥有 2 种实例化方式,分别对应活动布局和子布局(如 Fragment 或 View)
// 以 ActivityMainBinding 为例 public class MainActivity extends AppCompatActivity {private ActivityMainBinding binding;@Overrideprotected void onCreate(Bundle savedInstanceState){super.onCreate(savedInstanceState);binding = ActivityMainBinding.inflate(getLayoutInflater());/* 完成的工作有1. 实例化绑定类对象2. 加载 activity_main.xml 布局文件3. 创建布局里所有View的对象所有嵌套的View都能被创建4. 将View对象与绑定类里的成员变量关联起来*/setContentView(binding.getRoot());// 告诉Activity使用绑定类的根布局作为界面根布局} }
// 以 FragmentMainBinding 为例,构造方法和其他重写方法略 public class MainFragment extends Fragment {private ActivityMainBinding binding;@Overridepublic View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container,Bundle savedInstanceState) {binding = FragmentInformationBinding.inflate(inflater, container, false);// 与单参构造方法类似,因为碎片等布局在活动内部,所以要传入父容器实例return binding.getRoot();}@Overridepublic void onDestroyView() {super.onDestroyView();binding = null;} }
-
完成初始化操作后,即可引用绑定类实例的成员变量来直接操作控件
LayoutInflater
用于将 xml 布局加载成 View 对象
1. setContentView
Activity 的 setContentView 在底层会调用 LayoutInflater 加载 xml 文件成 View 对象
@Override
protected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);// 几乎等同于View view = getLayoutInflater().inflater(R.layout.activity_main, null);setContentView(view);
2. ViewBinding 初始化
ViewBinding 使用 LayoutInflater 来加载布局且返回绑定类的实例
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
3. RecyclerView Adapter onCreateViewHolder
@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType){View view = LayoutInflater.from(parent.getContent()).inflate(R.layout.item_layout, parent, false);return new MyViewHolder(view);
} // item 需要随用户滑动动态加载显示
4. 动态引入布局
LinearLayout container = findViewById(R.id.main);
View butotnView = getLayoutInflater().inflate(R.layout.button_layout, container, false);
container.addView(buttonView);
希望动态加载的控件不一定是额外的 xml layout 文件,只要是存在的 ViewGroup 父容器即可,布局文件中提前写空的 layout 可以明确的确定控件位置,方便做 UI 排版
数据
单例模式
设置用户自己的个人信息类
public class Instance {private static final Instance instance = new Instance();private Data data;private Instance(){} // 私有化构造方法,保证全局只有instance唯一实例public static Instance getInstance(){ return instance; }public Data getData(){ return data; }public void setData(Data data){ this.data = data; }
}
// 饿汉式,在类初始化后就加载实例,而不是在getInstance方法被调用时
ViewModel
存储消息集合
ViewModel 类似单例模式,不随活动碎片销毁而回收,但在绑定宿主调用 finish 方法或不再加入返回栈后回收
LiveData 是生命周期感知型数据容器,可以让实例 Observe 观察 LiveData 的数据变化,执行更新逻辑
MutableLiveData 继承自 LiveData,是可写的,提供了主线程进行的 setValue 方法和回调的 postValue 方法
一个好的 ViewModel 继承类应该有私有的 MutableLiveData,提供一系列操作容器的方法,最终调用 setValue 或 postValue 方法来回调 Observe 的更新逻辑
其他观察 ViewModel 的实例应该只有 LiveData 只读容器的引用
SharedPreferences
登录缓存
SharedPreferences 用于保存一些简单的键值对数据(比如设置、用户偏好、登录状态等)
// Activity/Fragment 中获取 SharedPreferences
String document_name = "";
SharedPreferences sp = getSharedPreferences(document_name, MODE_PRIVATE);
// MODE_PRIVATE 表示文件私有
// 全局获取 通过上下文实例 Context 调用 getSharedPreferences 获得SharedPreferences.Editor editor = sp.edit();editor.putString("username", "Young");
editor.putInt("age", 20);
editor.putBoolean("isLogin", true);editor.apply();
// apply 异步提交保存 commit 同步提交保存 返回 boolean 表示是否成功// 删除调用 remove 方法 参数传递 put 的键名 最后也要保存String name = sp.getString("username", "defaultName");
// 读取数据不需要 Editor 实例 第二参数是键不存在时的默认返回值
SQLiteOpenHelper & Cursor
本地存储消息
继承自 SQLiteOpenHelper 的自定义类可以用于创建数据库和提供可读写的数据库对象 SQLiteDatabase
基本使用
public class DatabaseHelper extends SQLiteOpenHelper {private static final String DB_NAME = "database.db";private static final int DB_VERSION = 1;public MyDatabaseHelper(Context context) {super(context, DB_NAME, null, DB_VERSION);}@Overridepublic void onCreate(SQLiteDatabase db) {String createTable = "CREATE TABLE chair (" +"id INTEGER PRIMARY KEY AUTOINCREMENT, " +"name TEXT, " +"age INTEGER)";db.execSQL(createTable);}@Overridepublic void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {db.execSQL("DROP TABLE IF EXISTS chair");// DROP TABLE IF EXISTS chair 库中存在 chair 表则删除 否则什么都不做onCreate(db);}
}
CURD
DatabaseHelper dbHelper = new DatabaseHelper(context);
SQLiteDatabase db = dbHelper.getWritableDatabase(); // 获取可写流// 增
ContentValues values = new ContentValues();
values.put(key1, value1);
values.put(key2, value2);
db.insert(table, null, values);// 改
ContentValues updateValues = new ContentValues();
updateValues.put(key, value);
db.update(table, updateValues, "name=?", new String[]{"4Forsee"});/*
int update(String table, // 表名ContentValues values, // 要更新的列及新值String whereClause, // WHERE 条件(可以用 ? 占位)String[] whereArgs // 占位符对应的值
)
*/// 删
db.delete(table, "name=?", new String[]{"4Forsee"});
Cursor 查
Cursor 是 SQLite 查询结果的游标对象,指向一行数据,可以按列读取数据
SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor cursor = db.rawQuery("SELECT * FROM chair WHERE age > ?", new String[]{"24"});
// rawQuery 原生使用SQL语句查询 query方法参数繁杂if (cursor.moveToFirst()) { // 移到第一行do {String name = cursor.getString(cursor.getColumnIndexOrThrow("name"));Log.d("TAG", "name=" + name);} while (cursor.moveToNext());
}
// cursor.close();
db.close();
活动间通信
ActivityResult
用于打开相册
Activity1 期望启动 Activity2 且回调 Activity2 的返回值在 < 30 API 前要配对使用 startActivityForResult(Intent intent, int requestCode)
和 onActivityResult(int requestCode, int resultCode, Intent data)
,前者调用后者重写,requestCode 是唯一整数,用于在回调区分来源
当启动活动 Activity2 调用 setResult(int resultCode, Intent intent)
且 finish 结束后,回调 Activity1 的 onActivityResult 方法,在方法中根据不同的 requestCode 请求码和 resultCode 返回码设计具体逻辑
这样写有 requestCode,resultCode 和 Intent 的解析码都要处理,代码耦合且不直观,所以有 ActivityResult 的出现,内部管理 requestCode,使用方法如下:
-
private ActivityResultLauncher<Intent> launcher;
在 Activity 中设置 ActivityResultLauncher 成员变量 -
在 Activity 的 onCreate 方法中注册回调
launcher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),result -> {if (result.getResultCode() == RESULT_OK) {Intent data = result.getData();if (data != null) {String value = data.getStringExtra("result_key");// 处理返回的数据}}});
-
Intent intent = new Intent(this, Activity2.class); launcher.launch(intent);
-
Activity2 的回调逻辑和原来一样,都是调用 setResult 方法且结束后回调
注册回调结构
launcher = registerForActivityResult(ActivityResultContract<I, O> Contract, ActivityResultCallback<O> Callback);
ActivityResultContract 是安卓已经写好的任务合同,定义了发送接收数据的类型和任务执行的具体细节
ActivityResultCallback 是回调逻辑,result 的数据类型相当于 Contract 的接收数据类型
未完成
- 点击按钮显示聊天室所有成员
- 后端代码迁移到新服务器
- 尝试开通SMTP做邮箱验证码登录和找回密码
- 让下线的用户也能接收到消息
- 发送多媒体文件
- 实时更新用户信息
- 长按消息出现删除按钮
服务器搭建
-
在阿里云平台的 轻量应用服务器 或 云服务器 ECS 购买服务器,也可以尝试拿到试用服务器,通常是 1 个月
-
创建系统时选择 Linux Ubuntu 且安装 Docker,不同的 Linux 发行版本有不同的软件包管理器(yum 或 apt)
-
创建成功后直接进入或设置密码进入服务器内部,在命令行输入下列内容:
sudo apt update# 安装 Docker sudo apt install -y docker.io sudo systemctl enable --now docker sudo docker --version# 安装 Node.js sudo apt install -y nodejs npm# 安装 PM2 sudo npm install -g pm2 pm2 --version# 安装 Nano 编辑器 sudo apt install -y nano nano --version# 拉取v8.0的 MySQL 镜像 docker pull mysql:8.0# 运行 MySQL 容器 docker run --name 容器名称 -e MYSQL_ROOT_PASSWORD=密码 -p 3306:3306 -d mysql:8.0
-
成功执行后,服务器就拥有简易的后端环境,可以继续安装 express,ws,mysql2,multer 等模块
表和存储路径
+----------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+----------+--------------+------+-----+---------+----------------+
| id | int | NO | PRI | NULL | auto_increment |
| username | varchar(50) | NO | UNI | NULL | |
| password | varchar(100) | NO | | NULL | |
+----------+--------------+------+-----+---------+----------------+ (users)+------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+--------------+------+-----+---------+----------------+
| id | int | NO | PRI | NULL | auto_increment |
| avatar | varchar(255) | YES | | NULL | |
| nickname | varchar(50) | YES | | | |
| bio | text | YES | | NULL | |
+------------+--------------+------+-----+---------+----------------+ (information)
服务器头像存储路径 /home/admin/public/avatar/
默认头像 0.png
本地头像存储路径 /data/data/com.example.chatroom/files/avatar
Nodejs 后端代码
const express = require('express');
const mysql = require('mysql2/promise');
const multer = require('multer');
const fs = require('fs');
const path = require('path');const http = require('http');
const { WebSocketServer, WebSocket } = require('ws');const app = express();
app.use(express.json());const server = http.createServer(app);
const wss = new WebSocketServer({ server });// MySQL 配置
const dbConfig = {host: 'localhost',port: 3306,user: 'root',password: '123456',database: 'chatdb'
};// 头像存储目录
const avatarDir = '/home/admin/public/avatar/';
if (!fs.existsSync(avatarDir)) {fs.mkdirSync(avatarDir, { recursive: true });
}// Multer 配置:接收 multipart/form-data 的 avatar 文件
const storage = multer.diskStorage({destination: (req, file, cb) => cb(null, avatarDir),filename: (req, file, cb) => {const ext = path.extname(file.originalname);const name = Date.now() + '-' + Math.round(Math.random() * 1e9) + ext;cb(null, name);}
});
const upload = multer({ storage });const getActiveClientCount = () => {let count = 0;wss.clients.forEach(client => {if (client.readyState === WebSocket.OPEN) {count++;}});return count;
};wss.on('connection', (ws) => {console.log('有客户端连接,当前客户端数量:', getActiveClientCount());ws.on('message', (msg) => {let obj;try {obj = JSON.parse(msg);} catch (e) {return;}// 广播给所有客户端wss.clients.forEach(client => {console.log('发送成功');if (client.readyState === ws.OPEN) {client.send(JSON.stringify({...obj,isSelf: client === ws ? 1 : 0}));}});});ws.on('close', (code, reason) => {console.log('客户端断开连接,当前客户端数量:', getActiveClientCount());});});/*** 上传头像并更新数据库* POST /upload* fields:* - id: 用户 ID (text)* - avatar: 头像文件 (file)*/
app.post('/upload', upload.single('avatar'), async (req, res) => {const userId = req.body.id;if (!userId) {return res.status(400).json({ success: false, message: '缺少用户 ID' });}if (!req.file) {return res.status(400).json({ success: false, message: '没有上传文件' });}const absolutePath = path.join(avatarDir, req.file.filename);let connection;try {connection = await mysql.createConnection(dbConfig);const [result] = await connection.execute('UPDATE information SET avatar = ? WHERE id = ?',[absolutePath, userId]);if (result.affectedRows === 0) {return res.json({ success: false, message: '未找到该用户信息,更新失败' });}return res.json({success: true,message: '头像上传并更新成功',avatarPath: absolutePath});} catch (err) {console.error(err);return res.status(500).json({ success: false, message: '服务器内部错误' });} finally {if (connection) await connection.end();}
});/*** 更新用户个人信息(不含头像)* POST /update* JSON body:* { id, nickname, bio }*/
app.post('/update', async (req, res) => {const { id, nickname, bio } = req.body;if (!id || nickname === undefined || bio === undefined) {return res.status(400).json({ success: false, message: '参数不完整' });}let connection;try {connection = await mysql.createConnection(dbConfig);const [result] = await connection.execute('UPDATE information SET nickname = ?, bio = ? WHERE id = ?',[nickname, bio, id]);if (result.affectedRows === 0) {return res.json({ success: false, message: '未找到该用户信息,更新失败' });}return res.json({ success: true, message: '信息更新成功' });} catch (err) {console.error(err);return res.status(500).json({ success: false, message: '服务器错误' });} finally {if (connection) await connection.end();}
});/*** 登录接口* POST /login* JSON body:* { username, password }*/
app.post('/login', async (req, res) => {const { username, password } = req.body;if (!username || !password) {return res.status(400).json({ success: false, message: '用户名或密码不能为空' });}let connection;try {connection = await mysql.createConnection(dbConfig);const [rows] = await connection.execute('SELECT * FROM users WHERE username = ?',[username]);if (rows.length === 0) {const [insertResult] = await connection.execute('INSERT INTO users (username, password) VALUES (?, ?)',[username, password]);await connection.execute('INSERT INTO information (id, avatar, nickname, bio) VALUES (?, ?, ?, ?)',[insertResult.insertId, '/home/admin/public/avatar/0.png', username, '']);return res.json({success: true,message: '新用户已创建并登录',user: {id: insertResult.insertId,username,avatar: '/home/admin/public/avatar/0.png',nickname: username,bio: ''}});} else {if (rows[0].password !== password) {return res.status(401).json({ success: false, message: '密码错误' });}const userId = rows[0].id;const [infoRows] = await connection.execute('SELECT avatar, nickname, bio FROM information WHERE id = ?',[userId]);return res.json({success: true,message: '登录成功',user: {id: userId,username,avatar: infoRows[0]?.avatar || '',nickname: infoRows[0]?.nickname || '',bio: infoRows[0]?.bio || ''}});}} catch (err) {console.error(err);return res.status(500).json({ success: false, message: '服务器错误' });} finally {if (connection) await connection.end();}
});/*** 获取所有头像* GET /avatars* 返回:{ success: true, data: [avatar1, avatar2, ...] }*/
app.get('/avatars', async (req, res) => {try {const connection = await mysql.createConnection(dbConfig);const [results] = await connection.execute('SELECT avatar FROM information');await connection.end();const avatars = results.map(row => row.avatar);res.json({ success: true, data: avatars });} catch (err) {return res.status(500).json({ success: false, message: '服务器错误' });}
});server.listen(3000, '0.0.0.0', () => {console.log('服务器启动,监听端口 3000');
});