Unity 开发答题系统:用 EPPlus 读取 Excel 搭建可维护题库 做答题类项目最烦的不是写逻辑不是调UI而是改题目。去年我做一个模拟测验Demo策划同学每天发来一版改动Json文件复制粘贴、转义、查漏光整理数据就浪费了一下午。后来我换了思路直接在Unity里用EPPlus读取Excel生成题库策划改完表格发给我我扔进StreamingAssets目录就能跑再也不用手动维护Json结构。这篇文章就记录一下这套“Excel出题”的完整做法包括环境配置、表格设计、代码实现、打包期避坑以及我踩过的几个典型坑希望给同样在Unity里做答题、测验、刷题类功能的同学一个可以直接抄作业的参考。这套方案适合谁如果你正在做课程Demo、内部工具、答题小游戏或者纯粹想给项目加一个可维护的题库系统那EPPlus是性价比很高的选择。它不需要你把Excel转成CSV也不用引入一堆运行时插件直接在C#里以流的形式读取.xlsx原始内容解析成自定义对象后续不管是接UGUI还是接UI Toolkit都方便。下面我按实际开发顺序把整个流程拆开讲。1. 项目概述与整体思路1.1 核心需求解析先说清楚这个题库系统到底要解决什么。你做答题类功能核心数据无非是题干、选项、答案、解析这四类外加题型和分值这些辅助字段。很多同学习惯把这些数据写成Json或ScriptableObject但实际维护起来很痛苦改一个错别字要重新找到对应节点加一道题要数半天逗号非技术人员更是完全不敢碰。用Excel就不一样。每一行是一道题每一列是一种属性策划或者运营同学打开表格就能编辑根本不用碰代码。EPPlus做的就是让Unity程序在运行时直接读取这个Excel文件把每一行数据映射成代码里的一个题目对象。这也是整套方案最核心的闭环表格维护数据代码只负责解析和展示。另外一个容易被忽略的需求是“题型可变”。不是所有题目都是四选一还得有判断题、多选题甚至以后可能加填空题。所以题目对象在设计之初就得留好扩展余地不能写死成单选结构。我下面给的方案里题型用枚举区分选项和答案都用字符串数组承载就是为了让表格加列、改列时代码改动成本最低。1.2 技术选型为什么选EPPlus而不是其他方案Unity读取Excel有好几条路可选我建议你根据自己的场景先看清楚再决定。纯商业项目可以用专门的表格导出工具把Excel转成二进制、Json甚至代码结构自动生成但这类工具集成成本高对小团队和独立开发者来说有些重。CSV方案最轻量但格式限制多多选题答案带逗号就麻烦单元格换行更是灾难。Json方案结构灵活但正如开头说的维护体验差。EPPlus在这中间是个很好的平衡点。它是一套.NET框架下读取/写入Excel文件的开源库你只需要把一个DLL放进Unity工程写几行代码就能拿到工作表里的行列数据。它还能保留单元格公式、样式这些信息不过我们做题库用不到这么复杂的能力。有一点必须提醒EPPlus从5.x版本开始引入了商业授权判断如果你只是做学习Demo、课程设计或者公司内部工具建议直接用4.5.3.3这个版本这也是网上能找到的最后一个完全免费版本。如果是商用项目请自己去确认5.x的授权条款别稀里糊涂埋了雷。后文的所有示例代码我都默认用的是4.5.3.3的API兼容性最稳。1.3 功能范围界定我在这套实现里圈定的功能范围是运行时从Excel加载题目支持单选、多选、判断三种题型支持随机出题、答案比对、答题进度和分数统计最后能一键重新开始。界面部分就用最简单的UGUI做按钮和文本全靠代码引用不写复杂框架。这样整体代码量控制在一个游戏管理的控制器加一个读取工具类加两个数据类左右你看完完全能自己复现。如果你后面想扩展比如加倒计时、每题限时、错题本、记录历史成绩这套基础结构都留了口子。题目对象加字段表格加列加载方法顺手把这个字段读出来就行。2. 环境准备与Excel模板设计2.1 Unity工程配置与EPPlus导入先说环境配置。打开Unity工程后先把Player Settings里的Api Compatibility Level改为.NET Framework旧版本叫.NET 4.x。EPPlus依赖了不少.NET标准库下的功能如果保持默认的.NET Standard 2.0编译阶段就会冒出一堆类型找不到的错误。这是新手最容易卡住的第一关。接着把下载好的EPPlus dll拖进Assets目录建议单独建一个Plugins文件夹放它。Unity 2019及以上版本默认不会对Plugins下的DLL做额外处理直接引用命名空间就能用。有一点要注意如果你以后切换到IL2CPP打包EPPlus内部用了反射可能会被代码裁剪掉一部分功能所以这套方案现阶段建议用Mono打包做测试或者干脆只在编辑器模式下使用运行时加载打包前把题库固化成文本缓存。我后面第五部分会详细说这个坑。2.2 Excel表格结构设计表格怎么设计直接决定代码怎么写。我建议第一行固定为表头程序从第二行开始遍历数据。下表是我在这套方案里用的列结构你可以按需增删。列索引列名含义示例AId题目编号1001BType题型Single / Multiple / JudgeCTitle题干以下哪个选项是Unity中的物理引擎DOptionA选项APhysXEOptionB选项BBox2DFOptionC选项CHavokGOptionD选项DBulletHAnswer答案AIAnalysis解析Unity默认内置PhysX物理引擎判断题比较特殊选项A和选项B固定填“正确”“错误”答案填A或B就行。多选题答案直接填选项字母比如“ABD”中间不要加逗号这样程序拿到字符串后直接拆字符就能逐个比较。这里有个小细节Excel的单元格默认可能会把ABCD当成文本或公式你最好把选项列和答案列的单元格格式都手动设为“文本”避免某些时候出现科学计数法或自动转换的问题。2.3 数据规范与校验规则表格写多了你就知道出题内容可以乱但表头不能乱字段格式更不能乱。我在这套方案里定了几条硬性规则加载代码也会逐条校验不合规的行直接跳过并打印警告不会让整个读取过程崩溃。第一条题型列只认三种枚举值Single、Multiple、Judge大小写不敏感代码内部统一转成大写后再匹配。第二条选项列如果为空程序会把该选项视为不存在后续判题会遇到“答案引用了空选项”的异常所以最好保证选项数量完整。第三条答案列是判断题时只能填A或B单选填一个字母多选填一个或多个字母且所有字母必须真实存在于选项列中。第四条题干、答案、解析里不要用特殊换行符单元格内的换行在部分版本里会被解析成正则匹配不到的空字符导致结果对不上。为了降低人工填写的失误概率我强烈建议在代码加载完成后做一个control面板式的日志输出把每一道题的核心字段打出来扫一眼尤其关注解析后的答案串和题目数。亲眼看到数据进了内存比什么校验逻辑都管用。3. 核心代码实现从Excel到题库对象3.1 题库数据结构设计在写读取逻辑之前先把数据结构定下来。我设计了一个QuestionType枚举、一个Question类和一个QuestionBank类尽量保持纯粹的数据模型不掺任何Unity引擎功能这样后面做单元测试或者移植到其他框架都方便。public enum QuestionType { Single, Multiple, Judge }[System.Serializable] public class Question { public int Id; public QuestionType Type; public string Title; public Liststring Options new Liststring(); public string Answer; public string Analysis; public bool IsCorrect(string playerAnswer) { if (string.IsNullOrEmpty(playerAnswer)) return false; string a Answer.Trim().ToUpper(); string p playerAnswer.Trim().ToUpper(); if (Type QuestionType.Multiple) { return SortAnswer(a) SortAnswer(p); } return a p; } private string SortAnswer(string source) { char[] chars source.ToCharArray(); System.Array.Sort(chars); return new string(chars); } }这里有个关键点多选题判分不能直接做字符串相等因为玩家可能以不同顺序点选选项答案是“ABD”而玩家选了“ADB”。我的做法是把两边都转成字符数组排序后再比较只要选的字母集合相同就算对。这个细节看起来小实际答题系统里非常容易漏漏了就出现“明明选对了却判错”的玄学问题。QuestionBank就非常简单只包装一个List 并暴露几个便捷方法比如根据Id查找题目、返回题目总数等不写复杂逻辑。3.2 Excel读取工具类下面这段是整套方案的主干代码作用是把指定路径的.xlsx文件读取出来逐行解析成Question装进QuestionBank返回。我实际实现时把它放到了一个静态工具类里方便任何场景直接调用。using System.Collections.Generic; using System.IO; using OfficeOpenXml; using UnityEngine; public static class QuestionLoader { public static QuestionBank LoadFromExcel(string excelPath) { QuestionBank bank new QuestionBank(); if (!File.Exists(excelPath)) { Debug.LogWarning(题库文件不存在: excelPath); return bank; } using (FileStream fs new FileStream(excelPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) using (ExcelPackage package new ExcelPackage(fs)) { ExcelWorksheet sheet package.Workbook.Worksheets[0]; int rowCount sheet.Dimension.End.Row; int colCount sheet.Dimension.End.Column; for (int row 2; row rowCount; row) { if (IsRowEmpty(sheet, row, colCount)) continue; Question q new Question(); q.Id GetIntValue(sheet, row, 1); string typeText GetStringValue(sheet, row, 2).ToUpper(); q.Type ResolveType(typeText); q.Title GetStringValue(sheet, row, 3); for (int col 4; col 7; col) { string option GetStringValue(sheet, row, col); if (!string.IsNullOrEmpty(option)) q.Options.Add(option); } q.Answer GetStringValue(sheet, row, 8).ToUpper(); q.Analysis GetStringValue(sheet, row, 9); if (Validate(q)) { bank.Questions.Add(q); } else { Debug.LogWarning($第{row}行题目校验失败已跳过。题干: {q.Title}); } } } return bank; } private static string GetStringValue(ExcelWorksheet sheet, int row, int col) { object value sheet.Cells[row, col].Value; return value null ? : value.ToString().Trim(); } private static int GetIntValue(ExcelWorksheet sheet, int row, int col) { string s GetStringValue(sheet, row, col); int result 0; int.TryParse(s, out result); return result; } private static bool IsRowEmpty(ExcelWorksheet sheet, int row, int colCount) { for (int col 1; col colCount; col) { if (!string.IsNullOrEmpty(GetStringValue(sheet, row, col))) return false; } return true; } private static QuestionType ResolveType(string typeText) { if (typeText.Contains(MULTI)) return QuestionType.Multiple; if (typeText.Contains(JUDGE)) return QuestionType.Judge; return QuestionType.Single; } private static bool Validate(Question q) { if (string.IsNullOrEmpty(q.Title)) return false; if (string.IsNullOrEmpty(q.Answer)) return false; if (q.Options.Count 0) return false; if (q.Type QuestionType.Judge q.Options.Count 2) return false; return true; } }这里有一个使用细节值得展开读取单元格时我用的是Value而不是Text。Text属性是EPPlus经过格式化后给用户展示的文本会做日期换算、数字格式化等处理Value返回的是单元格的原始值更干净。做程序设计时我建议优先用Value再自己ToString避免Excel里一个普通数字“2024”被格式化成“2,024”之类的意外。还有开头提到的FileShare.ReadWrite参数。加上它之后即使Excel文件正被WPS或Office软件的进程占用着程序也能以共享模式打开读取不会因为文件被锁定而报错。这个参数建议保留别图省事去掉。3.3 数据解析与边界处理解析过程中最常见的边界情况是Excel末尾有很多“看着是空行但实际上有格式残留”的行。所以我在循环里加了一个IsRowEmpty方法判断一整行是否全为空是就跳过。这样做的好处是策划同学为了对齐美观在表格底部多发了几行空白占位程序不会误识别成一道空题目。另一个边界情况是选项数量不足。比如某道判断题只填了两个选项而代码里默认取四列选项解析后Options.Count是2Validate方法会直接拦截。对于多选题如果策划漏填了C或D选项加载日志里就会把这道题标记成“校验失败”方便你立刻倒回去修表格。再唠叨一句字段解析顺序。我在Example代码里把多选题答案做成了“ABD”这种连续字符串所以读取时不需要额外解析直接用就行。如果你希望Excel里更直观也可以用“A,B,D”这种带分隔符的写法那你就要在GetStringValue之后再做一次Split操作并过滤掉空串。你自己选一种两种都行但务必在表格规范里写清楚让填表的人有据可依。4. 运行时加载与答题流程4.1 文件位置与加载路径方案Excel文件放在哪里直接决定了读取代码长什么样。Unity工程里有三种常见位置Assets/Resources、Assets/StreamingAssets、以及任意自定义路径。Resources目录的问题在于.xlsx不是Unity原生可识别的资源格式LoadAsset加载不了还得改扩展名避过检查非常别扭。StreamingAssets是正统做法文件会原样打进安装包代码运行时可以拼出绝对路径去读既支持编辑器环境也支持真机环境。以StreamingAssets为例编辑器和真机拼路径的方式略有不同。编辑器下是Application.dataPath /StreamingAssets/题库.xlsx真机上要用Application.streamingAssetsPath拼同样的文件名。不过安卓平台上StreamingAssets其实在APK压缩包内部直接用FileStream打开会失败正确姿势是先用UnityWebRequest把文件读成byte[]再手动包一层MemoryStream交给ExcelPackage。我建议把这段加载逻辑单独封装成一个方法让它返回MemoryStream上层不用关心文件是本地还是压缩包内部。using System.Collections; using System.IO; using UnityEngine; using UnityEngine.Networking; public static class ExcelFileReader { public static IEnumerator LoadStreamingAssetBytes(string fileName, System.Actionbyte[] onSuccess, System.Actionstring onError) { string path Path.Combine(Application.streamingAssetsPath, fileName); using (UnityWebRequest request UnityWebRequest.Get(path)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { onSuccess?.Invoke(request.downloadHandler.data); } else { onError?.Invoke(request.error); } } } }拿到byte[]之后直接把QuestionLoader的入口改成接收Stream或者byte[]不要让它自己猜路径。这样运行时和编辑器加载逻辑完全统一编辑器测试也能走StreamingAssets路径提前暴露打包后的路径问题。4.2 答题流程与核心控制逻辑读取Excel只是第一步真正让题库转起来的是GameManager。它负责加载题目、洗牌、控制当前题号、接收玩家答案、判断对错、累计分数。我下面给一个精简但完整的实现方便你直接套到自己的场景里。using System.Collections; using System.Collections.Generic; using UnityEngine; public class GameManager : MonoBehaviour { public static GameManager Instance; [Header(UI引用)] public TextAssetText titleText; // 按你的UI组件替换 public UnityEngine.UI.Text titleUI; public UnityEngine.UI.Text optionsUI; public UnityEngine.UI.Text resultUI; public UnityEngine.UI.Text scoreUI; private ListQuestion questions new ListQuestion(); private int currentIndex 0; private int score 0; private Question currentQuestion; private void Awake() { Instance this; } private IEnumerator Start() { yield return StartCoroutine(ExcelFileReader.LoadStreamingAssetBytes(QuestionBank.xlsx, bytes { using (MemoryStream ms new MemoryStream(bytes)) using (ExcelPackage package new ExcelPackage(ms)) { // 这里复用QuestionLoader按Stream解析的逻辑 } }, error Debug.LogError(error))); } }核心的状态流我建议拆成三个函数StartRound()负责从题库里拿一道题并刷新UIOnSubmitAnswer(string playerAnswer)负责判题并登记分数NextQuestion()负责推进题号并检查是否结束。这样逻辑边界清楚以后加倒计时或者加错题本都方便插入。洗牌逻辑用Fisher-Yates就够了遍历数组每次随机交换。真没必要引第三方随机工具类。要注意的是重置游戏时必须重置currentIndex和score否则玩家点了一次重新开始题目序号还接着上次跑分数也越加越离谱。4.3 UI交互与答案比对实现答题界面的UI部分用最简单的UGUI就好一个Text显示题干一个Text显示四个选项一个InputField或一组Toggle接收答案一个Button提交判断。我实际做的时候没有给每题单独建GameObject而是复用一个模板动态刷新文本这样表格里加再多的题都不需要改UI。单选和判断可以直接用InputField输入“A”“B”这类字符串拿到的值就是玩家答案。多选题如果UI用ToggleList提交时把你勾选的那几个字母拼成一个字符串再交给Question.IsCorrect比对。这里有一个体验层面的小建议提交按钮的点击事件里区分“判题”和“下一题”两种状态第一次点击显示解析和结果第二次点击才跳下一题不然玩家连题目都看不清就闪过去了。比分统计上我做的是每道题答对得10分单选和多选都是。如果你想按难度加权在Excel里增加一个Score列Question类加一个Score字段Parse时读进来GameManager累加时直接用这个字段就行。流程代码不需要改只改得分来源。5. 常见问题与排查实录5.1 编译报错与授权问题先处理最多人问的编译问题。如果你把EPPlus dll放进来后脚本报“找不到OfficeOpenXml命名空间”99%是Api Compatibility Level设置没改。你在Player Settings里把Scripting Runtime Version和Api Compatibility Level都调到.NET 4.x/ .NET Framework然后重启编辑器问题基本消失。还有一类集中在打包阶段报错里带“LicenseContext”字样。EPPlus 5.x以上会在没有设置授权上下文时抛异常最简单的解决方法是回到4.5.3.3版本。如果因为某些功能必须用5.x那就在程序初始化时设置// 这部分只在EPPlus 5.x及以上需要 OfficeOpenXml.ExcelPackage.LicenseContext OfficeOpenXml.LicenseContext.NonCommercial;务必自行确认授权条款别只看代码能跑就上线。5.2 路径读取失败与中文乱码很多同学第一次做流读取时发现编辑器下没问题打包后读不到文件。原因是我前面说的安卓StreamingAssets在包内不能直接用FileStream。判断诀窍很简单如果Application.streamingAssetsPath以“jar:file:///”开头就别用FileStream改走UnityWebRequest。iOS上StreamingAssets虽然在应用沙盒里路径处理也可能有差异统一用UnityWebRequest读byte[]是最大公约数。中文乱码的锅倒是很少扣在EPPlus头上。xlsx内部本质是UTF-8存储的XMLEPPlus解析出的字符串天然支持中文。如果你看到读取后全是问号先检查Excel文件是否真的保存成xlsx而不是另存成的CSV再检查有没有在读取后调用Encoding.Default转码这类多余操作。转码这个动作不是必须的很多乱码反而是因为强行转成了系统ANSI编码。5.3 题目数据对不上与判题错误这类问题最隐蔽。举例题目答案是“A”玩家选“A”但判题返回false。排查思路从两个点下手Excel里答案列是不是包含不可见字符比如全角空格判断题答案如果填了“正确”而不是“A”程序自然匹配不上。我在Question类的IsCorrect里全部做Trim和ToUpper就是为了把这种不可见字符和大小写问题消掉。多选判错则多半是没做排序参考3.1节的方法就能解决。另外一个容易踩的是“解析失败但日志不显示”。Validate方法里我用了Debug.LogWarning如果Import时脚本挂在场景中但GameObject被隐藏或者日志级别被过滤很容易漏网。建议在编辑器下打开Console把所有Log级别显示出来加载完成后再扫一遍警告条数。5.4 性能优化与项目规模预估EPPlus读取一张5000题、9列的表格在PC上耗时大概在几百毫秒级别放在加载界面完全能接受。但如果表格膨胀到几万行或者你的游戏目标平台是低端手机就要考虑两个优化方向。第一个方向是编辑器缓存在编辑器下读取一遍Excel把解析好的QuestionBank序列化成Json或二进制asset运行时不走EPPlus直接加载缓存文件。第二个方向是按需读取把题库拆成多个Excel文件一关一个进入关卡时只加载当前文件。这两种方案我都试过对于“简单题库”的需求来说第一种最实用改动成本低第二种适合超大规模题库但对文件组织和命名规范要求更高。如果你只是做课程作业或者开发期Demo我最诚实的建议是先别优化。几千题的加载耗时给玩家一个加载转圈就掩盖掉了优化工作等真到了性能瓶颈再动手不迟。最后分享两个小习惯做成这套东西之后我发现至少有两点值得坚持。第一每次改完Excel后打开编辑器跑一遍加载流程看Console里有没有警告不要等到打包才验证数据否则你会发现错别字和错答案在真机上出现的概率远高于编辑器里。第二Excel里所有表头和规范说明单独放一个隐藏Sheet别跟题目混在一起并且把这个隐藏Sheet锁死防止有人误删列导致整个文件结构错乱。还有个小技巧如果你跟我一样经常需要在Excel里反复调整题目可以在加载代码里加一个快捷键比如按下某个键就重新加载一次题库省去反复重启游戏的麻烦。我是绑定在F5上的测试的时候非常舒服改完表格回来按一下就是新题目工作效率能明显提升一个档次。最后再多说一句这套方案的代码结构并不复杂但它帮我省下了大量重复劳动。希望这篇记录也能让准备做类似功能的你少走几步弯路。