Redian新闻
>
实现一个基于注解的 Excel 万能导出模板

实现一个基于注解的 Excel 万能导出模板

公众号新闻

前言

上次给大家分享了一个自己开发的基于注解Excel导出的小工具,不知道有没有人用,是针对复杂数据导出多个Sheet页的,那也是第一个版本。

后来小编发现了很多问题:

  • 无法满足多复杂数据多行表头
  • 有那种一对一的数据也没有办法显示,而且得需要去做连表查询。开发太费劲。
  • 无法设置自己的样式或者企业规定的样式或字体。
  • 列宽无法自适应。
  • 遇到数据库里面 类似:type 字段的值一般都是 1,2,3,4:1:未支付,2:支付中,3:已支付 等类似的,无法直接导出,希望能够查询列表中就是这些值,只需要配置便可以导出对应的类型的中文。
  • 遇到时间,需要自定义时间格式化。

今天给大家推出的算是一个完整版本的基于注解导出Excel的工具,花费了小编整整一周的时间来写这个,就是为了争取能把功能写到位。微信搜索公众号:Java后端编程,回复:java 领取资料 。

功能简介

  • 能够针对树结构数据导出复杂表头(多少层数据都可以)
  • 针对数据对应关系分为一对一数据关系;一对多数据关系。
  • 可选择导出多个Sheet。(针对一对多的数据关系)。
  • @Cell注解 增加Groups属性(导出时选择需要导出的Group,会自动分组,实现同一实体类不同的自定义列)
  • 增加@Style注解(设置对齐方式,填充方式,前景色,边框及边框颜色)
  • 增加@Font注解(设置字体样式,字体颜色,字体名称)
  • @Sheet注解增加了password 属性,可以给Sheet页面设置密码。
  • @Title注解增加heightInPoints 属性,给标题设置行高。
  • 导出大数据量分页(适应xls格式的文件,无法导出超大数据。大概6万条数据)

功能展示

接下来就给大家展示一下插件的功能吧。

准备

pom.xml 中加入我们的依赖包。(友情提示:加入依赖包后,就不要在本地依赖POI相关的依赖了,如果存在可能会导致jar包冲突)

<dependency>
  <groupId>com.gitee.lwpwork</groupId>
  <artifactId>excel</artifactId>
  <version>0.0.2-RELEASE</version>
</dependency>

1. 一对多数据关系, 生成多个Sheet,并附加Sheet的密码。

实体类Shop配置:

@Data
@Sheet(name = "吹雪恒集团" ,password = "lwp")
@Title(value = "吹雪恒集团报表统计",heightInPoints = 30)
public class Shop implements ExcelAble {
    @Cell(value = "店名")
    private String name;
 
    @Cell(value = "收入",groups = Clerk.GroupB.class)
    private Integer income
;

    //这里实体上面需要添加@Sheet注解
    private List<Clerk> clerks ;

    public Shop() {
    }

    public Shop(String name, Integer income) {
        this.name = name;
        this.income = income;
    }
}

实体类Clerk配置:

@Sheet(name = "{}的员工报表",isField = true,value = {"name"})//这是设置是否关联父节点的字段,来拼接Sheet的名字 name中{}是占位符。isField表示是否开启 父节点字段。
@Title("员工统计报表")
public class Clerk implements ExcelAble {

    @Cell(value = "姓名")
    private String name;

    @Cell(value = "年龄")
    private String age;

    @Cell(value = "收益")
    private Integer income;

    public Clerk() {
    }

    public Clerk(String name, String age, Integer income) {
        this.name = name;
        this.age = age;
        this.income = income;
    }
}

我们的数据结构是:一个Shop->多个Clerk实体

测试代码:

public static void main(String[] args) throws Exception {
        Long start = System.currentTimeMillis();
        List<Shop> shops = init();//初始化数据列表。
        HSSFWorkbook wb = ExcelUtil.exportExcel(shops);//生成Excel的HSSFWorkbook 对象。
        ExcelUtil.createExcelFile(wb,"/Shop.xls");//生成文件
        System.out.println(System.currentTimeMillis()-start);
}

效果图:


2. 一对一数据关系,复杂表头。

Shop:

@Data
@Sheet(name = "吹雪恒集团")
@Title(value = "吹雪恒集团报表统计",heightInPoints = 30)
public class Shop implements ExcelAble {
    @Cell(value = "店名")
    private String name;
    @Cell(value = "收入")
    private Integer income;
    @Cell(value = "店长")
    private Manager manager;

    private List<Clerk> clerks ;

    public Shop() {
    }

    public Shop(String name, Integer income) {
        this.name = name;
        this.income = income;
    }
}

Manager:

@Data
public class Manager implements ExcelAble {

    @Cell(value = "店长姓名")
    private String name;

    @Cell(value = "性别")
    private String sex;

    @Cell(value = "工作经验")
    private String jobExperience;

    public Manager(String name, String sex, String jobExperience) {
        this.name = name;
        this.sex = sex;
        this.jobExperience = jobExperience;
    }

    public Manager() {
    }
}

数据结构:Manager是Shop的子集,那么我们看看导出后的效果是什么样子的。

效果图:


3. 一对多数据关系和一对一数据关系,复杂表头。

Shop:

@Data
@Sheet(name = "吹雪恒集团")
@Title(value = "吹雪恒集团报表统计",heightInPoints = 30)
public class Shop implements ExcelAble {
    @Cell(value = "店名")
    private String name;
    @Cell(value = "收入")
    private Integer income;
    @Cell(value = "店长")
    private Manager manager;
    @Cell(value = "员工")
    private List<Clerk> clerks ;

    public Shop() {
    }
    public Shop(String name, Integer income) {
        this.name = name;
        this.income = income;
    }
}

这次的导出增加了Clerk这张表。

Clerk:

@Data
public class Clerk implements ExcelAble {

    @Cell(value = "姓名")
    private String name;

    @Cell(value = "年龄")
    private String age;

    @Cell(value = "收益")
    private Integer income;
    public Clerk() {
    }

    public Clerk(String name, String age, Integer income) {
        this.name = name;
        this.age = age;
        this.income = income;
    }
}

效果图:


4. 分组表头的使用。

在导出Excel的时候,往往实体类的字段要比导出的Excel字段多,并且在不同的业务情境中,导出的列是不同的。那么我们怎么才能使用同一个实体类来实现不同的Excel导出呢 ?接下来给大家演示一下。

Shop:

@Data
@Sheet(name = "吹雪恒集团")
@Title(value = "吹雪恒集团报表统计",heightInPoints = 30)
public class Shop implements ExcelAble {
    @Cell(value = "店名", groups = {Clerk.GroupB.classClerk.GroupA.class})
    private String name
;
    @Cell(value = "收入",groups = {Clerk.GroupB.classClerk.GroupA.class})
    private Integer income
;
    @Cell(value = "店长",groups = Clerk.GroupA.class)
    private Manager manager
;
    @Cell(value = "员工", groups = Clerk.GroupB.class)
    private List<Clerkclerks 
;

    public Shop() {
    }

    public Shop(String name, Integer income) {
        this.name = name;
        this.income = income;
    }
}

看一下Shop的子集Manager

Manager:

@Data
public class Manager implements ExcelAble {

    @Cell(value = "店长姓名",groups = Clerk.GroupA.class)
    private String name
;

    @Cell(value = "性别",groups = Clerk.GroupA.class)
    private String sex
;

    @Cell(value = "工作经验",groups = Clerk.GroupA.class)
    private String jobExperience
;

    public Manager(String name, String sex, String jobExperience) {
        this.name = name;
        this.sex = sex;
        this.jobExperience = jobExperience;
    }

    public Manager() {
    }
}

然后再看一下,Shop子集Clerk

Clerk:

@Data
public class Clerk implements ExcelAble {

    @Cell(value = "姓名",groups = {GroupA.class,GroupB.class})
    private String name
;

    @Cell(value = "年龄" ,groups = GroupB.class)
    private String age
;

    @Cell(value = "收益" ,groups = GroupA.class)
    private Integer income
;

    public Clerk() {
    }

    interface GroupA{}  //A分组
    interface GroupB{}  //B分组  这个可以写在任何地方。

    public Clerk(String name, String age, Integer income) {
        this.name = name;
        this.age = age;
        this.income = income;
    }
}

我们先使用Clerk.GroupA分组测试:

 public static void main(String[] args) throws Exception {

        Long start = System.currentTimeMillis();
        List<Shop> shops = init();
        HSSFWorkbook wb = ExcelUtil.exportExcel(shops, Clerk.GroupA.class);//使用Clerk.GroupA.class 分组
        ExcelUtil.createExcelFile(wb,"/Shop.xls");
        System.out.println(System.currentTimeMillis()-start);
    }

效果图:


我们再看一下Clerk.GroupB分组测试:

public static void main(String[] args) throws Exception {

        Long start = System.currentTimeMillis();
        List<Shop> shops = init();
        HSSFWorkbook wb = ExcelUtil.exportExcel(shops, Clerk.GroupB.class);//,使用Clerk.GroupB.class 分组
        ExcelUtil.createExcelFile(wb,"/Shop.xls");
        System.out.println(System.currentTimeMillis()-start);
    }

效果图:


5. Cell注解时间格式化。

@Data
@Sheet(name = "吹雪恒集团")
@Title(value = "吹雪恒集团报表统计",heightInPoints = 30)
public class Shop implements ExcelAble {
    @Cell(value = "店名")
    private String name;
    @Cell(value = "收入")
    private Integer income;
    @Cell(value = "店长")
    private Manager manager;
    @Cell(value = "员工")
    private List<Clerk> clerks ;
    @Cell(value = "创建时间",format = "yyyy-MM-dd HH:mm:ss")
    private Date createDate;

    public Shop() {
    }

    public Shop(String name, Integer income) {
        this.name = name;
        this.income = income;
    }
}

效果图:


6. Cell注解类型解析。

@Data
@Sheet(name = "吹雪恒集团")
@Title(value = "吹雪恒集团报表统计",heightInPoints = 30)
public class Shop implements ExcelAble {
    @Cell(value = "店名")
    private String name;
    @Cell(value = "收入")
    private Integer income;
    @Cell(value = "店长")
    private Manager manager;
    @Cell(value = "员工")
    private List<Clerk> clerks ;
    @Cell(value = "创建时间",format = "yyyy-MM-dd HH:mm:ss")
    private Date createDate;
    @Cell(value = "店铺类型",readConverterExp = "1=超市,2=商场,3=餐饮" )
    private Integer shopType;
    public Shop() {
    }

    public Shop(String name, Integer income) {
        this.name = name;
        this.income = income;
    }
}

效果图:


7. 设置样式(对齐方式、边框、边框颜色、前景色、填充方式)【全局设置、局部设置】

Shop:

@Data
@Sheet(name = "吹雪恒集团")
@Title(value = "吹雪恒集团报表统计",heightInPoints = 30)
public class Shop implements ExcelAble {
    @Style(border = BorderStyles.BORDER_DASH_DOT,color = ExcelColors.AQUA)
    @Cell(value = "店名")
    private String name;
    @Style(backgroundColor = ExcelColors.YELLOW,fillPattern = FillPatternStyles.THIN_HORZ_BANDS)
    @Cell(value = "收入")
    private Integer income;
    @Cell(value = "店长")
    private Manager manager;
    @Cell(value = "员工")
    private List<Clerk> clerks ;
    @Style(valign = ValignStyles.VERTICAL_TOP,halign = HalignStyles.ALIGN_RIGHT)//垂直靠上,水平右对齐
    @Cell(value = "创建时间",format = "yyyy-MM-dd HH:mm:ss")
    private Date createDate;
    @Cell(value = "店铺类型",readConverterExp = "1=超市,2=商场,3=餐饮" )
    private Integer shopType;


    public Shop() {
    }

    public Shop(String name, Integer income) {
        this.name = name;
        this.income = income;
    }

}

Clerk: Clerk是进行全局设置的样式

@Data
@Style(backgroundColor = ExcelColors.RED)
public class Clerk implements ExcelAble {

    @Cell(value = "姓名",groups = {GroupA.class,GroupB.class})
    private String name
;

    @Cell(value = "年龄" ,groups = GroupB.class)
    private String age
;

    @Cell(value = "收益" ,groups = GroupA.class)
    private Integer income
;

    public Clerk() {
    }

    interface GroupA{}
    interface GroupB{}

    public Clerk(String name, String age, Integer income) {
        this.name = name;
        this.age = age;
        this.income = income;
    }
}

效果图:


8. 设置字体(字体大小、字体名字、字体颜色)【全局设置、局部设置】

Shop: 局部设置列

