转转技术

游戏账号大图生成

  • 1 背景
  • 2 从前端生成大图,到后端生成大图
    • 2.1 前端生成大图的考量与实现方案
    • 2.2 前端生成大图遇到的问题及原因分析
    • 2.3 改进方案-后端生成大图
  • 3. 后端生成大图的具体实现
    • 3.1 Java图像处理api
    • 3.2 基础信息部分
    • 3.3 模块化拼接皮肤分类部分
  • 4 总结
    • 4.1 性能提升
    • 4.2 用户体验
    • 4.3 扩展性与维护性

1 背景

转转游戏账号业务中,以王者荣耀为例,用户关注的核心指标是:英雄总数、皮肤总数、特色皮肤等重点信息。但是在旧版商品列表展示的图片,是用户上传的图片,以及一些简单业务规则生成的图片,没有突出账号相关重点特色。Image

虽然在商详(商品详情页)内部已经呈现出了完整的验号报告,但是用户需要从商列(商品列表页)点击进入到商详,存在较长的转换路径,降低了关键信息的触达效率。Image

于是我们将游戏账号相关重点信息生成大图,展示在商列及商详。

商列(商品列表页)大图:Image

商详(商品详情页)大图:Image

2 从前端生成大图,到后端生成大图

2.1 前端生成大图的考量与实现方案

需求初期,大图由前端生成,主要出于以下考虑:

  1. 前端可利用浏览器原生渲染能力处理复杂布局和样式,开发验证速度较快。
  2. 前端能更灵活地支持图像处理,开发成本较低。

前后端进行交互,由后端提供生成图片所需的全量物料图片信息。前端拿到物料之后,通过页面api,与Puppeteer截图相结合的方式生成大图。步骤如下:

  1. 前端创建一个页面,通过访问后端接口拿到对应的物料数据(小图url、文字等)。对这些图片资源url数据进行实时访问,在页面上进行图片与文字的绘制,进而生成大图的页面。
  2. 将此页面的地址,传到另一个Puppeteer服务。Puppeteer服务会启动一个浏览器实例,再次访问传入的页面,然后进行截图。

2.2 前端生成大图遇到的问题及原因分析

在上线之后,发现前端生成大图会有一定概率的超时异常导致生成图片失败,平均2~3s,超时情况大于5s。在生成只有12个拼接图片的情况下尚且超时,随着我们商详大图需求的引入,拼接图片数量有时会超过600。此时超时情况会更加严重。

通过对整体链路的分析,发现Puppeteer服务截图是一个耗时较多的操作,其大概步骤如下:

  • 申请服务内存、磁盘等资源;
  • 创建浏览器进程并启动;
  • 通过网络访问传入页面URL;
  • 渲染页面并且进行截图。

2.3 改进方案-后端生成大图

为了解决前端方案的性能瓶颈,我们评估了迁移到后端生成图片的可行性。对于上面遇到的问题,后端可以有针对性地进行解决:使用Java中awt包下的画图api拼接生成大图,避免了Puppeteer启动浏览器、渲染页面带来的开销。

能否迁移到后端,有两个衡量标准,第一个是性能,即耗时。第二个是后端生成的图片UI样式,即能否达到UI验收标准。

于是我们先在本地测试,发现相同图片耗时仅仅需要20ms左右(相较前端平均2~3秒的时间有较大提升)。即使涉及500多张图片的拼接,平均耗时也只在2s左右。另外在生成的图片样式效果上也达到了UI验收的要求。

前端生成大图:Image

后端生成大图:Image

3. 后端生成大图的具体实现

以上文提到的、规则相对复杂的商品详情页大图为例进行说明。通过分析UI原型,我们发现其结构具有清晰的模块化特征:从上往下看依次为基础信息模块、分类皮肤信息模块。其中皮肤信息模块又可分为分类标题模块、单个皮肤单元模块。

Image

这里需要用到的操作,包括绘制图片、文字、伸缩图片、平移图片等。由于后端使用的编程语言为Java,所以先简要介绍一下Java图片处理相关的api。

3.1 Java图像处理api

在Java图像处理中,java.awt.image.BufferedImage.BufferedImage与java.awt.Graphics2D是两个核心类,它们密切协作以实现图像的创建、编辑和渲染。

  • BufferedImage是图像数据的画布容器,负责存储像素信息。主要用于读写图像文件。
  • Graphics2D是操作图像的画笔,负责绘制和修改图像内容。在BufferedImage上绘制内容。支持绘制文字、图像平移缩放等操作。

3.1.1 创建BufferedImage

    /**     * 读取本地文件     */    BufferedImage imageFromFile = ImageIO.read(new File("本地图片路径"));    /**     * 从网络中读取图片     */    BufferedImage imageFromUrl= ImageIO.read(new URL("网络图片路径"));    /**     * 通过构造方法创建。构造参数指定宽和高。    */    BufferedImage combinedImage = new BufferedImage(100, 200, BufferedImage.TYPE_INT_RGB);

