前言
勿忘IT苦人多,本文会使用JAVA在Android Studio上撰写,Server使用JavaScript在VScode上撰写
需要一些android studio的基础,相关环境方面的问题就不赘述,让我们开始吧
前置作业:
1.安装Java
2.安装Android Studio
3.安装Node.js
4.安装VScode
5.安装Nox,买Android手机(X
1.建立专案
创建一个专案(new project),选择empty Activity -> Next -> Finish,这样就创建了一个空白的专案。
在左边打开Gradle Scripts这个资料夹,选取 build.gradle (Module: app)
我们要在这边引入我们想要使用的功能,在dependencies{}里面新增两行代码
implementation 'androidx.recyclerview:recyclerview:1.0.0'implementation 'com.squareup.okhttp3:okhttp:3.10.0'
然后使用Java8的环境
在android{}里面新增以下代码
compileOptions { sourceCompatibility '1.8' targetCompatibility '1.8' }
这样就修改完成了
文件上方会有一串提示文字,点选" Sync Now ",也可以点选绿色小槌子rebuild
再来去app -> manifests -> AndroidManifest.xml 增加权限
<uses-permission android:name="android.permission.INTERNET" /><uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
INTERNET就是网路,READ_EXTERNAL_STORAGE是我们要传递图片使用的,去读取档案的权限
在"application"内部新增一串代码
android:usesCleartextTraffic="true"
这是用来支持app可以接受未加密的请求
接下来就是使用者界面了
2.使用者界面
主画面,输入自己的名字进入聊天室
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <EditText android:id="@+id/editText" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="10dp" android:layout_marginStart="20dp" android:layout_marginTop="20dp" android:layout_marginEnd="20dp" android:layout_marginBottom="20dp" android:background="@drawable/edit_text_design" android:hint="输入您的名字" android:padding="10dp" android:textSize="16sp" tools:ignore="MissingConstraints" /> <Button android:id="@+id/enterBtn" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@id/editText" android:layout_marginStart="24dp" android:layout_marginTop="24dp" android:layout_marginEnd="24dp" android:background="@color/colorPrimaryDark" android:text="进入聊天" android:textColor="#ffffff" /></RelativeLayout>
文字区域设计 放在drawable里面
<?xml version="1.0" encoding="utf-8"?><shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> <solid android:color="#cccccc" /> <stroke android:color="#808080" android:width="1dp" /> <corners android:radius="5dp"/></shape>
进入MainActivity.java
这里主要是进入房间这个按钮我们希望可以把"名字"这个资讯传入聊天室里面
创建一个Empty Activity 命名为 ChatActivity
package com.example.test0513;import androidx.appcompat.app.AppCompatActivity;import androidx.core.app.ActivityCompat;import android.Manifest;import android.content.Intent;import android.content.pm.PackageManager;import android.os.Bundle;import android.widget.EditText;public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); if(ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED){ ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 10); } EditText editText = findViewById(R.id.editText); findViewById(R.id.enterBtn) .setOnClickListener(v -> { Intent intent = new Intent(this,ChatActivity.class); intent.putExtra("name",editText.getText().toString()); startActivity(intent); }); }}
在创建完ChatActivity之后会出现一个activity_chat.xml
建构聊天画面
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".ChatActivity"> <androidx.recyclerview.widget.RecyclerView android:layout_width="match_parent" android:layout_height="match_parent" android:layout_above="@id/messageEdit" android:id="@+id/recyclerView"/> <EditText android:id="@+id/messageEdit" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_marginStart="16dp" android:layout_marginBottom="16dp" android:layout_toStartOf="@id/sendBtn" android:background="@drawable/edit_text_design" android:hint="Message..." android:padding="8dp" android:textSize="16sp" /> <TextView android:id="@+id/sendBtn" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_alignParentBottom="true" android:layout_marginBottom="16dp" android:padding="10dp" android:text="Send" android:visibility="invisible" android:textColor="@color/colorPrimary" /> <ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_image_black_24dp" android:tint="@color/colorPrimary" android:padding="8dp" android:layout_alignParentBottom="true" android:layout_toEndOf="@id/messageEdit" android:layout_marginBottom="16dp" android:id="@+id/pickImgBtn" android:layout_alignParentEnd="true" /></RelativeLayout>
接下来都是新增Layout,在Layout资料夹点右键 -> New -> Layout Resource File
新增四个Layout 分别叫
item_received_message.xml
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" tools:context=".MainActivity"> <TextView android:id="@+id/nameTxt" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="4dp" android:text="Name" android:textStyle="bold" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@+id/nameTxt" android:background="@color/colorPrimary" android:padding="8dp" android:textColor="#ffffff" android:layout_marginEnd="64dp" android:layout_marginBottom="4dp" android:textSize="16sp" android:id="@+id/receivedTxt" android:text="Hello my name is yancehn"/></RelativeLayout>
item_received_photo.xml
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" tools:context=".MainActivity"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Name" android:id="@+id/nameTxt" android:layout_margin="4dp" android:textStyle="bold"/> <androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="64dp" android:layout_marginStart="4dp" android:layout_marginBottom="4dp" android:layout_below="@id/nameTxt" android:id="@+id/imageView" android:src="@drawable/ic_image_black_24dp"/></RelativeLayout>
item_send_message.xml
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" tools:context=".MainActivity"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_marginTop="4dp" android:layout_marginBottom="4dp" android:layout_marginStart="64dp" android:padding="8dp" android:textSize="16sp" android:background="#cccccc" android:text="Hello" android:id="@+id/sentTxt"/></RelativeLayout>
item_sent_image.xml
<?xml version="1.0" encoding="utf-8"?><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" tools:context=".MainActivity"> <androidx.appcompat.widget.AppCompatImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_marginTop="4dp" android:layout_marginBottom="4dp" android:layout_marginStart="64dp" android:padding="8dp" android:id="@+id/imageView" android:src="@drawable/ic_image_black_24dp"/></RelativeLayout>
这样基本的使用者介面就完成了
3.WebSocket客户端连线
在完成使用者介面之后,接下来我们要来跟伺服器建立连线,我们会使用okhttp3,他是一个网路请求的开源专案
伺服器位址我们可以先用"ws://echo.websocket.org" 这个位址会回传你给他的内容(你跟他说hello 他就跟你说hello),可以用来检视自己发的内容是不是正确的。
打开ChatActivity.java
package com.example.test0513;import android.os.Bundle;import android.view.View;import android.widget.EditText;import androidx.appcompat.app.AppCompatActivity;import androidx.recyclerview.widget.RecyclerView;import okhttp3.OkHttpClient;import okhttp3.Request;import okhttp3.WebSocket;import okhttp3.WebSocketListener;public class ChatActivity extends AppCompatActivity{ private String name; private String SERVER_PATH = "ws://echo.websocket.org"; private WebSocket webSocket; private EditText messageEdit; private View sendBtn, pickImgBtn; private RecyclerView recyclerView; private MessageAdapter messageAdapter;//先别管我 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_chat); name = getIntent().getStringExtra("name"); initiateSocketConnection(); } private void initiateSocketConnection() { OkHttpClient client = new OkHttpClient(); Request request = new Request.Builder().url(SERVER_PATH).build(); webSocket = client.newWebSocket(request, new SocketListener()); } private class SocketListener extends WebSocketListener{ }}
点在WebSocketListener上按Ctrl+O ,override两个方法,onOpen是当连接成功的时候会调用的方法
onMessage(websocket, string)是当收到string内容的时候会执行的方法,点选这两个方法按下OK
private class SocketListener extends WebSocketListener{ @Override public void onOpen(WebSocket webSocket, Response response) { super.onOpen(webSocket, response); } @Override public void onMessage(WebSocket webSocket, String text) { super.onMessage(webSocket, text); } }
现在你的SoketListener会长这样,然后我们希望在连接成功的时候我们可以获得一条讯息
所以在onOpen里面
runOnUiThread(() -> { Toast.makeText(ChatActivity.this, "Socket Connection Successful", Toast.LENGTH_SHORT).show(); initializeView(); });
顺带一提,这里的写法没有Java8是不支援的,然后如果连接成功我们希望初始化界面,新增一个initializeView方法
在那上面按下 Alt+Enter + Enter新增方法(在ChatActivity新增)
4.输入内容
在initializeView这个方法里面我们先把该找的Id找好让他们找到归宿
然后我们希望我们的输入介面是这样的,如果使用者没输入内容的时候我们希望按钮变成发送图片,如果使用者输入文字按钮就变成发送按钮所以在这边新增一个addTextChangedListener,他会在每次使用者打字或是删减时被触发
private void initializeView() { messageEdit = findViewById(R.id.messageEdit); sendBtn = findViewById(R.id.sendBtn); pickImgBtn = findViewById(R.id.pickImgBtn); recyclerView = findViewById(R.id.recyclerView); messageEdit.addTextChangedListener(this); messageAdapter = new MessageAdapter(getLayoutInflater());//先别管我 recyclerView.setAdapter(messageAdapter);//先别管我 recyclerView.setLayoutManager(new LinearLayoutManager(this));//先别管我 }
在this上面按Alt+Enter,选择让ChatActivity实作TextWatcher,按下OK会实作三个方法,在afterTextChanged这里去判断是要显示哪种Button(afterTextChanged是三个的其中一个)
@Override public void afterTextChanged(Editable s) { String string = s.toString().trim(); if(string.isEmpty()){ resetMessageEdit(); }else{ sendBtn.setVisibility(View.VISIBLE); pickImgBtn.setVisibility(View.INVISIBLE); } } private void resetMessageEdit() { messageEdit.removeTextChangedListener(this); messageEdit.setText(""); sendBtn.setVisibility(View.INVISIBLE); pickImgBtn.setVisibility(View.VISIBLE); messageEdit.addTextChangedListener(this); }
5.发送按钮
再来我们要赋予按钮功能,回到initializeView这个方法
private void initializeView() { ...略 sendBtn.setOnClickListener(v -> { JSONObject jsonObject = new JSONObject(); try { jsonObject.put("name", name); jsonObject.put("message", messageEdit.getText().toString()); webSocket.send(jsonObject.toString()); jsonObject.put("isSent", true); messageAdapter.addItem(jsonObject);//先别管我 resetMessageEdit(); } catch (JSONException e) { e.printStackTrace(); } });}
在这里,如果使用者按了发送扭,我们就把他的名字还有他输入的内容发送到伺服器,并把输入的内容清空
接下来就是获取图片的按钮
private void initializeView() { ...略 pickImgBtn.setOnClickListener(v -> { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("image/*"); startActivityForResult(Intent.createChooser(intent, "Pick image"), IMAGE_REQUEST_ID); }); } @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == IMAGE_REQUEST_ID && resultCode == RESULT_OK) { try { InputStream is = getContentResolver().openInputStream(data.getData()); Bitmap image = BitmapFactory.decodeStream(is); sendImage(image); } catch (FileNotFoundException | JSONException e) { e.printStackTrace(); } } } private void sendImage(Bitmap image) throws JSONException { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); image.compress(Bitmap.CompressFormat.JPEG, 50, outputStream); String base64String = android.util.Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT); JSONObject jsonObject = new JSONObject(); jsonObject.put("name", name); jsonObject.put("image", base64String); webSocket.send(jsonObject.toString()); jsonObject.put("isSent", true); messageAdapter.addItem(jsonObject);//先别管我 }
这里的IMAGE_REQUEST_ID可以设置随意的数字,当使用者点选图片按扭的时候,会跳转到档案选择画面,
这段代码会把使用者选取的点阵图片压缩并转成Base64编码发送,这里我们是用android的Base64类别
6.接收讯息
回到SocketListener的onMessage这里我们谈过,是当接收到消息的时候会执行的方法
@Override public void onMessage(okhttp3.WebSocket webSocket, String text) { super.onMessage(webSocket, text); runOnUiThread(() -> { try { JSONObject jsonObject = new JSONObject(text); jsonObject.put("isSent", false); messageAdapter.addItem(jsonObject);//先别管我 } catch (JSONException e) { e.printStackTrace(); } }); }
在这里我们把收到的字串内容标记 "isSent" 为false,用来区别这个内容不是自己发出的
7.聊天内容显示
新增一个新的Java class,命名为MessageAdapter,并继承RecyclerView.Adapter,按下Alt+Enter
实作所有方法,我们先前创作了四个layout分别是自己发送的文字内容,自己发送的图片,与别人发的文字和图片
根据不同的情况在使用者介面要放置不同的layout
package com.example.test0513;import android.view.LayoutInflater;import android.view.ViewGroup;import androidx.annotation.NonNull;import androidx.recyclerview.widget.RecyclerView;import org.json.JSONObject;import java.util.ArrayList;import java.util.List;public class MessageAdapter extends RecyclerView.Adapter { private final int TYPE_MESSAGE_SENT = 0; private final int TYPE_MESSAGE_RECEIVED = 1; private final int TYPE_IMAGE_SENT = 2; private final int TYPE_IMAGE_RECEIVED = 3; private LayoutInflater inflater; private List<JSONObject> messages = new ArrayList<>(); public MessageAdapter(LayoutInflater inflater){ this.inflater = inflater; } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { return null; } @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { } @Override public int getItemCount() { return 0; }}
所以我们需要用到LayoutInflater,创建一个List来放置所有的聊天内容,
接下来我们要创建四个ViewHolder去控制四个layout的内容
private class SentMessageHolder extends RecyclerView.ViewHolder { TextView messageTxt; public SentMessageHolder(@NonNull View itemView) { super(itemView); messageTxt = itemView.findViewById(R.id.sentTxt); } } private class SentImageHolder extends RecyclerView.ViewHolder{ ImageView imageView; public SentImageHolder(@NonNull View itemView) { super(itemView); imageView = itemView.findViewById(R.id.imageView); } } private class ReceivedMessageHolder extends RecyclerView.ViewHolder{ TextView nameTxt, messageTxt; public ReceivedMessageHolder(@NonNull View itemView) { super(itemView); nameTxt = itemView.findViewById(R.id.nameTxt); messageTxt = itemView.findViewById(R.id.receivedTxt); } } private class ReceivedImageHolder extends RecyclerView.ViewHolder{ ImageView imageView; TextView nameTxt; public ReceivedImageHolder(@NonNull View itemView) { super(itemView); imageView = itemView.findViewById(R.id.imageView); nameTxt = itemView.findViewById(R.id.nameTxt); } }
然后创建一个getItemViewType方法去判断是哪一种情况要用哪一个Layout
如果isSent是true就是自己发送的,如果里面有message就是文字内容
@Override public int getItemViewType(int position) { JSONObject message = messages.get(position); try { if(message.getBoolean("isSent")){ if(message.has("message")){ return TYPE_MESSAGE_SENT; }else{ return TYPE_IMAGE_SENT; } }else{ if(message.has("message")){ return TYPE_MESSAGE_RECEIVED; }else{ return TYPE_IMAGE_RECEIVED; } } } catch (JSONException e) { e.printStackTrace(); } return -1; }
再来修改getItemCount这个方法的内容为
@Override public int getItemCount() { return messages.size(); }
来获取有几条讯息
之后让我们看到onCreateViewHolder这个方法,我们要在这里套入要用的Layout
并把这些Layout内容放到我们先前做的ViewHolder里面
@NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view; switch(viewType){ case TYPE_MESSAGE_SENT: view = inflater.inflate(R.layout.item_send_message, parent, false); return new SentMessageHolder(view); case TYPE_MESSAGE_RECEIVED: view = inflater.inflate(R.layout.item_received_message, parent, false); return new ReceivedMessageHolder(view); case TYPE_IMAGE_SENT: view = inflater.inflate(R.layout.item_sent_image, parent, false); return new SentImageHolder(view); case TYPE_IMAGE_RECEIVED: view = inflater.inflate(R.layout.item_received_photo, parent, false); return new ReceivedImageHolder(view); } return null; }
最后就是在onBindViewHolder放入我们要这些Layout显示的内容
@Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { JSONObject message = messages.get(position); try { if(message.getBoolean("isSent")){ if(message.has("message")){ SentMessageHolder messageHolder = (SentMessageHolder) holder; messageHolder.messageTxt.setText(message.getString("message")); }else{ SentImageHolder imageHolder = (SentImageHolder) holder; Bitmap bitmap = getBitmapFromString(message.getString("image")); imageHolder.imageView.setImageBitmap(bitmap); } }else{ if(message.has("message")){ ReceivedMessageHolder messageHolder = (ReceivedMessageHolder) holder; messageHolder.nameTxt.setText(message.getString("name")); messageHolder.messageTxt.setText(message.getString("message")); }else{ ReceivedImageHolder imageHolder = (ReceivedImageHolder) holder; imageHolder.nameTxt.setText(message.getString("name")); Bitmap bitmap = getBitmapFromString(message.getString("image")); imageHolder.imageView.setImageBitmap(bitmap); } } } catch (JSONException e) { e.printStackTrace(); } } private Bitmap getBitmapFromString(String image) { byte[] bytes = Base64.decode(image, Base64.DEFAULT); return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); }
我们再新增一个方法,在有新的聊天内容的时候调用这个方法,把内容放到我们的messages里面
并告知Adapter内部的内容改变了
public void addItem(JSONObject jsonObject){ messages.add(jsonObject); notifyDataSetChanged(); }
这样客户端的部分就完成了,开起来做测试
现在是输入甚么文字就会收到甚么文字,图片也是一样
Server
在搜寻列执行cmd -> npm install websocket
并打上ipconfig/all去查看自己的ip 把SERVER_PATH的内容换成
ws://自己的IP:3000
再把下面的代码放在VScode上运行下
就可以多人在同一个聊天室进行聊天了
const SocketServer = require('websocket').server;const http = require('http');const port = 3000;const server = http.createServer((req, res) => {});server.listen(port, () => { console.log(`Listening on port ${port}...`);});wsServer = new SocketServer({httpServer:server});const connections = [];wsServer.on('request', (req) => { const connection = req.accept(); console.log('new connection'); connections.push(connection); connection.on('message', (mes) => { connections.forEach(element => { if(element != connection) element.sendUTF(mes.utf8Data); }); }); connection.on('close', (resCode, des) => { console.log('connection closed'); connections.splice(connections.indexOf(connection), 1); });});