尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Android用jxl在Excel中插入图片的完整实践与避坑指南

Android用jxl在Excel中插入图片的完整实践与避坑指南 上个月接了个巡检系统的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 21targetSdk 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核心OOXMLcommons系列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行那么x2, y2。图片会以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拿到图片后如果不做任何处理直接decodeAndroid会按照原始尺寸在内存里创建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再调writewrite时拿到的输出流已经失效部分图片数据段会被截断。这也是为什么我在ExcelBuilder的build方法里强制这个顺序的原因——代码写得紧凑一点能省掉后续大量的排障时间。实际项目中我们还遇到过一种情况同一个文件路径先写了一个Excel没关流又写第二次第二次生成的文件就会损坏。这种问题的返回就是“上一把锁”——文件操作加同步锁同一时间只允许一个线程写同一个路径避免交叉写。写在最后插图片这件事远没有想象的那么玄把jxl 2.6.12用熟之后回过头来看这个需求最核心的其实就是四件事理解WritableImage用的是单元格坐标系、用JPEG绕过PNG解析的坑、采样压缩控制内存、确保write/close时序正确。四件事都做对图片插入就变成了一个稳定且不消耗太多成本的功能。如果你想往下扩展建议把图片读取逻辑从本地文件扩展到网络URL核心思路不变——先下载字节流解码成Bitmap再压缩成JPEG。或者研究一下jxl的单元格样式把表头背景色、字体加粗、边框线这些做出来报表的质感会提升一大截。这些扩展不会推翻现在的设计反而会让ExcelBuilder这个工具类越用越顺手。
返回列表