3.1.2 绘制图片与文字

    public static void main(String[] args) throws IOException {        BufferedImage backGroundImage = ImageIO.read(new File("输入路径"));        BufferedImage combinedImage = new BufferedImage(backGroundImage.getWidth(), backGroundImage.getHeight(), BufferedImage.TYPE_INT_RGB);        Graphics2D graphics = combinedImage.createGraphics();        try {            // 画图            graphics.drawImage(backGroundImage, 0, 0, null);            // 写文字            graphics.setFont(new Font("微软雅黑", Font.BOLD, 20));            graphics.setColor(Color.WHITE);            graphics.drawString("文字内容", 20, 20);        } finally {            // 释放资源            graphics.dispose();        }        // 保存结果        File output = new File("输出路径");        ImageIO.write(combinedImage, "jpg", output);    }

3.1.3 图片伸缩

    public static void main(String[] args) throws IOException {       // 伸缩前的画面        BufferedImage originImage = ImageIO.read(new File("文件路径"));        // 伸缩后的画面        BufferedImage scaleImage = getScaleImage(originImage, 0.7, 0.7);        File output = new File("输出路径");        ImageIO.write(scaleImage, "jpg", output); }    /**     * 对原始图片按比例进行伸缩     */    public static BufferedImage getScaleImage(BufferedImage originImage, double scaleFactorWidth, double scaleFactorHeight) {if (Objects.isNull(originImage) || scaleFactorWidth <= 0 || scaleFactorHeight <= 0) {return originImage;        }        // 等比例压缩比例        int scaledWidth = (int) (originImage.getWidth() * scaleFactorWidth);        int scaledHeight = (int) (originImage.getHeight() * scaleFactorHeight);        // 创建新的 BufferedImage        BufferedImage scaledImage = new BufferedImage(scaledWidth, scaledHeight, BufferedImage.TYPE_INT_RGB);        Graphics2D g2d = scaledImage.createGraphics();        try {            // 绘制缩放后的图像            g2d.drawImage(originImage, 0, 0, scaledWidth, scaledHeight, null);        } finally {            g2d.dispose();        }return scaledImage;

3.2 基础信息部分

原始UI图如下,我们需要将具体文字信息写入到对应位置内。Image

实现思路:

  1. 首先将原始UI图绘制到画布;
  2. 设置文字字体、大小,以及横纵坐标参数,绘制文字。

效果图如下:Image

3.3 模块化拼接皮肤分类部分

皮肤分类模块分开来看,可以按照皮肤类型分成若干个大类,如史诗皮肤、限定皮肤等。同时在每个皮肤分类模块前有对应标题。

3.3.1 绘制标题

通过api绘制标题图片到指定位置即可,这样可省去字体设置与字体居中的步骤。Image

3.3.2 生成皮肤图片单元

每一个皮肤分类中,各个皮肤单元都由四部分元素组成,分别是角标图片、底图图片、文字、浮层图片。

Image

我们拿到这四部分基础原始数据之后,按如下步骤进行绘制:

  1. 绘制皮肤底图到画布;
  2. 绘制浮层覆盖到皮肤底图之上;
  3. 绘制角标图片到指定位置;
  4. 绘制文字到指定位置。

由于各类小图片信息是短时间内不会变更的,所以这里会对各类小图片进行本地缓存,避免频繁网络请求导致的资源浪费。

3.3.3 生成皮肤分类模块

将所有生成的皮肤图片单元按指定横纵坐标绘制。

Image

3.3.4 拉伸背景与边框

原始UI切图如下:Image

由于每个账号对应的皮肤数量不同,需要让背景图与边框适配对应数量的皮肤图片总高度。

Image

3.3.5 拼接生成各分类组合大图

通过对每个皮肤分类,重复以上步骤,即可生成各分类组合大图。Image

4 总结

4.1 性能提升

生成耗时从平均2-3秒(前端+Puppeteer)降至毫秒级(简单图片)至秒级(超复杂图片如500+皮肤),解决了超时问题。

4.2 用户体验

确保了用户在浏览商品时,能快速地获取到游戏账号的核心价值信息。

4.3 扩展性与维护性

通过模块化思想,将图片拼接的核心逻辑(图片加载、绘制、文字渲染、布局、背景处理)抽象为可复用的基础服务模块。再结合动态配置来定义不同游戏的大图布局、元素样式、数据映射规则等,实现了业务逻辑与渲染逻辑的解耦。使新游戏品类的接入效率大幅提升。

现已应用在王者荣耀、原神、火影忍者、枪战王者等多款游戏。

原神:Image火影忍者:Image枪战王者:Image


关于作者

张廉洁 转转Java开发工程师

想了解更多转转公司的业务实践,欢迎点击关注下方公众号: