Unity读取Excel

前言

        其实我写游戏逻辑的时候很少去做关于Excel的操作。这是因为大多数时候,我使用xml或是json这样的数据文件。但Excel本身有像是office和wps这样方便的可视化软件可以帮我们更好处理一些数据。所以在游戏开发的过程中,我也经常遇到这样的一种需求:将Excel文件中的内容转为Json或是xml的数据文件。所以用代码读取Excel也变成了我们游戏开发过程中需要面对的事情。这篇文章是记录我个人在开发中如何使用Unity读取Excel的记录。

环境

    Windows10
    Unity2022.3.8f1c1 IL2CPP .NET Framework
    OpenXml.3.0.1

OpenXml插件

         Unity本身并不提供Excel的读取。我们必须依赖第三方开发的API帮助我们读取Excel。我使用的是Open XML,这个是其文档链接

OpenXml下载方式

         它本身的下载,我觉得挺麻烦的。如何你去GitHub上搜索,你的确可以找到有关它的工程,工程链接。但我没搞懂这个怎么用。如果你使用的是Visual Studio,那么你可以直接在包管理中搜索DocumentFormat.OpenXml然后进行下载。又或者你去NuGet上下载OpenXml的包体,下载地址。然后你再添加NuGet包体到你的工程中。

Unity加载OpenXml

         我本身只成功了用Visual Studio自己下载包体。所以我只说明这个,其他的大家可以去尝试一下。我Unity游戏工程的路径为E:。当你使用Visual Studio下载下来后,你以为你可以使用OpenXml。但是这个只是假象。你写完代码回Unity中重新编译后。你会发现Unity会报错和你说并没有OpenXml。而你再回去看Visual Studio,Visual Studio也会报没有OpenXml。而这是因为OpenXml并没有在Unity的Asset下。以我上面的路径为例,即它没有在:E:下。它的位置是在E:下。而找到它的方法我也说明一下。你只需要在其第一次被加载的时候,在Visual Studio中对OpenXml其中的类型按下F12进行反编译。然后我们直接拉倒最后看它的位置。因为它会依赖其他的dll,而这些也是会被反编译出来。我们只需找到我们想要的DocumentFormat.OpenXml的dll,并从中拿到它所在的文件路径。如果你看不懂也没事,按照我给例子找一下也可以。

DocumentFormat.OpenXml

1
E:\GameProject\packages\DocumentFormat.OpenXml.Framework.3.0.1\lib\net46\DocumentFormat.OpenXml.Framework.dll

         打开E:,你会发现文件下还有两个不同的文件DocumentFormat.OpenXml.3.0.1和DocumentFormat.OpenXml.Framework.3.0.1(3.0.1表示的是版本)。如果你和我的环境不一样所以在导入同样的dll时报下面的这样的错:

1
2
3
4
5
Assembly 'Assets/Editor/Plug-In/DocumentFormat.OpenXml.Framework.dll' will not be loaded due to errors:
Unable to resolve reference 'System.IO.Packaging'. Is the assembly missing or incompatible with the current platform?
Reference validation can be disabled in the Plugin Inspector.
Unable to resolve reference 'System.Collections.Immutable'. Is the assembly missing or incompatible with the current platform?
Reference validation can be disabled in the Plugin Inspector.

那么你只能先将这DocumentFormat.OpenXml.Framework文件中的所有dll实验过去直到找到的dll不报错。然后再将对应的DocumentFormat.OpenXml下的dll放入。例如我要放入的是net46的DocumentFormat.OpenXml.Framework.dll,所以我也要将net46的DocumentFormat.OpenXml.dll的放入其中。

PS:在一些版本的OpenXml中是没有DocumentFormat.OpenXml.Framework的。比如OpenXml.2.20.0。它只提供了DocumentFormat.OpenXml.dll。那这时候我们仍然可以用上面的方法来选择出我们想要的DocumentFormat.OpenXml.dll。

         当我们将DocumentFormat.OpenXml和DocumentFormat.OpenXml.Framework的dll加入后,我们按照官方文档进行操作。你会发现有部分类是用不了的,比如SpreadsheetDocument。这是因为我们还需要一个dll,WindowsBase.dll。而要是你看得懂,我之前找路径的方法。那么你也会发现WindowsBase.dll的路径。我项目中的路径如下:C:Files (x86)Assemblies.NETFramework.7.1.dll。但是我们不能将这个dll放入项目中,否则Unity就会报下面这样的错误:

1
Loading assembly failed: "Assets/Editor/Plug-In/WindowsBase.dll" reason: File does not contain a valid CIL image

而这个dll其实Unity编辑器自身也有带的。在我多次的实验下我找到了这些地方是有存在WindowsBase.dll(请结合自己的Unity编辑器所在位置进行获取):

1
D:\Unity Editor\2022.3.8f1c1\Editor\Data\MonoBleedingEdge\lib\mono
1
D:\Unity Editor\2022.3.8f1c1\Editor\Data\UnityReferenceAssemblies

这里面有很多小文件夹,而这些文件夹中有着不同的WindowsBase.dll。如果你和我环境不一样那只能一个个试过去了。我使用的WindowsBase.dll是在D:Editor\2022.3.8f1c1_4_x-win32路径下的dll。

         所有的dll我都放在了Assets/Editor/Plug-In这个路径下,大家可以按照自己的习惯来进行放置。接下来我们就可以按照官方的文档来进行操作了。而接下来的示例中,我会直接给出OpenXml的使用。

示例

基础数据

         我先给出一个要求:我们需要将Excel中配置的信息转为json。其中Excel的第一行是用中文来描述各个列是表示什么意思。第二行是对应的变量名。从第三行开始是我们要填入的数据。为了简单,我这边就给了两列。最终形成的json要是字典格式的。表格如下:

key value
1
value key

我们创建一个Excel文件(记得扩展名要是xlsx,不然会有问题),并将上面的表格数据填入到这个文件中。这个Excel文件中的工作表只能有一个(这个工作表我命名为example1),不然后面的执行会存在问题。如下图所示:

图1

PS:关于xls和xlsx的区别大家可以去看一下这篇文章关于xls和xlsx的区别。而OpenXml之所以可以处理xlsx就是因为xlsx可以支持xml。

一些额外的知识

         虽然我可以直接贴代码告诉你表格应该怎么读,但是我还是觉得这些东西会更好的帮助我们去理解OpenXml。如果你比较心急,可以直接跳过这一部分。这部分的知识是我个人的在实践中总结,我并不保证全部正确。如果有错误我希望大家能够告诉我,我验证后就立刻修改。

关于官方文档中的xml信息

         微软中是有如何用OpenXml读Excel文件的文档:文档链接。你会看到文档中,对于表格是直接给出了一个xml的表述。对此我们也可以拿到类似的xml。我们将之前的做好的Excel文件(这个文件我取名为example1),将其扩展名改为zip然后解压。接下来你在解压出来的文件中可以找到一个名为xl的文件夹。我们打开xl文件夹就可以看到.xml文件了。接下来的一些表述中,我也会结合这些.xml文件来说明。 图2

xl文件夹下的信息

         按照官方的文档,我们读表格可以使用以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 读取文件 excelPath是文件的路径
using (SpreadsheetDocument document = SpreadsheetDocument.Open(excelPath, false))
{
WorkbookPart workbookPart = document.WorkbookPart;
foreach (var i in workbookPart.Workbook.Sheets)
{
foreach(var j in i.GetAttributes())
{
Debug.Log(j.LocalName + " " + j.Value);
}
}
}