@Data
@Sheet(name = "吹雪恒集团")
@Title(value = "吹雪恒集团报表统计",heightInPoints = 30)
public class Shop implements ExcelAble {
    @Font(fontHeightInPoints = 12,fontColor = ExcelColors.RED ,fontName = "华文琥珀")
    @Cell(value = "店名")
    private String name;
    @Font(fontHeightInPoints = 14,fontColor = ExcelColors.BLUE , fontName = "Bradley Hand ITC")
    @Cell(value = "收入")
    private Integer income;
    @Cell(value = "店长")
    private Manager manager;
    @Cell(value = "员工")
    private List<Clerk> clerks ;
    @Style(valign = ValignStyles.VERTICAL_TOP,halign = HalignStyles.ALIGN_RIGHT)//垂直靠上,水平右对齐
    @Cell(value = "创建时间",format = "yyyy-MM-dd HH:mm:ss")
    private Date createDate;

    @Cell(value = "店铺类型",readConverterExp = "1=超市,2=商场,3=餐饮" )
    private Integer shopType;


    public Shop() {
    }

    public Shop(String name, Integer income) {
        this.name = name;
        this.income = income;
    }

}

Clerk:全局设置所有列:

@Data
@Font(fontHeightInPoints = 13,fontColor = ExcelColors.GREEN)
public class Clerk implements ExcelAble {

    @Cell(value = "姓名",groups = {GroupA.class,GroupB.class})
    private String name
;

    @Cell(value = "年龄" ,groups = GroupB.class)
    private String age
;

    @Cell(value = "收益" ,groups = GroupA.class)
    private Integer income
;

    public Clerk() {
    }

    interface GroupA{}
    interface GroupB{}

    public Clerk(String name, String age, Integer income) {
        this.name = name;
        this.age = age;
        this.income = income;
    }
}

效果图:


9. 动态title、动态headers【表头】

先说明一下动态title和动态headers 是什么,就是配置我们全部都做到位了,但是业务需要根据不同的时间或者前端输入名字来生成对应的title,或者前端选择导出Excel的列,这时候,只靠配置是做不到的了。那么就需要动态title和动态headers来搞定了。

Shop:

@Data
@Sheet(name = "吹雪恒集团")
@Title(value = "吹雪恒集团报表统计",heightInPoints = 30)
public class Shop implements ExcelAble {
    @Cell(value = "店名")
    private String name;
    @Cell(value = "收入")
    private Integer income;
    @Cell(value = "店长")
    private Manager manager;
    @Cell(value = "员工")
    private List<Clerk> clerks ;
    @Cell(value = "创建时间",format = "yyyy-MM-dd HH:mm:ss")
    private Date createDate;

    @Cell(value = "店铺类型",readConverterExp = "1=超市,2=商场,3=餐饮" )
    private Integer shopType;


    public Shop() {
    }

    public Shop(String name, Integer income) {
        this.name = name;
        this.income = income;
    }

}

Manager:

@Data
public class Manager implements ExcelAble {

    @Cell(value = "店长姓名",groups = Clerk.GroupA.class)
    private String name
;

    @Cell(value = "性别",groups = Clerk.GroupA.class)
    private String sex
;

    @Cell(value = "工作经验",groups = Clerk.GroupA.class)
    private String jobExperience
;

    public Manager(String name, String sex, String jobExperience) {
        this.name = name;
        this.sex = sex;
        this.jobExperience = jobExperience;
    }

    public Manager() {
    }
}

Clerk:

@Data
public class Clerk implements ExcelAble {

    @Cell(value = "姓名",groups = {GroupA.class,GroupB.class})
    private String name
;

    @Cell(value = "年龄" ,groups = GroupB.class)
    private String age
;

    @Cell(value = "收益" ,groups = GroupA.class)
    private Integer income
;

    public Clerk() {
    }

    interface GroupA{}
    interface GroupB{}

    public Clerk(String name, String age, Integer income) {
        this.name = name;
        this.age = age;
        this.income = income;
    }
}

测试代码:

public static void main(String[] args) throws Exception {

    Long start = System.currentTimeMillis();
    List<Shop> shops = init();
    //模拟前端传过来的headers
    String[] headers = new String[]{"name","income","manager","clerks","createDate","shopType",
            "manager.name","manager.jobExperience","clerks.name","clerks.income"};

    //模拟前端传过来的title
    String title = "吹雪恒集团报表统计【动态title】";
    HSSFWorkbook wb = ExcelUtil.exportExcel(shops,headers,title);//,
    ExcelUtil.createExcelFile(wb,"/Shop.xls");
    System.out.println(System.currentTimeMillis()-start);
}

效果图:


源码分析

这个Excel插件主要运用的技术就是反射,和自定义注解,还有就是POI的API的使用。

下面我们从方法入口开始讲解,由于代码过多,所以我们这里只讲解主线部分,那些细枝末节的方法便一带而过了。

public static void main(String[] args) throws Exception {
        Long start = System.currentTimeMillis();
        List<Shop> shops = init();//初始化数据列表。
        HSSFWorkbook wb = ExcelUtil.exportExcel(shops);//生成Excel的HSSFWorkbook 对象。(最主要的方法便是这个方法。也是导出Excel的核心)
        ExcelUtil.createExcelFile(wb,"/Shop.xls");//生成文件
        System.out.println(System.currentTimeMillis()-start);
}

最主要的方法便是HSSFWorkbook wb = ExcelUtil.exportExcel(shops);方法 ,接下来我们看一下,这个方法到底干了些什么。

/**
 * 生成Excel文件对象:1.创建一个HSSFWorkbook,对应一个Excel文件
 * 2.第二步,在workbook中添加一个sheet,对应Excel文件中的sheet 3.在sheet中添加表头第0行,创建单元格,并设置值表头
 * 设置表头居中,注意老版本poi对Excel的行数列数有限制 4.插入数据
 *
 * @param data
 *            需要生成Excel的数据列表
 * @param <T>
 * @return
 */

 //只导出数据列表,没有任何限制
public static <T> HSSFWorkbook exportExcel(List<T> data) {
    return exportExcel(data,(Class<?>) null);
}

//导出数据列表,并按照Group分组来导出对应的分组列。
public static <T> HSSFWorkbook exportExcel(List<T> data,Class<?> group) {
    return exportExcel(data,group,(String[]) null);
}

//导出Excel列表,并按照Group分组来导出,并且可以有外部控制,导出的分组的这些列中,有哪些可以导出。headers便是 能够导出的列。
public static <T> HSSFWorkbook exportExcel(List<T> data,Class<?> group,List<String> headers) {
    return exportExcel(data,group,(String[])headers.toArray());
}

//导出Excel列表,并按照外部传入的头部,来控制显示列表。
public static <T> HSSFWorkbook exportExcel(List<T> data,List<String> headers) {
    return exportExcel(data,null,(String[])headers.toArray());
}

