1.聊天内容表情和文字混合消息
对于服务器来说,表情+文字的聊天消息其实也是纯文本格式,所以这部分的难点只是在客户端。
那对于这种聊天内容,有哪些可能的实现方案呢?
1.1.XML - like 格式
一种可能的格式是类似 XML 的结构。例如,消息内容可能被包装成如下形式
<message>
<text>这是一段文字</text>
<emoji code="E001"/>
</message>
1.2.自定义分隔符格式
使用自定义分隔符来区分文字和表情。关于分隔符号,最好选择不常见的,键盘无法直接输入的,例如下面的内容
₰₰-这是一段文字₰₰+₰₰-另一段文字₰₰+⚜⚜-表情代码1⚜⚜+⚜⚜-表情代码2⚜⚜+
1.3.简单标签
与其费尽心思选择那些不常见的分隔符,倒不如大大方方把分隔符开放出来。这一点,微信做得非常简单。
如果在微信的消息发送框输入官方的表情包,然后再复制到qq软件,就会发些,复制出来的表情变成 [微笑][爱心][转圈] 这种文本格式了。
试着把 “111[微笑]222[爱心][转圈]333”这样的文本内容在微信里进行发送,可以看到显示出正确的文本及表情。
因此,可以推断出,微信采用标签来定义表情。
使用正则提取文字+表情标签
对于“111[微笑]222[爱心][转圈]333”,程序需要按照顺序提取出文字111,表情微笑,文字222,表情爱心,表情转圈,文字333这种列表。这个也不是很难,写个正则就可以提取出来,代码如下:
public static List<ContentElemNode> parseMessage(String message) {
List<ContentElemNode> resultList = new ArrayList<>();
// 111[好色]222[惊讶]
// 111
// [微笑][微笑]
Pattern pattern = Pattern.compile("([^\\[]+)(?=\\[)|\\[([^]]+)]|([^\\[]+)", Pattern.DOTALL);
Matcher matcher = pattern.matcher(message);
while (matcher.find()) {
if (matcher.group(1) != null) {
resultList.add(new TextElemNode(matcher.group(1)));
} else if (matcher.group(2) != null) {
resultList.add(new EmojiElemNode(getEmojiUrl(matcher.group(2))));
} else if (matcher.group(3) != null) {
resultList.add(new TextElemNode(matcher.group(3)));
}
}
return resultList;
}
这里定义ContentElemNode作为节点内容的抽象,有表情节点,以及文字节点,不同节点的显示规则不同,具体代码如下:
import javafx.scene.Node;
public interface ContentElemNode {
Node toUi();
}
import javafx.scene.Node;
import javafx.scene.image.ImageView;
public class EmojiElemNode implements ContentElemNode {
private String url;
public EmojiElemNode(String url) {
this.url = url;
}
@Override
public Node toUi() {
ImageView imageView = new ImageView(url);
imageView.setFitWidth(30);
imageView.setFitHeight(30);
imageView.setStyle("-fx-background-color: transparent;" +
"-fx-border-color: transparent;" +
"-fx-padding: 0;");
return imageView;
}
}
import javafx.scene.Node;
import javafx.scene.text.Text;
public class TextElemNode implements ContentElemNode {
private String content;
public TextElemNode(String content) {
this.content = content;
}
@Override
public Node toUi() {
Text label = new Text (content);
label.setMouseTransparent(false);
label.setFocusTraversable(true);
return label;
}
}
2.服务器导入表情资源
表情包资源可以放在服务器文件存储桶,也可以放在客户端本地资源。建议放在服务器,因为这便于动态管理表情资源,也减少客户端的文件大小。
本文使用minio部署本地文件服务,亦可选择阿里云、腾讯云、亚马逊等云存储服务。测试开发阶段当然选择免费的minio啦。
我们可以写一个脚本,一键把指定目录的表情资源导入到minio服务。
基本算法:
读取指定目录,获取所有的表情资源文件,并计算每个文件的md5;
读取数据库,若数据库不存在指定表情(根据标签名称),则上传到minio,将相关信息存入到数据库;
若数据库已存在指定标签的文件,则比较两者的md5,则md5不同,则进行替换(此步骤的目标是为了实现动态修改表情文件,例如从png改为gif)。
代码如下:
@Component
public class EmojiScript {
@Autowired
S3Client s3Client;
@Autowired
OssResourceDao ossResourceDao;
@Autowired
OssService ossService;
public void updateEmojiResource() {
Map<String, OssResource> existed = ossResourceDao.selectList(new LambdaQueryWrapper<>()).stream().collect(Collectors.toMap(OssResource::getLabel, Function.identity()));
String folderUrl = "D:\\java_projects\\im\\im\\im-chat\\src\\test\\resources\\emoji";
Map<String, FileVo> newFiles = listFiles(folderUrl);
newFiles.forEach((key, value) -> {
// 新增
if (!existed.containsKey(key)) {
String id = IdFactory.nextUUId();
try {
doUpload(value.file, id);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
} else {
OssResource prev = existed.get(key);
// md5不一致,表情内容有修改,替换
if (!value.md5.equals(prev.getMd5())) {
try {
doReplace(value, prev);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
});
}
private Map<String, FileVo> listFiles(String directoryPath) {
Map<String, FileVo> files = new HashMap<>();
try {
Resource resource = new FileSystemResource(directoryPath);
File directory = resource.getFile();
if (directory.exists()) {
File[] fileList = directory.listFiles();
if (fileList != null) {
for (File file : fileList) {
FileVo fileVo = new FileVo(file);
files.put(fileVo.label, fileVo);
}
}
}
} catch (Exception e) {
LoggerUtil.error("", e);
}
return files;
}
static class FileVo {
File file;
String md5;
String label;
public FileVo(File file) throws Exception {
this.file = file;
this.md5 = FileMD5Calculator.calculateMD5(file);
this.label = file.getName().substring(0, file.getName().lastIndexOf("."));
}
}
private void doUpload(File file, String id) throws IOException {
try {
String suffix = file.getName().substring(file.getName().lastIndexOf(".") + 1);
String catalog = FileTypes.EMOJI.getPath() + "/";
String objectName = String.format("%s%s", id, "." + suffix);
String fullPath = catalog + objectName;
s3Client.upload(new FileInputStream(file), fullPath, ossService.getContentType(suffix));
OssResource ossResource = new OssResource();
ossResource.setUrl(fullPath);
ossResource.setOriginalName(file.getName());
ossResource.setType(FileTypes.EMOJI.getPath());
ossResource.setLabel(file.getName().substring(0, file.getName().lastIndexOf(".")));
ossResource.setCreatedDate(new Date());
ossResource.setMd5(FileMD5Calculator.calculateMD5(file));
ossResourceDao.insert(ossResource);
} catch (Exception e) {
throw new IOException(e);
}
}
private void doReplace(FileVo fileVo, OssResource ossResource) throws IOException {
try {
String fileName = fileVo.file.getName();
String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
String oldPath = ossResource.getUrl();
// 删掉旧的
s3Client.remove(oldPath);
String catalog = FileTypes.EMOJI.getPath() + "/";
String objectName = String.format("%s%s", IdFactory.nextUUId(), "." + suffix);
String newPath = catalog + objectName;;
// 上传新的
s3Client.upload(new FileInputStream(fileVo.file), oldPath, ossService.getContentType(suffix));
ossResource.setUrl(oldPath);
ossResource.setUrl(newPath);
ossResource.setOriginalName(fileName);
ossResource.setType(FileTypes.EMOJI.getPath());
ossResource.setLabel(fileVo.label);
ossResource.setCreatedDate(new Date());
ossResource.setMd5(fileVo.md5);
ossResourceDao.updateById(ossResource);
} catch (Exception e) {
throw new IOException(e);
}
}
}
导入成功后,数据库表内容如下:
3.客户端获取表情库
客户端启动的时候,通过http获取最新的表情包列表。
try {
HttpResult httpResult = Context.httpClientManager.get(ClientConfigs.REMOTE_HTTP_SERVER + "/emoji/list", new HashMap<>(), HttpResult.class);
@SuppressWarnings("all")
LinkedList<EmojiVo> list = JsonUtil.string2Collection(httpResult.getData(), LinkedList.class, EmojiVo.class);
emojiVoMap = list.stream().collect(Collectors.toMap(EmojiVo::getLabel, Function.identity()));
} catch (IOException e) {
throw new RuntimeException(e);
}
这里有个优化点,如果每次打开表情面板,都从网络下载表情资源的话,无疑非常消耗IO,且渲染体验不好。服务器可以在推送表情列表的时候,顺便带下md5。采用服务器上传表情资源的策略,将表情包保存到客户端本地存储。
4.javafx显示表情包
使用javafx的ui控件,我们可以选择弹窗容器,将获取到的所有表情资源放到一个格子面板,对于不熟悉javafx组件的人(包括本人),不用深入理解avafx的各种组件,有问题直接问AI吧。这里给出示例代码
public class EmojiPopup extends PopupControl {
private TextArea msgInput;
public EmojiPopup(TextArea msgOutput) {
this.msgInput = msgOutput;
BorderPane borderPane = new BorderPane();
// 创建包含表情的GridPane
GridPane gridPane = new GridPane();
gridPane.setPadding(new Insets(10));
gridPane.setHgap(10);
gridPane.setVgap(10);
Map<String, EmojiVo> emojiVoMap = Context.chatManager.getEmojiVoMap();
ObservableList<EmojiVo> emojis = FXCollections.observableArrayList(
emojiVoMap.values()
);
int columnSum = 9;
int columnIndex = 0;
int rowIndex = 0;
for (EmojiVo emoji : emojis) {
ImageView imageView = new ImageView(new Image(emoji.getUrl()));
imageView.setFitWidth(30);
imageView.setFitHeight(30);
Tooltip tooltip = new Tooltip(emoji.getLabel());
Tooltip.install(imageView, tooltip);
imageView.setOnMouseClicked(event -> {
System.out.println("clicked:" + emoji.getLabel());
msgInput.setText(msgInput.getText() + "[" + emoji.getLabel() + "]");
this.hide();
});
gridPane.add(imageView, columnIndex++, rowIndex);
if (columnIndex == columnSum) {
columnIndex = 0;
rowIndex++;
}
}
// 将GridPane设置到BorderPane的中心区域
borderPane.setCenter(gridPane);
// 设置样式
borderPane.setStyle("-fx-border-color: black; -fx-border-width: 2px; -fx-background-color: #ffffff;");
StageController stageController = UiContext.stageController;
Stage stage = stageController.getStageBy(R.id.ChatToPoint);
Node root = stage.getScene().getRoot();
// 为聊天容器添加鼠标点击事件过滤器
root.addEventFilter(javafx.scene.input.MouseEvent.MOUSE_CLICKED, event -> {
if (!borderPane.contains(event.getX(), event.getY())) {
this.hide();
}
});
getScene().setRoot(borderPane);
}
}
运行效果如下:
5.客户端显示表情+文字组合
这里有两个地方需要支持,一个是输入框,一个是消息显示面板。
对于消息显示面板,实现比较简单,每条消息都放在一个Pane上,消息区域则采用FlowPane,FlowPane如果是水平布局的话,则根据每个孩子节点的宽度进行排列,如果是垂直布局的话,则根据每个孩子节点的高度进行排列,默认使用水平布局。
给定一段字符串,先根据正则提取出节点表情,再依次放入FlowPane即可,代码如下:
public class TextContentHandler implements MessageContentUiHandler {
@Override
public void display(Pane parent, ChatMessage message) {
TextMessageContent textMessageContent = (TextMessageContent)message.getContent();
List<ContentElemNode> nodes = MessageTextUiEditor.parseMessage(textMessageContent.getContent());
for (ContentElemNode node : nodes) {
parent.getChildren().add(node.toUi());
}
}
@Override
public byte type() {
return MessageContentType.TYPE_TEXT;
}
}
对于输入框,比较麻烦的是,如果把编辑和显示共用一个ui控件,像微信 ,qq等IM软件,都是直接共用同一个控件的。这里暂时想不到什么解决方案,先用纯文本显示,后面再作优化吧。
全部代码已在github上托管
服务端代码请移步 --> 聊天室服务器
客户端代码请移步 --> 聊天室客户端