document.WorkbookPart其实等同于我们打开了xl文件夹。而workbookPart.Workbook,则是下面是我使用工具排版后的workbook.xml内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<fileVersion appName="xl" lastEdited="3" lowestEdited="5" rupBuild="9302"/>
<workbookPr/>
<bookViews>
<workbookView windowWidth="28800" windowHeight="11775"/>
</bookViews>
<sheets>
<sheet name="example1" sheetId="1" r:id="rId1"/>
</sheets>
<calcPr calcId="191029"/>
<extLst>
<ext uri="{B58B0392-4F1F-4190-BB64-5DF3571DCE5F}" xmlns:xcalcf="http://schemas.microsoft.com/office/spreadsheetml/2018/calcfeatures">
<xcalcf:calcFeatures>
<xcalcf:feature name="microsoft.com:RD"/>
<xcalcf:feature name="microsoft.com:Single"/>
<xcalcf:feature name="microsoft.com:FV"/>
<xcalcf:feature name="microsoft.com:CNMTM"/>
<xcalcf:feature name="microsoft.com:LET_WF"/>
<xcalcf:feature name="microsoft.com:LAMBDA_WF"/>
<xcalcf:feature name="microsoft.com:ARRAYTEXT_WF"/>
</xcalcf:calcFeatures>
</ext>
</extLst>
</workbook>

这里对我们来说最重要的就是sheets,因为这个就是我们的工作表。之前我们让工作表只有一个且将其命名为example1。那么它在sheets下也很好反映了出来。如果多了几个工作表,则Sheets会如下显示:

1
2
3
4
<sheets>
<sheet name="example1" sheetId="1" r:id="rId1"/>
<sheet name="example2" sheetId="2" r:id="rId2"/>
</sheets>

而sheets下的内容就放在workbookPart.Workbook.Sheets下,上面的代码执行后我们就可以得到所有sheet和其中的属性信息。但是其中并没有我们想要的表格信息。其真正存储表格内部信息的地方在xl文件夹下的worksheets文件夹的.xml文件。

         打开xl/worksheets文件夹,你会发现sheet1.xml,如果你有两个工作表那么还会看得到sheet2.xml。这些表格名字只与其工作表对应的位置有关。比如我example1是我第一张工作表,所以它对应的内容就在sheet1.xml中。而我们要获取worksheets文件夹下的内容也很简单,我们只需要写下如下的代码就好了。

1
2
3
4
5
6
7
8
9
// 读取文件 excelPath是文件的路径
using (SpreadsheetDocument document = SpreadsheetDocument.Open(excelPath, false))
{
WorkbookPart workbookPart = document.WorkbookPart;
foreach(var i in workbookPart.WorksheetParts)
{

}
}

我们使用上述代码就可以遍历所有的工作表,而这个顺序就是我们在前面workbook.xml中Sheets的排序顺序。

         我们之前说过Excel工作表中的内容被存储在sheetX.xml(X表示数字)。我们现在就一张表所以我们只有sheet1.xml,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData">
<sheetPr/>
<dimension ref="A1:B4"/>
<sheetViews>
<sheetView tabSelected="1" workbookViewId="0">
<selection activeCell="B4" sqref="B4"/>
</sheetView>
</sheetViews>
<sheetFormatPr defaultColWidth="9" defaultRowHeight="13.5" outlineLevelRow="3" outlineLevelCol="1"/>
<sheetData>
<row r="1" spans="1:2">
<c r="A1" t="s">
<v>0</v>
</c>
<c r="B1" t="s">
<v>1</v>
</c>
</row>
<row r="2" spans="1:2">
<c r="A2" t="s">
<v>2</v>
</c>
<c r="B2" t="s">
<v>3</v>
</c>
</row>
<row r="3" spans="1:1">
<c r="A3">
<v>1</v>
</c>
</row>
<row r="4" spans="1:2">
<c r="A4" t="s">
<v>3</v>
</c>
<c r="B4" t="s">
<v>2</v>
</c>
</row>
</sheetData>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
<pageSetup paperSize="9" orientation="portrait"/>
<headerFooter/>
</worksheet>

其中真正的内容被存储在sheetData中。row表示行,c表示行中的单元格,v表示单元格中的值。但是这其中有一个奇怪的点:我们看不到我们设定的数据。比如上面sheetData中,我们并没有看到key,value等字符串。反而在其v标签中是数字。这是因为Excel中有一个共享字符串表(微软的文档链接)的概念。这个表我们也可以看得到。其就在xl文件夹下名为sharedStrings.xml。其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="6" uniqueCount="4">
<si>
<t></t>
</si>
<si>
<t></t>
</si>
<si>
<t>key</t>
</si>
<si>
<t>value</t>
</si>
</sst>

但是有时候并不是所有的文本都会被放入共享字符串表中。比如表中我们有填入‘1’,而在共享字符串表中我们并没有看到‘1’这个字符被他收入进去。所有我们要进行一些操作来判断这些字符是不是在共享文档中。这里还要注意一下,Excel中的B3我们并没有填入数据。所以在Sheet1中B3并没有被记录。在c标签中如果那些字符串是在共享字符串中的话,那么c标签则有一个t属性其等于"s"。至于代码中我们如何判断,你可以看我后面写出的示例。

碎碎念

         我大体想写的就这些。我希望这些能帮助到你。这里我再放一些官方文档链接:SpreadsheetDocumentWorkbookPartWorksheetPart

读Excel文件

         这里我只给出一些简单的操作。一些困难的操作比如表和表之间的链接,或者是使用公式计算的情况我都没有给出具体的操作。但是我觉得如果真的要去做这些操作都涉及到表格分析了,做游戏应该是不需要达到这样的程度吧。如果你真的需要可以去查看一下官方的文档

代码示例

         下面的代码只针对一张工作表的情况。为了方便输出json形式,我还使用了Newtonsoft插件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
private void Excute()
{
Dictionary<string, string> jsonInfo = new Dictionary<string, string>();
// 读取文件 excelPath是文件的路径
using (SpreadsheetDocument document = SpreadsheetDocument.Open(excelPath, false))
{
// 读xl文件信息
WorkbookPart workbookPart = document.WorkbookPart;
// 读worksheets文件信息,并得到第一张表的信息即sheet1.xml
WorksheetPart worksheetPart = workbookPart.WorksheetParts.FirstOrDefault();
// 读表格中真正的内容
SheetData sheetData = worksheetPart.Worksheet.Elements<SheetData>().First();
// 读行
foreach (Row r in sheetData.Descendants<Row>())
{
// 读列
string key = "", val = "";
foreach (Cell c in r.Elements<Cell>())
{
// 计算得到具体下标
int ind = ReturnCellIndex(c.CellReference);
// 这里防止有时候空白单元格输入数据
if (ind > 2)
{
break;
}
else if(ind == 1)
{
key = ReturnCellValue(c, workbookPart.SharedStringTablePart);
}
else val = ReturnCellValue(c, workbookPart.SharedStringTablePart);
}
jsonInfo.Add(key, val);
}
Debug.Log(JsonConvert.SerializeObject(jsonInfo,Formatting.Indented));
}
}

private int ReturnCellIndex(string strValue)
{
// 将其存储的信息转换为下标信息
int ind = 0;
foreach(char i in strValue)
{
// 经过观察,我们看到数字后直接跳过就好了
if (char.IsDigit(i))
break;
ind *= 26;
ind = (i - 'A' + 1);
}
return ind;
}

// 获取得到单元格中真正的值
private string ReturnCellValue(Cell c, SharedStringTablePart sharedStringTablePart)
{
string result = string.Empty;
// 判断是否为共享字符串表中的数据
if (c.DataType != null && c.DataType == CellValues.SharedString)
{
result = sharedStringTablePart.SharedStringTable.ChildElements[Int32.Parse(c.CellValue.InnerText)].InnerText;
}
else
{
if (c.CellValue != null)
result = c.CellValue.InnerText;
else
result = c.InnerText;
}
return result;
}