//导出Excel列表,并按照外部传入的头部,来控制显示列表。(传入参数的数据类型的变动)
public static <T> HSSFWorkbook exportExcel(List<T> data,String [] headers) {
    return exportExcel(data,null,headers);
}

/**
 * 生成Excel文件对象:1.创建一个HSSFWorkbook,对应一个Excel文件
 * 2.第二步,在workbook中添加一个sheet,对应Excel文件中的sheet 3.在sheet中添加表头第0行,创建单元格,并设置值表头
 * 设置表头居中,注意老版本poi对Excel的行数列数有限制 4.插入数据
 *
 * @param data
 *              需要生成的数据列表
 * @param group
 *              生成列的分组
 * @param headers
 *              生成列的再次筛选控制(只有使用@Cell注解标注的字段,
 *              并且headers中存在这个字段,才会显示。
 *              如果headers 为null。认为没有筛选过滤条件。显示@Cell注解的所有字段。)
 * @param <T>
 * @return
 */

 //真正做事情的方法。一会也是重点研究的一个方法。
public static <T> HSSFWorkbook exportExcel(List<T> data , Class<?> group , String [] headers){
    DataExcelResolver excelResolver = new DataExcelResolver(group,headers);
    //检验数据
    excelResolver.checkExcel(data);
    // 第一步,创建一个HSSFWorkbook,对应一个Excel文件
    HSSFWorkbook wb = new HSSFWorkbook();
    // 第二步,生成Sheet集合,和Sheet对应的数据
    Map<HSSFSheet, List> sheetMap = excelResolver.sheetResolver(wb, data,null);
    for (HSSFSheet sheet : sheetMap.keySet()) {
        // 第三步,在sheet中添加标题,注意老版本poi对Excel的行数列数有限制
        excelResolver.titleResolver(sheet, sheetMap.get(sheet), wb,headers);
        // 第四步,在标题下面添加表头
        excelResolver.headerResolver(sheet, sheetMap.get(sheet), wb);
        // 第五步,插入数据
        excelResolver.dataResolver(sheet,sheetMap.get(sheet), wb);
        // 第六步,数据列宽自适应
        int index = excelResolver.countParticleCell(sheetMap.get(sheet).get(0).getClass(),headers);
        //由于后期测试,这种POI的列宽自适应耗时很长,10000条大概1分钟,所以放弃了这个自适应。而是使用自己写的自适应列宽。
        /*for (int i = 0; i < index; i++) {
            sheet.autoSizeColumn(i);
        }*/

        // 第七步,处理中文列宽自适应。
        setSizeColumn(sheet,index);
    }
    return wb;
}

我们会看到这个ExcelUtil 类里面有这么多的重载方法,这里是为了方便对外开发接口,而构造的各种导出入口。真正的导出逻辑在exportExcel(List data , Class<?> group , String [] headers)这个方法中。

我们这里主要讲解的就是第二步、第三步、第四步、和第五步。这也是Excel导出的核心和接续数据的核心。

excelResolver.sheetResolver(wb, data,null):这个方法的作用就是解析树结构的数据,然后把所有的被@Sheet注解标注的数据都按照@Sheet的配置规则生成名字,并创建Sheet页。然后读者应该也可以看到,该方法的返回值是Map<HSSFSheet, List> ,这个就是可以根据Sheet来找到对应的数据列表。然后后面的几步就是把数据列表生成Excel数据。

/**
 * 解析数据列表,讲数据和Sheet分离开,
 * 每个Sheet对应一个数据列表
 * @param wb
 * @param dataList
 * @param objData
 * @return
 */

public Map<HSSFSheet, List> sheetResolver(HSSFWorkbook wb, List<?> dataList,Object objData){
    Map<HSSFSheet, List> res = new HashMap<>();// 结果集合
    // 遍历数据列表解析data中的Sheet注解,并获取名称和对应的data列表
    if (dataList == null || dataList.size() == 0) {
        //throw new NotHasDataRunTimeException("没有数据,无法导出Excel");
        return null;
    }
    //获取数据类型
    Object data = dataList.get(0);
    Class clazz = data.getClass();
    //获取Sheet的注解。
    Sheet sheetAnnotation = (Sheet) clazz.getAnnotation(Sheet.class);
    String sheetName = null;
    //初始化Sheet的注解解析器
    SheetAnnotationResolver<Sheet> sheetAnnotationResolver = new SheetAnnotationResolver();
    if (sheetAnnotation == null) {
        return null;
    } else {
        // 获取SheetName数据,解析Sheet注解,并获取SheetName。
        sheetName = (String) sheetAnnotationResolver.resolve(sheetAnnotation, objData);
    }
    //创建Excel中的Sheet页
    HSSFSheet sheetParent = wb.createSheet(sheetName);
    获取Sheet注解中配置的密码。并设置密码。
    String pwd = sheetAnnotation.password();
    if (pwd != null && !pwd.equals("")) {//加密码
        sheetParent.protectSheet(pwd);
    }
    //将解析好的数据加入到map集合中。key为Sheet对象,value为Sheet的数据列表。
    res.put(sheetParent, dataList);
    //遍历该类型中的所有字段,查找是否有列表。
    //如果有,则递归解析子集数据。
    Field[] fields = clazz.getDeclaredFields();
    int index = 0;
    for (Field field : fields) {// 遍历data里面的所有字段,判断 数据模型中是否有 其他列表
        if (Collection.class.isAssignableFrom(field.getType())) {//该字段是集合,需要检测是否被Sheet标记
            for (Object item : dataList) {// datas中的有多少条数据就有多少个sheet
                Map<HSSFSheet, List> itemRes = null;// 结果集合
                Method method;
                List itmeData = null;
                try {
                    if (field.getType().equals("boolean")) {// 基本变量
                        method = clazz.getMethod(ExcelUtil.getBooleanPrefix(field.getName()));
                    } else {
                        method = clazz.getMethod("get" + ExcelUtil.getMethodName(field.getName()));
                    }
                    itmeData = (List) method.invoke(item);
                } catch (NoSuchMethodException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                } catch (InvocationTargetException e) {
                    e.printStackTrace();
                }
                itemRes = sheetResolver(wb,itmeData,item);
                if (itemRes != null) {
                    res.putAll(itemRes);
                }

            }
        }
    }
    return res;
}

最后,我们得到了所有的Sheet页面。

那么接下来我们就应该毫不留情的去遍历所有的sheet页面,并把sheet页面对应的数据装载的sheet面中。接下来我们查看一下,如何装载这些数据的吧。并如何保证多级表头(可无限扩展)的。

第三步,在sheet中添加标题,注意老版本poi对Excel的行数列数有限制

excelResolver.titleResolver(sheet, sheetMap.get(sheet), wb,headers)

/**
 * 标题,行高占用两行
 *
 * @param sheet
 * @param dataList
 * @param wb
 */

public void titleResolver(HSSFSheet sheet, List<?> dataList, HSSFWorkbook wb,String[] headers) {
    //int index = this.lastRowIndex(sheet);//获取行索引, 标题的行索引由手动定义。
    Object obj = dataList.get(0);
    Class clazz = obj.getClass();
    Title title = (Title) clazz.getAnnotation(Title.class)//获取Title
    HSSFRow row = sheet.createRow(0);//第0行为Title
    short height = title.heightInPoints();
    row.setHeightInPoints(height);
    HSSFCell cell = row.createCell(0);//创建一列

    HSSFCellStyle style = wb.createCellStyle();
    style.setAlignment(CellStyle.ALIGN_CENTER);
    style.setVerticalAlignment(CellStyle.VERTICAL_CENTER);
    cell.setCellValue(title.value());//填充title数据。
    cell.setCellStyle(style);
    int index = this.countParticleCell(clazz,headers);//获取title的宽度(占用几个单元格。)
    sheet.addMergedRegion(new CellRangeAddress(//合并单元格。
            0// 起始行
            0// 结束行
            0// 起始列
            index-1  // 结束列
    ));

}

生成标题的这个功能的是比较简单的。也只给标题设置了行高。和通用的对齐方式。

看完小编生成标题,肯定会有读者有这样的疑问,那就是,小编怎么知道这个标题应该占用多少个单元格呢 ?这里小编写了countParticleCell 方法,是给到数据列表的一个Class类型获取到这个数据类型会占用的总列数,这个类是生成title的父类,那么我们来看一下这个方法吧。

countParticleCell(clazz,headers)

/**
 * 传入一个数据Class,
 * 返回一个装在该数据需要的列数。
 *
 * @param clazz 字段类型/字段如果是集合,则是泛型的类型。
 * @return
 */

@Override
public int countParticleCell(Class<?> clazz,String[] headers) {//
    Field[] fields = clazz.getDeclaredFields();
    //字段需要占用Excel的列数。
    int cellCount = 0;
    //验证
    //是否有字段
    if (fields==null||fields.length==0) {
        return 0;
    }

    //被@Cell注解标注↓↓↓↓↓
    if (ExcelAble.class.isAssignableFrom(clazz)) {//是多属性字段(字段是实例对象)
        for (Field field :
                fields) {
            //1.验证字段是否被@Cell注解标注
            Cell cell = field.getAnnotation(Cell.class);
            if (cell == null) {//没有被@Cell注解标注
                continue;
            }
            HeaderExcelResolver header = new HeaderExcelResolver(group,headers);
            if (!header.verifyField(field)) {
                continue;
            }
            Class<?> genericType = null;
            if (ExcelAble.class.isAssignableFrom(field.getType())) {
                genericType = field.getType();
            } else if (Collection.class.isAssignableFrom(field.getType())) {
                Type type = field.getGenericType();

                if (type == nullcontinue;
                //得到泛型类型的类名
                if (type instanceof ParameterizedType) {
                    ParameterizedType parameterizedType = (ParameterizedType) type;
                    //得到泛型里的class类型对象
                    genericType = (Class<?>) parameterizedType.getActualTypeArguments()[0];
                }
            } else {
                cellCount ++;
            }
            if (genericType != null) {
                cellCount += countParticleCell(genericType,headers);
            }

        }
    } else {
        cellCount = 1;
    }
    return cellCount;
}

实现思路:遍历Class的所有属性,符合可显示字段的规则的,cellCount+1,遇到符合规则的字段的数据类型为ExcelAble,或者Collection类型的。那么就递归执行,cellCount+递归执行的返回值。

最终便能得到标题的总单元格数。

这里,我们就把所有的Title给生成好了。那么我们继续往下。下面我们应该是生成表头了。我们看一下

第四步,在标题下面添加表头

excelResolver.headerResolver(sheet, sheetMap.get(sheet), wb)

/**
 * @param sheet
 * @param dataList
 * @param wb
 */

public void headerResolver(HSSFSheet sheet, List<?> dataList, HSSFWorkbook wb) {
    Object data = dataList.get(0);
    Class clazz = data.getClass();//获取填充对象的Class对象,进行反射
    headerResolver(sheet,clazz,wb,0,lastRowIndex(sheet)-1);
}

/**
 * 解析表头
 * 1.获取最后一个需要添加的行
 * 2.创建行
 * 遍历字段;
 * 3.验证字段:
 *      1>验证字段是否被@Cell注解。
 *      2>验证是否有Group,有Group,值生成对应的Group
 *      3>验证是否有header,有header,值是否有该字段。
 *      以上三种验证都通过才能算通过,该字段才能显示。
 * 4.添加数据
 *       1> 获取最后一个需要添加的列
 *       2> 添加数据
 * 5.写入Style
 *
 * 6.验证是否是子集列表,或子集实体
 *    1> 子集递归解析。
 *    2> 不是自己准备继续。
 * 7.获取该字段占用多少单元格
 * 8.合并单元格。
 * 9.如果是子集列表或者实体,递归解析。
 * 遇到子集怎么办?遇到自己列表,则递归调用生成列。
 * @param sheet
 * @param clazz
 * @param wb
 * @param parentInsertCellIndex 父级节点所在的列的坐标。
 * @param parentRowIndex 父级节点所在行的坐标。
 */

public void headerResolver(HSSFSheet sheet, Class<?> clazz, HSSFWorkbook wb,int parentInsertCellIndex,int parentRowIndex) {
    Field[] fields = clazz.getDeclaredFields();//获取全部的字段
    int insertRowIndex = parentRowIndex + 1;//lastRowIndex(sheet);
    HSSFRow row = sheet.getRow(insertRowIndex);//sheet.createRow(insertRowIndex);
    if (row == null) {
        row = sheet.createRow(insertRowIndex);
    }
    row.setHeightInPoints(18);
    int lastFeildCellSize = 1;
    HSSFCellStyle style = wb.createCellStyle();
    style.setAlignment(CellStyle.ALIGN_CENTER);
    style.setVerticalAlignment(CellStyle.VERTICAL_CENTER);
    //遍历字段
    for (Field field :
            fields) {
        //3.验证字段:
        if (!verifyField(field)) {//验证没有通过
            continue;
        }
        //4.添加数据:1> 获取最后一个需要添加的列
        int insertCellIndex = lastCellIndex(row)+lastFeildCellSize-1;
        //2> 添加数据
        if (insertCellIndex == 0) {
            insertCellIndex += parentInsertCellIndex;
        }
        HSSFCell cell = row.createCell(insertCellIndex);
        Cell cellAnnotation = field.getAnnotation(Cell.class);
        String cellValue = cellAnnotation.value();//获取表头
        cell.setCellValue(cellValue);//添加数据。
        //5.写入Style
        cell.setCellStyle(style);//设置样式,水平垂直居中。
        //6.验证是否是子集列表,或子集实体
        Class genericType = null;
        if (ExcelAble.class.isAssignableFrom(field.getType())) //子集实体
            genericType = field.getType();
        } else if (Collection.class.isAssignableFrom(field.getType())) //子集列表。
            Type type = field.getGenericType();
            if (type == nullcontinue;
            //得到泛型类型的类名
            if (type instanceof ParameterizedType) {
                ParameterizedType parameterizedType = (ParameterizedType) type;
                //得到泛型里的class类型对象
                genericType = (Class<?>) parameterizedType.getActualTypeArguments()[0];
            }
        }
        //7.获取该字段占用多少单元格
        int fieldRowSize = 1;
        if (genericType == null) {
            fieldRowSize = countParticleRow(clazz,headers); //获取行数。
        }
        //获取字段需要的列数
        int fieldCellSize = 1;
        if (genericType == null) {
            fieldCellSize = countParticleCell(field.getType(),headers);
        } else {
            fieldCellSize = countParticleCell(genericType,headers);

        }
        lastFeildCellSize = fieldCellSize;
        //8.合并单元格。
        sheet.addMergedRegion(new CellRangeAddress(//合并单元格。
                insertRowIndex, // 起始行
                insertRowIndex+fieldRowSize-1// 结束行
                insertCellIndex, // 起始列
                insertCellIndex+fieldCellSize-1  // 结束列
        ));
        //9.如果是子集列表或者实体,递归解析。
        if (genericType != null) {//拥有子集
            headerResolver(sheet, genericType, wb,(insertCellIndex),insertRowIndex);
        }
    }
}

这里合并单元格,遇到一个坑,那就是合并单元格。未创建的虽然这个单元格占用了很多行或者列,但是为创建的的行,虽然占用,但是行依然是不存在的。所以如果要是获取最后一行的索引,这时候是错误的,所以方法引用了一个父级行索引,和父级列索引。

这里我们因为需要合并表头的单元格,我们依然会用到获取数据的总列数。当然我们也需要获取这个类型的表头需要占用多少行。

countParticleCell(field.getType(),headers)

/**
 * 传入一个数据Class,
 * 返回一个装在该数据需要的列数。
 *
 * @param clazz 字段类型/字段如果是集合,则是泛型的类型。
 * @return
 */

@Override
public int countParticleCell(Class<?> clazz,String[] headers) {//
    Field[] fields = clazz.getDeclaredFields();
    //字段需要占用Excel的列数。
    int cellCount = 0;
    //验证
    //是否有字段
    if (fields==null||fields.length==0) {
        return 0;
    }
    //被@Cell注解标注↓↓↓↓↓
    if (ExcelAble.class.isAssignableFrom(clazz)) {//是多属性字段(字段是实例对象)
        for (Field field :
                fields) {
            //1.验证字段是否被@Cell注解标注
            Cell cell = field.getAnnotation(Cell.class);
            if (cell == null) {//没有被@Cell注解标注
                continue;
            }
            HeaderExcelResolver header = new HeaderExcelResolver(group,headers);
            if (!header.verifyField(field)) {
                continue;
            }
            Class<?> genericType = null;
            if (ExcelAble.class.isAssignableFrom(field.getType())) {
                genericType = field.getType();
            } else if (Collection.class.isAssignableFrom(field.getType())) {
                Type type = field.getGenericType();

                if (type == nullcontinue;
                //得到泛型类型的类名
                if (type instanceof ParameterizedType) {
                    ParameterizedType parameterizedType = (ParameterizedType) type;
                    //得到泛型里的class类型对象
                    genericType = (Class<?>) parameterizedType.getActualTypeArguments()[0];
                }
            } else {
                cellCount ++;
            }
            if (genericType != null) {
                cellCount += countParticleCell(genericType,headers);
            }

        }
    } else {
        cellCount = 1;
    }
    return cellCount;
}

这个实现思路还是利用反射、注解、递归。

头部设置好后,我们就可以去加载数据啦。这里我们使用的是 excelResolver.dataResolver(sheet,sheetMap.get(sheet), wb);

/**
 * 解析数据插入数据。
 * @param sheet
 * @param dataList
 * @param wb
 */

public void dataResolver(HSSFSheet sheet, List<?> dataList, HSSFWorkbook wb) {
    dataResolver(sheet, dataList, wb,lastRowIndex(sheet),0);
}

public void dataResolver(HSSFSheet sheet, List<?> dataList, HSSFWorkbook wb,int insertLastRowIndex,int insertLastCellIndex) {
    for (Object data :
            dataList) {
        HSSFRow row ;
        row = sheet.getRow(insertLastRowIndex);
        if (row == null) {
            row = sheet.createRow(insertLastRowIndex);
        }
        insertLastRowIndex  = dataResolver(sheet, data, wb,row,insertLastCellIndex,countParticleValRow(data,headers));
    }
}


public int dataResolver(HSSFSheet sheet, Object data, HSSFWorkbook wb, HSSFRow row,int insertLastCellIndex,int fieldRowSize) {
    if (data == null) {
        throw new CellDataIsNullException("data can’t be Null");
    }
    Class clazz = data.getClass();
    Field[] fields = clazz.getDeclaredFields();//获取所有字段
    Style classStyle = (Style) clazz.getAnnotation(Style.class);
    Style style = classStyle;
    Font classFont = (Font) clazz.getAnnotation(Font.class);
    Font font = classFont;
    //int insertCellIndex = lastCellIndex(row);
    //遍历字段,解析每个字段的数据,创建每行数据。
    //int fieldRowSize = countParticleValRow(data);
    for (Field field :
            fields) {
        Cell cell = field.getAnnotation(Cell.class);
        if (classStyle == null ) {//获取style注解
            style = field.getAnnotation(Style.class);
        } else {
            Style feildStyle = field.getAnnotation(Style.class);
            if ( feildStyle != null) {
                style = feildStyle;
            }
        }
        if (classFont == null) {
            font = field.getAnnotation(Font.class);
        } else {
            Font feildFont = field.getAnnotation(Font.class);
            if (feildFont != null) {
                font = feildFont;
            }
        }

        if (!verifyField(field)) {//验证没有通过
            continue;
        }
        //验证通过。
        //解析数据值
        Object value = getValue(data, field);
        if (Collection.class.isAssignableFrom(field.getType())) {//是列表集合
            Class<?> genericType = null;
            if (value == null || ((List) value).size() == 0) {
                value = new ArrayList<>();
                Type type = field.getGenericType();
                if (type == nullcontinue;
                //得到泛型类型的类名
                if (type instanceof ParameterizedType) {
                    ParameterizedType parameterizedType = (ParameterizedType) type;
                    //得到泛型里的class类型对象
                    genericType = (Class<?>) parameterizedType.getActualTypeArguments()[0];
                    Object obj = null;
                    try {
                        obj = genericType.newInstance();
                        ((List) value).add(obj);
                    } catch (InstantiationException e) {
                        e.printStackTrace();
                    } catch (IllegalAccessException e) {
                        e.printStackTrace();
                    }
                }
            }
            dataResolver(sheet, (List<?>) value, wb, row.getRowNum(),lastCellIndex(row));
        } else if (ExcelAble.class.isAssignableFrom(field.getType())) {//是对象。
            if (value == null) {
                try {
                    value = field.getType().newInstance();
                } catch (InstantiationException e) {
                    e.printStackTrace();
                } catch (IllegalAccessException e) {
                    e.printStackTrace();
                }
            }
            dataResolver(sheet,value,wb,row,lastCellIndex(row),fieldRowSize);
        } else {//普通字段。
            HSSFCell dataCell = null;
            int lastCellIndex = lastCellIndex(row);
            if (lastCellIndex < insertLastCellIndex) {
                lastCellIndex = insertLastCellIndex;
            }
            dataCell = row.createCell(lastCellIndex);//创建列
            String excelVal = getExcelValue(cell, value);//对Cell注解一个全面的解析,得到的最终的value值。
            HSSFCellStyle cellStyle = getStyle(wb, style, font);
            //合并单元格。
            sheet.addMergedRegion(new CellRangeAddress(//合并单元格。
                    row.getRowNum(), // 起始行
                    row.getRowNum()+fieldRowSize-1// 结束行
                    lastCellIndex, // 起始列
                    lastCellIndex  // 结束列
            ));
            dataCell.setCellValue(excelVal);//给列设置值。
            if (cellStyle != null) {
                dataCell.setCellStyle(cellStyle);//给列设置样式
            }

        }
    }
    return (row.getRowNum()+fieldRowSize);

}

这里我们需要做的是解析Cell注解,

  • 解析里面的format 属性,对时间进行时间格式化。
  • readConverterExp 属性,对类型的转换。
  • defaultValue 属性,对空值时的默认值的显示。
  • 微信搜索公众号:Java项目精选,回复:java 领取资料 。

主要写数据String excelVal = getExcelValue(cell, value); 这个方法。

/**
 * 将java数据改为Excel数据。
 * String format() default "yyyy-MM-dd";  时间格式化
 * String defaultValue() default "";   值为null时,的默认字符串
 * String readConverterExp() default ""; 读取内容转表达式 (如: 0=男,1=女,2=未知)
 * @param cell
 * @param javaValue
 * @return
 */

public String getExcelValue(Cell cell,Object javaValue){
    String val = null;
    if (javaValue == null) {//javaVal = null 显示默认数据
        val = cell.defaultValue();
    } else {
        CellAnnotationResolver<Cell> cellCellAnnotationResolver = new CellAnnotationResolver<>();
        if (javaValue instanceof Date) {//时间类型的数据,需要时间格式化
            SimpleDateFormat format = new SimpleDateFormat(cell.format());
            String dateStr = format.format((Date) javaValue);
            val = dateStr;
        } else if (javaValue instanceof Boolean && isReadConverterExp(cell)) {//boolean类型的。true = 1,false = 0;
            Map<String, Object> converter = (Map<String, Object>) cellCellAnnotationResolver.resolve(cell,cellCellAnnotationResolver.READ_CONVERTER_EXP);
            val = (String) converter.get(getConverterKey((Boolean) javaValue));
        } else if (javaValue instanceof Number && isReadConverterExp(cell)) {// 数字类型
            Map<String, Object> converter = (Map<String, Object>) cellCellAnnotationResolver.resolve(cell,cellCellAnnotationResolver.READ_CONVERTER_EXP);
            val = (String) converter.get(getConverterKey((Number) javaValue));
        } else if (javaValue instanceof String && isReadConverterExp(cell)) {//String类型
            Map<String, Object> converter = (Map<String, Object>) cellCellAnnotationResolver.resolve(cell,cellCellAnnotationResolver.READ_CONVERTER_EXP);
            val = (String) converter.get((String) javaValue);
        } else {//不需要 做类型转换的。
            val = javaValue.toString();
        }
    }
    return val;
}

解析对应数据类型的格式,最终都以字符串的形式写入Excel。

说到这里,excel数据导出基本已经结束了。

还有最后一步,自适应列宽:

POI给的自适应列宽性能很差,10000条数据生成时间大概需要1分钟的时间。这对导出Excel,是在是太慢了。所以小编自己写了一个算法。来计算列宽。

实现原理便是计算所有数据每列最长的那条数据,在根据公式 换算出列宽,最终定义每列的列宽。这是实现的基本原理,下面我们看看细节吧。

private static void setSizeColumn(HSSFSheet sheet, int size) {
    int[] columnWidths = new int[size];//定义所有列最大列宽的数组
    for (int rowNum = sheet.getLastRowNum() - 1; rowNum >= 0; rowNum--) {
        HSSFRow currentRow;
        //当前行未被使用过
        if (sheet.getRow(rowNum) == null) {
            currentRow = sheet.createRow(rowNum);
        } else {
            currentRow = sheet.getRow(rowNum);
        }
        //遍历该行的所有列。计算每列的列宽,与最大列宽数组对比。
        for (int columnNum = 0; columnNum < size; columnNum++) {
            int columnWidth = sheet.getColumnWidth(columnNum) ;
            if (currentRow.getCell(columnNum) != null) {
                HSSFCell currentCell = currentRow.getCell(columnNum);
                if (currentCell.getCellType() == HSSFCell.CELL_TYPE_STRING) {
                    MegedRegionCellResult megedRegionCellResult = isMergedRegionCell(sheet, rowNum, columnNum);
                    int length = currentCell.getStringCellValue().length() * 256 * 9 / 4;
                    if (megedRegionCellResult.isMerged()) {//是否合并单元格
                        int sum = 0;
                        for (int i = 0; i < megedRegionCellResult.getCellMergedLength(); i++) {
                            sum += columnWidths[columnNum + i];//计算合并单元格现宽度
                        }
                        if (isChangeColumnWidth(length,sum)) {//需要自适应宽度,但是合并单元格,是多个单元格的所以得按比例分给不同的单元格。
                            for (int i = 0; i < megedRegionCellResult.getCellMergedLength(); i++) {
                                int lengthRate = length/sum -1;
                                //three += columnWidths[columnNum + i];
                                length = columnWidths[columnNum + i] +lengthRate * columnWidths[columnNum + i];
                                if (length > 15000) {
                                    length = 15000;
                                }
                                if (columnWidths[columnNum+i] < length) {
                                    columnWidths[columnNum+i] = length;
                                }
                            }
                        }
                    }else {//正常情况下。
                        if (columnWidth < length) {
                            columnWidth = length;
                        }
                        if (columnWidth > 15000) {
                            columnWidth = 15000;
                        }
                        if (columnWidths[columnNum] < columnWidth) {
                            columnWidths[columnNum] = columnWidth;
                        }
                    }
                }
            }
        }
    }
    //遍历初始化好的单元格列宽。并初始化列宽。
    for (int i = 0; i < size; i++) {
        sheet.setColumnWidth(i, columnWidths[i]);
    }
}

这里用到了一个是初始化单元格结果的 方法。

isMergedRegionCell(sheet, rowNum, columnNum)

/**
 * 获取MegedRegionCell结果集。
 * 只判断列有没有合并,(自适应列宽,跟行没有关系,所以就忽略了。)
 * @param sheet  当前Sheet页
 * @param row    当前行
 * @param column 当前列
 * @return
 */

private static MegedRegionCellResult isMergedRegionCell(HSSFSheet sheet, int row, int column) {
    int sheetMergeCount = sheet.getNumMergedRegions();
    for (int i = 0; i < sheetMergeCount; i++) {
        CellRangeAddress range = sheet.getMergedRegion(i);
        int firstColumn = range.getFirstColumn();
        int lastColumn = range.getLastColumn();
        int firstRow = range.getFirstRow();
        int lastRow = range.getLastRow();
        if (row >= firstRow && row <= lastRow) {//任意。
            if (column >= firstColumn && column < lastColumn) {//插入数据的时候往往都是插入到合并单元格的最左边的那个单元格。
                //return new Result(true, firstRow + 1, lastRow + 1, firstColumn + 1, lastColumn + 1);
                return new MegedRegionCellResult(true, lastColumn - firstColumn + 1, lastRow - firstRow + 1, column, row);
            }
        }
    }
    return new MegedRegionCellResult(false);
}

这样我们的整个Excel导出的代码主线算是结束了。如果想深入了解的可以下载源码进行查看。当然,如果发现bug或不足的地方也可以提交修改申请的。

源码地址

  • https://gitee.com/lwpwork/excel

作者:木得未来

来源:blog.csdn.net/qq_36622496/article/

details/100152838



推荐

1. 优秀的 Java 代码都是如何分层的 ?看了直呼NB!

2. IDEA新UI速览,成了 VS Code 的样子?

3. 如果有一千万数据,怎么用Java快速查询?

4. 废物利用,拿自己的旧电脑搭建个服务器吧!



最近面试BAT,整理一份面试资料Java面试BATJ通关手册,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。
获取方式:点“在看”,关注公众号并回复 Java 领取,更多内容陆续奉上。
PS:因公众号平台更改了推送规则,如果不想错过内容,记得读完点一下在看,加个星标,这样每次新文章推送才会第一时间出现在你的订阅列里。
“在看”支持呀,谢谢啦

微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
看完高盛Banker晒出的Excel证书,才懂人和人的差距!Nature大牛给全组配的Excel数据分析大全,付费搞来的,限时3天!聽說中國的文化是從笛子開始的心动!高盛官宣:这张可以挂简历的Excel线上证书,留学生3小时直接拿!J.P.Morgan认证的Excel证书,我自学2周线上搞定!写出过的全优通过,有口皆碑!2023年,英国这所百年女校获ISI全领域“Excellent”评级!再见,VLOOKUP!全行业必备的Excel实操大全,请低调使用!通宵整理的100份Excel表格模板,免费送!高盛内部疯传的Excel教程,实习生看完都说一个字:绝!Excel中唯一无法被替代的函数,90%的人都不知道!效率低总被老板骂哭,学了这个Excel技巧就再也不怕了!必囤好课|三周带你从0到1掌握Python, SQL, Excel等核心技能,课程免费无限次回放!专注神经损伤和退行性疾病,NeuExcell神曦生物获超亿元Pre-A+轮融资|36氪首发J.P.Morgan认可的Excel证书,我自学2天搞定一个注解实现 WebSocket 集群方案,这样玩才爽!1分钟搞定数据汇总!这3个Excel求和方法不学错亿(建议收藏)实测1天拿下的Excel证书,挂上LinkedIn后我的简历被捞了又捞…春招倒计时 | BlackRock开放会计全职网申通道,要求精通ExcelVue+SpringBoot 集成 PageOffice 实现在线编辑Word、excel文档只会Vlookup函数out了!Excel中简单高效的技巧这么多,可惜很多人都不知道!用Vlookup太笨,筛选太慢!这才是No.1的Excel表格核对技巧实测1天就能拿下Excel证书,我挂上LinkedIn后简历被捞了又捞…用了这么多年Excel,原来一直都是错的!阿瑜陀耶专注神经损伤和退行性疾病,NeuExcell神曦生物获超亿元Pre-A+轮融资|早起看早期ChatGPT全新功能公开演示!能做Excel、能上网,聊天机器人未来会成为全能app哪位Excel高人琢磨出的这个数据处理技巧,太有用了!【吃顆米大會(つっこみたいかい)】暖場 《少年壯志不言愁》高盛、大小摩内部疯传的Excel教程,一个字:绝!Cell | 重大进展!我国科学家基于人工智能的蛋白结构聚类分析,发现一系列新的碱基编辑器输的很彻底! 哥大学霸惨遭Diss: 只会用Excel求和, 还敢面试金融?SpringBoot 集成 EasyExcel 3.x 优雅实现 Excel 导入导出鸢袭长空突发!摩根大通掀起Python转型,金融圈再无Excel?
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。