聊天文本内容支持表情包

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上托管

服务端代码请移步 --> 聊天室服务器

客户端代码请移步 --> 聊天室客户端

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

jforgame

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值