☰
Android用jxl在Excel中插入图片的完整实践与避坑指南
2026/10/13 2:07:40 网站建设 项目流程

上个月接了个巡检系统的Android端改造,需求里有一项看着简单、做起来费劲:把现场照片和巡检结论一起导出成Excel报表。我们当时第一反应是上Apache POI,结果依赖树一展开就头皮发麻——三个核心包加一堆commons系列,APK体积直接多出十几兆。冷静下来翻了翻项目里那个放了很久没动的老库jxl 2.6.12,发现它居然能扛住这个活儿,而且整体包体积不到800KB。这篇文章就把用jxl在Android上给Excel插图片的完整经验写出来,覆盖选型思路、WritableImage坐标系换算、从content:// Uri取图的完整链路,以及几个把我折腾到凌晨两点的坑。

1. 选型复盘:从POI膨胀到jxl2.6.12的实用主义回归

1.1 需求背景:巡检Excel要带现场照片

客户的需求表述很简单:每个点位一行记录,点位名称、巡检结果、现场照片,照片至少三张,导出的Excel要能在Windows和手机上直接打开,不能需要额外插件。听起来就是往单元格里塞图片的事,但真正落地时会发现里面有大量细节——图片插在哪个单元格、图片多大、多张图片怎么排布、原图太大怎么办,这些如果不提前想清楚,写出来的代码在演示现场一定会翻车。

先交代一下我们的工程背景:这是一个维护了三年的老Android项目,minSdk 21,targetSdk 33,还在用传统的View体系,没有引入任何Excel相关的库。在这种项目里引入新依赖,第一原则就是“能少加就少加”,因为每一行依赖都可能带来混淆问题、方法数压力和APK体积增长。

1.2 三大Excel方案在Android侧的差别

做Android的人提到Excel方案,脑子里蹦出来的基本就是jxl、Apache POI、EasyExcel三选一。把三者在Android侧的实际情况摆出来看,差别非常明显:

对比项jxl 2.6.12Apache POI 4.x + OOXMLEasyExcel
依赖体积约780KB,单jar核心+OOXML+commons系列,15MB起底层依赖POI,体积同样不小
支持的Excel格式仅xlsxls和xlsxxlsx为主
插入图片支持WritableImage支持Drawing API支持,但Android侧集成成本高
API复杂度低,几行代码可上手高,层次深中等,注解流程多
维护状态停止维护多年活跃活跃
Android兼容性老库但稳定需要额外适配主要面向Java服务端

POI在Android上的问题不只是体积。它的OOXML实现依赖了XMLBeans、commons-compress这些库,在Android这种割裂的生态里很容易踩到java.base模块缺失、classloader冲突、以及ART虚拟机下的兼容性坑。EasyExcel本质是POI的封装,也很难绕开这些问题。

1.3 jxl的边界与取舍

jxl 2.6.12确实是老古董,最后更新时间在2012年前后,但它有一个关键优势:纯Java实现加上小巧的jar体积,在Android上几乎不做任何额外适配就能跑。它的能力边界也很清晰——不支持xlsx、不支持复杂的图表、样式系统比较原始。如果只是生成一个结构规整、带图片的xls报表,jxl完全够用。

我当时的判断是:先用jxl跑通一个最小产品原型,如果遇到解决不了的问题再切POI。后来证明这个决策是对的,整个项目的Excel导出功能,jxl部分只占了大约200行代码,图片插入用了不到40行。当然,jxl也有它自己的脾气,尤其在对PNG图片的解析上,后面我会专门讲这个坑。

2. 集成这个老库:依赖、混淆与最小链路验证

2.1 jar包与Gradle依赖的引入方式

jxl 2.6.12在Maven中央仓库里有坐标,但我不建议直接在Gradle里写依赖——因为这个库太久没人维护,某些maven镜像上的pom文件可能不完整,导致传递依赖解析失败。最稳妥的方式还是直接下载jxl-2.6.12.jar,丢进app/libs目录,然后让Gradle去识别它:

implementation files('libs/jxl-2.6.12.jar')

如果你维护的是老项目,原本就有libs目录的集中式依赖管理,那这个姿势是最顺手的。下载jar包时有个细节:认准SourceForge上jxl项目官方发布的版本,有些第三方站点打包的jar可能被改过东西,尤其是再加了依赖进去的那种,别碰。

2.2 混淆规则与Android版本兼容

jxl这个库内部用了大量的Builder模式和工厂方法,但归根结底没有用到反射去加载第三方类,所以默认混淆规则下基本能跑。不过保险起见,我在proguard-rules.pro里加了一行keep,防止未来某个版本混淆规则变化导致意外:

-keep class jxl.** { *; }

Android版本兼容方面,jxl是老东西,但它的代码没有用到过时的Android API,我在minSdk 21、targetSdk 33的设备上(覆盖Android 5.0到13.0)都实测过,写入xls文件没有出现兼容性异常。唯一要注意的是Android 11以后的分区存储:向公共目录写文件需要权限,建议直接写到app的私有目录(getExternalFilesDir),或者申请MANAGE_EXTERNAL_STORAGE,后者在Google Play上架审核比较敏感,能用私有目录就别碰这个权限。

2.3 最小Demo:先跑通一张带图的xls

下面这段代码是最小的验证链路,用一张项目里的本地图片生成Excel,如果这段跑通了,后面做的事情都是在这个基础上加功能:

public File createExcelWithImageCell() throws Exception { File file = new File(context.getExternalFilesDir("exports"), "demo_with_image.xls"); OutputStream fos = new FileOutputStream(file); WritableWorkbook workbook = Workbook.createWorkbook(fos); WritableSheet sheet = workbook.createSheet("演示Sheet", 0); sheet.addCell(new Label(0, 0, "序号")); sheet.addCell(new Label(1, 0, "图片")); sheet.addCell(new Label(0, 1, "1")); // 读取本地图片 byte[] imgBytes = readBytesFromAsset(context, "demo_pic.jpg"); // 第1列、第1行开始,占2.5列宽、3.5行高 WritableImage image = new WritableImage(1, 1, 2.5, 3.5, imgBytes); sheet.addImage(image); workbook.write(); workbook.close(); fos.close(); return file; }

这段代码跑通后,你就拥有了一个最核心的引用模板:WritableImage + sheet.addImage() 这两个API就是jxl在Excel里插图片的全部入口。接下来需要钻进去理解的是坐标系统,因为x、y、width、height这几个参数的单位和大多数人第一直觉不一样。

3. WritableImage的坐标系:图片位置和尺寸的正确打开方式

3.1 参数拆解:x/y是单元格坐标,width/height是单元格跨度

第一次用WritableImage的人,十有八九会把x和y当成像素坐标,这是一个非常自然的误解。实际上jxl里的坐标系是“单元格网格坐标系”,不是Android的Canvas像素坐标系。

WritableImage的构造函数长这样:

new WritableImage(double x, double y, double width, double height, byte[] imageData)
  • x:图片左上角所在的列索引,0表示第1列,1表示第2列。
  • y:图片左上角所在的行索引,0表示第1行,1表示第2行。
  • width:图片占据的“列跨度”,2.5表示图片宽度相当于2.5列单元格的宽度;支持小数,意味着可以精确控制图片右边界落在某个单元格内部某一处。
  • height:图片占据的“行跨度”,3.5表示图片高度相当于3.5行单元格的高度。

如果你想要图片锚定在C3单元格(第3列第3行),那么x=2, y=2。图片会以C3的左上角为起点向下向右延伸width列距离、height行距离。这个机制换成Excel里的概念,相当于插入了一张浮动图片,并把左上角吸附在指定单元格的左上角。

3.2 行列宽高对图片显示效果的影响

理解了坐标系,下一个问题就是:图片实际在Excel里显示多大?答案取决于你Sheet里的列宽和行高。jxl里是可以自定义列宽行高的,列宽通过setColumnView设置,行高通过setRowView设置,两套单位还不太一样:

  • setColumnView(int col, int width):width的单位是“字符数”,大致等于在当前字体下能容纳多少个英文字符。
  • setRowView(int row, int height):height的单位是1/20磅,Excel默认行高是255,对应的磅数是12.75,在96dpi下大概17像素。

我在做图片排版时一般先用这两个接口把Sheet的骨架定下来,再算图片跨度。举个例子,我希望图片显示区域大约宽200像素、高150像素,假设我把列宽设成了10个字符(大约70像素),行高设成了8.5磅(大约11像素),那图片跨度就是200/70≈2.86列,150/11≈13.6行。

这段换算看起来繁琐,但实际调参时有个粗暴技巧:先随便填一个跨度,生成Excel,在电脑上打开看效果,再按比例微调。因为Excel在不同缩放级别、不同字体渲染下的实际像素本来就不同,追求精确到像素意义不大,视觉上协调即可。

3.3 批量排版:图片与文字的常见对齐方案

在巡检报表这种场景里,更常见的诉求是“每行一个点位,左侧是文字,右边放一到三张图”。这里有一个排版规律值得记下来:jxl的Label坐标和WritableImage坐标是同一个网格坐标系,只要让Label的列索引与图片的x索引错开,天然对齐。

我们项目里的方案是:

列内容说明
第0列点位名称Label(0, row)
第1列图片1WritableImage(1, row, 2.2, 3.0)
第3列图片2WritableImage(3, row, 2.2, 3.0)
第5列图片3WritableImage(5, row, 2.2, 3.0)

图片之间留一列作为间距,视觉上就不会挤在一起。需要特别注意的是,Excel不像Android有自动换行和相对布局,图片位置写死在哪就永远在哪,所以生成前最好算清楚列跨度,避免图片互相重叠。

4. 从content:// Uri到Excel:完整取图与写入链路

4.1 处理外部App分享的Uri(企业微信、相册、文件管理器)

这个项目里最常见的输入不是相册里的本地图片,而是用户在短信/企业微信/钉钉里收到一个Excel文件和一个压缩包,然后用户转发给我们的App来处理。此时App收到的Intent data是一个content:// Uri,典型的结构长这样:

content://com.tencent.wework.fileprovider/external_path/android/data/com.../files/....jpg

在Android 7.0以上,文件分享全部都走了FileProvider,所以uri的主机名是各App自己的authority,而不是“file”。很多新手习惯new File(uri.getPath()),这在绝大多数情况下会直接失败,因为那个路径不是真实文件路径。

正确的打开方式只有一种:通过ContentResolver拿InputStream。

InputStream is = context.getContentResolver().openInputStream(uri);

这里有一个权限陷阱:如果这个uri是别的App通过Intent临时授权给你的,你没有持久化权限。必须在处理该Intent的组件内同步读取内容,不能把它存下来等下次启动再读,否则会抛SecurityException。

4.2 Bitmap采样、JPEG压缩与字节流转换

从Uri拿到InputStream之后,不能直接塞给jxl。原因有两个:第一,jxl不认识InputStream,它只认File或byte[];第二,如果原图是几兆像素的大图,直接解码成Bitmap再压缩成字节数组,内存会爆炸。

完整的处理链路我封装成三步:

第一步,用BitmapFactory.Options的inJustDecodeBounds只读图片的宽高信息,不加载像素数据。 第二步,根据目标显示尺寸计算采样率inSampleSize,做一次降采样,把位图控制在合理范围内。 第三步,将Bitmap压缩成JPEG字节流,喂给WritableImage。

为什么不推荐用PNG?因为Android的Bitmap默认是ARGB_8888格式,压缩成PNG时会保留alpha通道,而jxl的PNG解析器对带alpha通道的PNG支持得不好,很容易报解析异常。JPEG没有alpha通道,jxl处理起来格外顺手。我们项目里最后统一用JPEG,压缩质量设为85,肉眼质量损失极小,文件体积却大幅下降。

public static byte[] loadCompressedImageBytes(Context context, Uri uri, int reqWidth, int reqHeight) throws IOException { // 第一次读取,只解析边界 BitmapFactory.Options boundsOpts = new BitmapFactory.Options(); boundsOpts.inJustDecodeBounds = true; InputStream is1 = context.getContentResolver().openInputStream(uri); BitmapFactory.decodeStream(is1, null, boundsOpts); is1.close(); // 计算采样率 int sample = 1; while (boundsOpts.outWidth / sample > reqWidth * 2 && boundsOpts.outHeight / sample > reqHeight * 2) { sample *= 2; } // 第二次读取,正式解码 BitmapFactory.Options decodeOpts = new BitmapFactory.Options(); decodeOpts.inSampleSize = sample; InputStream is2 = context.getContentResolver().openInputStream(uri); Bitmap bitmap = BitmapFactory.decodeStream(is2, null, decodeOpts); is2.close(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, 85, baos); bitmap.recycle(); return baos.toByteArray(); }

4.3 可直接用的工具类封装

把Excel创建逻辑和图片加载逻辑分开,是我习惯的方式。下面这个工具类可以直接抄进项目里,包含了设置表头、写文字、插入图片、写文件四个最常用的方法:

public class ExcelBuilder { private final WritableWorkbook workbook; private final WritableSheet sheet; public ExcelBuilder(OutputStream os) throws IOException { workbook = Workbook.createWorkbook(os); sheet = workbook.createSheet("Sheet1", 0); } public void writeText(int col, int row, String text) throws WriteException { sheet.addCell(new Label(col, row, text)); } public void insertImage(int col, int row, double width, double height, byte[] jpegData) throws WriteException { WritableImage image = new WritableImage(col, row, width, height, jpegData); sheet.addImage(image); } public void setColumnWidth(int col, int chars) throws WriteException { sheet.setColumnView(col, chars); } public File build(String path) throws IOException, WriteException { workbook.write(); workbook.close(); return new File(path); } }

调用方只需要把上面loadCompressedImageBytes的返回值直接传给insertImage就行。这套封装在我们项目里跑了两个多月,中间经历了三次需求微调(图片尺寸、列间距、单元格背景色),都只是在调用层改参数,核心代码一直没动过。

5. 三个典型故障的完整排查过程

5.1 PNG解析失败:ARGB_8888的锅

现象:把Bitmap压缩成PNG后喂给WritableImage,写入Excel时报IOException,提示类似“Image format not recognized”或“png: insufficient image data”,Excel文件能生成,但打开时提示损坏。

初步排查:我先怀疑是Bitmap压缩流程出错,于是在压缩后打印了字节数组长度,bytes不为空,长度也合理。又试着直接把压缩后的PNG写到磁盘,用电脑图片查看器打开,发现图片完全正常。说明问题不在压缩流程,而在jxl解析PNG的那一侧。

深入看jxl的源码:jxl的PNG解析器实现比较旧,对色彩类型的支持有限。Android的ARGB_8888压缩出的PNG带有alpha通道,色彩类型是RGBA,而jxl的PngReader主要处理RGB和调色板PNG,遇到RGBA就会解析失败。

解决:弃用PNG,统一走JPEG路线。改动就一行,把Bitmap.CompressFormat.PNG改成JPEG。这里也提醒一下,如果你从第三方拿到的图片本身就是PNG,最好解码成Bitmap再压缩成JPEG,不要直接拿原始PNG字节去喂jxl。

5.2 大图内存暴涨:OOM在哪一步发生

现象:用户从相册选了一张4000x3000的照片,App直接OOM崩溃。排查时打开logcat,看到OutOfMemoryError发生在BitmapFactory.decodeStream附近。

原因不复杂:OpenInputStream拿到图片后,如果不做任何处理直接decode,Android会按照原始尺寸在内存里创建Bitmap。ARGB_8888格式下,4000x3000的图片内存占用大约是400030004字节,约48MB。在旧机型上这已经很危险了。

解决:在loadCompressedImageBytes里先decode bounds再采样解码。这也是我前面代码里为什么要读两次InputStream的原因。采样率计算不能拍脑袋,我见过有人直接写死inSampleSize = 4,小图没问题,大图还是崩。正确做法是根据reqWidth和reqHeight动态算:

int sample = 1; while (boundsOpts.outWidth / sample > reqWidth * 2 && boundsOpts.outHeight / sample > reqHeight * 2) { sample *= 2; }

reqWidth和reqHeight我通常取Excel里图片显示区域的两倍,比如显示区域200像素,就按400解码,这样在Retina屏幕上放大看也不会糊。

5.3 多图写入后Excel打不开:写入时序与流关闭

现象:插入一张图片时一切正常,插入三张以上图片后,生成的文件在Excel里打开报“文件已损坏,是否修复”,点修复后图片丢失。

排查过程很值得记录。先把生成Excel的流程拆成两段:addImage到sheet的过程,和workbook.write()落盘的过程。我最初怀疑是不是多个WritableImage对象在同一个sheet里互相冲突,于是尝试了每张图单独创建一个sheet,结果多sheet的文件反而正常。这说明问题不在图片数据本身,而在write时序。

再去翻jxl的源码,发现addImage本质上是把图片信息暂存到一个内部的ImageDrawer对象里,直到workbook.write()时才真正封装进xls的二进制流中。如果在write()之前动了输出流、或者提前close了workbook,部分图片数据就会丢失或写坏。

这个问题最隐蔽的地方在于Android的FileOutputStream可能存在缓冲问题。我们的修复其实很简单:确保workbook.write()之后,依次关闭workbook和OutputStream,顺序不能反:

workbook.write(); workbook.close(); fos.close();

如果先关fos再调write,write时拿到的输出流已经失效,部分图片数据段会被截断。这也是为什么我在ExcelBuilder的build方法里强制这个顺序的原因——代码写得紧凑一点,能省掉后续大量的排障时间。

实际项目中我们还遇到过一种情况:同一个文件路径先写了一个Excel,没关流又写第二次,第二次生成的文件就会损坏。这种问题的返回就是“上一把锁”——文件操作加同步锁,同一时间只允许一个线程写同一个路径,避免交叉写。

写在最后:插图片这件事,远没有想象的那么玄

把jxl 2.6.12用熟之后,回过头来看这个需求,最核心的其实就是四件事:理解WritableImage用的是单元格坐标系、用JPEG绕过PNG解析的坑、采样压缩控制内存、确保write/close时序正确。四件事都做对,图片插入就变成了一个稳定且不消耗太多成本的功能。

如果你想往下扩展,建议把图片读取逻辑从本地文件扩展到网络URL,核心思路不变——先下载字节流,解码成Bitmap,再压缩成JPEG。或者研究一下jxl的单元格样式,把表头背景色、字体加粗、边框线这些做出来,报表的质感会提升一大截。这些扩展不会推翻现在的设计,反而会让ExcelBuilder这个工具类越用越顺手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询