把以前写过的 Java 基础小项目翻出来重新过一遍,是件挺有意思的事。我最近做的就是这件事——图书管理系统,一个在 Java 入门圈子里被写烂了的项目,但正因为常见,它反而是检验基础扎实程度的试金石。这篇文章就是我的自用复盘,把我重新梳理需求、拆解面向对象设计、排查历史代码问题的过程整理出来,希望对正在学 Java、准备面试题或者打算做第一个完整练手项目的朋友有点实际帮助。
你可能也遇到过这种情况:教程看懂了,语法背下来了,一到自己动手写一个能跑起来的完整系统,就不知道从哪下手。图书管理系统恰好能把这个断层补上——它小到一个人几周能写完,又大到能覆盖集合、面向对象、异常、IO、日期处理这些 Java 基础的核心考点。复盘过程中我最大的感受是:真正让我提升的,不是写了多少行代码,而是回头审问自己"每一行代码为什么这么写"。
1. 为什么拿图书管理系统复盘 Java 基础
1.1 一个项目把零散考点串成了线
在重新打开这个项目之前,我刚刷完一波 Java 面试题,状态是那种"每条题都眼熟,但两两之间连不上"的混沌感。比如集合框架里的 ArrayList 和 HashMap,面试题会问你区别、底层结构、扩容机制;语法题里会问你 equals 和 hashCode 为什么必须一起重写;IO 部分会问字节流和字符流怎么选。这些单独拎出来我都能说上几句,但让我用它们组装一个真实系统,我发现自己其实是懵的。
图书管理系统最妙的地方就在于,它是天然的"考点粘合剂"。
- 要管理图书列表、读者列表、借阅记录,就必须用集合类,而且不同场景会逼着你做选择。
- 要用对象表达图书、读者、借阅记录,就必须理解类和对象、封装、构造器。
- 要处理借书日期、还书日期、超期天数,就得碰时间 API。
- 要把数据保存下来,就得接触文件 IO 或序列化。
- 用户输入不合法、借了一本不存在的书,逼着你处理异常。
这些内容散在 Java 基础教材的不同章节里,刷题时像一颗颗珠子,但做一个系统就是把它们串成一条链。这个过程会暴露学习中的假理解和真盲区。
1.2 技术范围刻意收紧:不引入任何框架
做复盘的时候我不是没动过念头:要不要直接上 Spring Boot?毕竟现在出去面试,问的最多的就是框架。但认真想了想,这个项目的定位是"复盘 Java 基础",如果我引入 Spring Boot,那就要处理依赖注入、自动配置、Maven 依赖管理、嵌入式容器,这些内容反而会掩盖掉最本质的东西——纯 Java 语法、面向对象设计、集合与 IO 的实际运用。
所以我故意把技术范围压到最低:纯 JDK,控制台菜单操作,数据通过文件持久化,不联网、不用数据库驱动、不引第三方包。这样当你运行 java 命令启动程序时,运行的是最原汁原味的 Java SE 程序;当你写一个 HashMap 查询时,用的也不是框架里封装好的工具,而是 Java 自带的核心类。
我并不是说框架不重要,而是说复盘基础时应当聚焦。如果直接用 MyBatis 把 Book 映射到数据库表,你可能再也想不起来 ObjectInputStream 和 serialVersionUID 这种基础面试题在哪见过。先把根基夯实,再谈上层建筑,是这类自用复盘项目比较理性的原则。
1.3 复盘的第一件事:把项目的代码从"能跑"变成"能讲"
这次复盘我做的最重要的一件事,不是重构代码,而是给每个模块写"讲解稿"——用口述的方式回答三个问题:这段代码在做什么?为什么用这种方式做?换一种方式会有什么问题?
比如项目里有个根据图书编号查询的方法,原来用的是 for 循环遍历 ArrayList 一个个比对编号。我就在旁边写下讲解稿:O(n) 的时间复杂度,数据量小无所谓;但如果用 HashMap 以编号为 key、Book 为 value,查询复杂度就变成 O(1)。写下来的过程就是逼自己把"知道"变成"能讲明白"的过程。后面我会专门用一节来说这类选择,这里先不展开。
整理完之后,我对这个项目的定位有了清晰判断:它不是拿来给简历加分的项目,也不是复杂的商业系统,而是我自己的 Java 基础训练场。这个判断直接影响后面所有删改——凡是偏离"训练基础"的功能都被砍掉,凡是涉及基础考点的细节都被我刻意保留并强化。
2. 需求边界:自用项目要把功能收敛到哪一步
2.1 核心功能清单与我的取舍
复盘的第一步不是写代码,而是重新列需求。很多人写项目一上来就开代码,写到一半发现功能铺得太开,到处是残缺逻辑。我的做法是先在纸上画出功能清单,再根据"复盘 Java 基础"这个目标做一轮取舍。
最后保留的功能如下:
| 功能模块 | 具体说明 |
|---|---|
| 图书入库 | 录入编号、书名、作者、分类、库存总数 |
| 图书查询 | 按书名模糊查询、按编号精确查询 |
| 图书下架 | 下架时检查是否有未归还的借阅记录 |
| 读者管理 | 注册读者,限制最大可借数量(默认 3 本) |
| 借书操作 | 校验读者、校验库存、生成借阅记录、记录借书日期 |
| 还书操作 | 登记归还日期、自动计算超期天数 |
| 借阅明细 | 查看某本书或某读者的历史借阅记录 |
| 数据持久化 | 启动时加载文件,每次变更后保存到文件 |
被砍掉的功能也不少:图书封面图片、按出版社筛选、图书排行榜、批量导入导出 Excel,这些都挺好玩的,但和"Java 基础复盘"的目标错位。它们不依赖核心语法,反而会引入文件解析、GUI 或第三方库,增加噪音。
选择功能时要问自己一句话:这个功能能不能让我更理解某个 Java 基础知识点?如果答案是"不能",那就先放着。功能凹得越少,每条代码路径越能被反复打磨,这是自用项目和大项目最不同的一点。
2.2 数据存储选型:内存、文件还是数据库
数据怎么存,是每个写系统的人绕不开的题目。这个项目我最终选了"文件持久化",没有上数据库,原因不是数据库难,而是它太常见了,容易掩盖问题。
做个对比就明白了:
| 存储方式 | 优点 | 缺点 | 对基础复盘的帮助 |
|---|---|---|---|
| 纯内存(ArrayList) | 简单,启动快 | 重启丢数据 | 能验证集合基本用法,但不够完整 |
| 文件持久化(序列化/文本) | 实现简单,能练 IO 与异常 | 不适合并发,无查询优化 | 完整覆盖 File、Stream、序列化考点 |
| 关系型数据库(MySQL) | 可靠、查询强 | 需要 JDBC 或框架,环境复杂 | 更多是 JDBC/框架知识,偏离 SE 基础 |
如果你做的是和朋友一起用的项目,我肯定建议上数据库;但复盘 Java 基础时,文件持久化其实更有教学价值。你得自己设计存储格式,自己写加载逻辑,自己处理文件不存在、文件损坏、反序列化失败这些情况。
在这个项目里我用的方案是对象序列化:把 List<Book>、List<Reader>、List<BorrowRecord> 封装到一个 LibraryData 对象里,启动时 ObjectInputStream.readObject() 读出来,变更后 ObjectOutputStream.writeObject() 写回去。
2.3 角色和权限简化到同一入口
原本我还在脑补管理员登录、读者登录、密码加密这些需求,后来全部砍掉了。原因很现实:Java 基础阶段如果做权限系统,最后大概率变成简单地 if (password.equals("123456")),这种代码对业务逻辑没有帮助,还容易引入"学了个寂寞"的挫败感。
简化后的入口就是一个控制台菜单:
- 图书管理(录入、查询、下架)
- 读者管理(注册、查看)
- 借还书
- 借阅记录查询
- 退出系统
没有登录,不做鉴权,所有操作由同一个用户一揽子执行。这当然不像真实系统,但它让我们把注意力集中在对象建模、业务规则校验和异常处理上。一个自用复盘项目不需要在"像一个真实系统"这件事上过度用力。
3. 面向对象建模:图书、读者、借阅记录怎么设计
3.1 三个实体类的职责划分
第一次写这个项目时,我差点把所有东西塞进一个 Book 类里:库存、读者信息、借阅记录全用字段堆着。后来发现代码写得像一锅粥,才慢慢理解"职责单一"的意义。
最终我划分为三个核心类:
- Book:图书编号、书名、作者、分类、库存总数、当前可借数量。
- Reader:读者编号、姓名、已借数量、最大可借数量。
- BorrowRecord:借阅记录编号、图书编号、读者编号、借书日期、应还日期、实际归还日期。
每个类内部只保留和它自身属性相关的数据。类与类之间不通过嵌套引用纠缠,而是用编号关联,这其实借鉴了关系型数据库的外键思想,也让 BorrowRecord 能独立存在——你查某本书的历史记录时,不需要从 Book 对象里再挖一个链表出来。
对应的属性基本都是 private,并提供 getter/setter。但这里我想提个容易被忽视的点:封装不是为了让你写一堆 getter/setter,而是为了控制外部对对象状态的修改路径。比如 Book 的库存字段,我做了一个专门的 decreaseStock() 方法,外部不能直接 stock 减 1,必须通过方法内部校验"当前可借数 > 0 才允许减"。这就是把业务规则放进对象内部,而不是让调用方随处写 book.setStock(book.getStock() - 1)。
3.2 equals 和 hashCode 在 Book 上的实现复盘
以前学习 equals 和 hashCode 时,我对它的理解停留在"面试要背"的层面,直到这次复盘才真正体会到它存在的必要性。
项目里借书前要判断某个编号的书是否存在,最直觉的做法是 contains 一个 Book 对象。但 contains 底层调用的是 equals,如果 Book 类不重写 equals,那它就退化成"两个对象引用地址相同"比较。你 new 了一个编号和已有书籍完全相同的 Book,但 contains 却返回 false,程序就会认为这本书不存在。这不是逻辑问题,是没遵守对象等价规则。
我最终在 Book 中按"编号相同则相等"来重写 equals 和 hashCode:
@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Book book = (Book) o; return bookId.equals(book.bookId); } @Override public int hashCode() { return Objects.hash(bookId); }同时重写它们的原因在于:Java 规定两个对象 equals 相等时 hashCode 必须相等,否则 HashMap、HashSet 里基于 hashCode 定位的存储结构就会失效。你猜会发生什么?同一个编号的 Book 放进 HashSet,因为 hashCode 不一样,它会被当成两个不同的元素,重复录入怎么查都查不出问题。这个知识点在面试题里天天见,在项目里实际操作过一遍后,印象完全不一样了。
3.3 借书还书的逻辑为什么放进服务层而不放进实体类
设计时有个纠结:借书逻辑是不是写成 Book 的方法 borrow() 更直接?后来我否定了。
如果 borrow() 写在 Book 里,那这个方法要操作 Reader 对象,还要创建 BorrowRecord,等于图书类知道了太多和它无关的类存在。一次借书至少要检查三个条件:读者存在、图书可借数量足够、读者未超过借书上限。这些规则横跨三个实体,放在任何一个实体类里都会让对方被迫破坏封装。
所以我的结构是:实体类只管数据,一个 BookService 类专门编排业务规则。方法大概长这样:
public void borrowBook(String bookId, String readerId) { Book book = findBookById(bookId); Reader reader = findReaderById(readerId); if (book == null) { throw new BusinessException("图书不存在"); } if (book.getAvailable() <= 0) { throw new BusinessException("库存不足"); } if (reader.getBorrowedCount() >= reader.getMaxBorrowLimit()) { throw new BusinessException("超出可借数量限制"); } book.decreaseStock(); reader.increaseBorrowedCount(); recordList.add(new BorrowRecord(bookId, readerId, LocalDate.now())); }这种"实体类只当数据容器,服务类负责编写规则"的分层方式,以后学习 Spring 的 Service 层时会无缝衔接。我不是为了刻意引入专业词汇,而是这个项目让我第一次理解了什么叫"职责划分带来的可读性提升"。
4. 核心业务逻辑实现与关键考点
4.1 图书查询:ArrayList 和 HashMap 该怎么选
这个项目的查询功能给我上了很好的一课。图书总量约几千本,用 ArrayList 遍历查找完全够用,代码也直观:
public Book findByBookId(String bookId) { for (Book book : bookList) { if (book.getBookId().equals(bookId)) { return book; } } return null; }但有一次我脑子抽风,在读者列表做同样功能的查询时,代码里又来了一份相似的遍历。两处逻辑写法非常像,却没有任何复用。复盘时我把这两处统一了:如果查询条件是按编号精确匹配,就用 HashMap 以编号为 key、对象为 value;如果查询条件是按书名模糊匹配,则保留 List 遍历。
private final Map<String, Book> bookMapById = new HashMap<>(); // 键:图书编号,值:Book 对象 public Book findByBookId(String id) { return bookMapById.get(id); }查编号的复杂度从 O(n) 变成 O(1),代码从三行变一行。更重要的是我由此理解了集合选型的真实场景:遍历适合"条件不固定"的查找,散列适合"键确定"的查找。写项目时不存在绝对正确,只有适不适合当前场景。
4.2 借书与还书的流程控制:不变量校验是核心
借书流程并不只是"找到书、库存减一"。对任何业务系统来说,真正考验设计能力的是"不变量校验"——即保证系统在任何时刻都满足稳定规则。
借书时有几个不变量:
- 每本被借出的书都必须有对应的借阅记录
- 图书的可借数量不能变成负数
- 读者的已借数量不能超过最大可借数量
- 同一本书同一时间不能同时被借给两个读者
这些规则我在设计时写成了独立的校验方法,而不是散落在菜单分支里。还书时也要校验:归还日期不能早于借书日期,重复还书要提示错误。用 BusinessException 包装业务错误,再在上层统一 catch 并打印中文提示,控制台程序就不会动不动崩栈。
这种"边界检查第一、正常逻辑第二"的写法,在面试题里对应的名词是"防御式编程",但项目里你不需要背概念,你背不出来,你只是在做事时自然会把"如果读者不存在怎么办""如果日期是 null 怎么办"这些问题都问一遍。
4.3 日期与超期计算:别再用 Date 了
说到日期处理,我第一次写这个项目用的是 java.util.Date,也算是一次实打实的踩坑。
用 Date 算超期天数时,我最初把两个日期 getTime() 的毫秒差除以一天的毫秒数,代码大致是:
long days = (returnTime.getTime() - borrowTime.getTime()) / (1000 * 60 * 60 * 24);这个做法有大坑:毫秒除法会忽略时区、夏令时这些因素,如果项目运行环境涉及夏令时切换,一天可能是 23 小时或 25 小时,算出来会有偏差。对于图书借阅这种周期以天为单位的系统,更好的做法是用 LocalDate + Period,日期不再和时间绑死,精确稳定得多:
LocalDate borrowDate = record.getBorrowDate(); LocalDate dueDate = borrowDate.plusDays(30); LocalDate actualReturnDate = record.getActualReturnDate() == null ? LocalDate.now() : record.getActualReturnDate(); long overdueDays = Period.between(dueDate, actualReturnDate).getDays();如果 actualReturnDate 晚于 dueDate,overdueDays 就是超期天数;否则它是个负数,取 0 即可。顺带一提,应还日期我用 borrowDate.plusDays(30) 计算而不是存死值,这样如果之后想改借期规则,只需要改这一处。
4.4 数据持久化:序列化与反序列化
文件持久化是最容易被"能跑就行"心态糊弄过去的部分。很多人写控制台程序玩,退出就退出,下次启动全乱套。我这次复盘把持久化完全补齐了。
存储结构我上面提过,用一个 LibraryData 对象包装三个 List。保存时:
public void saveData() { try (ObjectOutputStream oos = new ObjectOutputStream( new FileOutputStream(DATA_FILE))) { LibraryData data = new LibraryData(bookList, readerList, recordList); oos.writeObject(data); } catch (IOException e) { System.err.println("数据保存失败:" + e.getMessage()); } }加载时逆向操作,但要额外处理两个问题:文件不存在时返回空数据,文件损坏或类结构变化时抛出的异常要捕获并提示,不能直接让程序崩溃。
这里有个隐藏考点:序列化类必须声明 serialVersionUID。如果你没有写,Java 会根据类结构自动生成一个默认值,只要你不再改字段,就不会有问题。但我这次复盘时给 Reader 增加了一个"联系电话"字段,旧数据文件反序列化就报了 InvalidClassException。解决办法就是每个实体类都显式声明 serialVersionUID,改字段后仍能优雅兼容。这个异常在面试题里被问到过,但只有自己写出过这种崩溃才能记得格外牢。
5. 复盘中踩过的坑:五处最容易翻车的 Java 基础细节
5.1 Integer 比较用 == 的翻车现场
我的借书逻辑里有个判断:读者已借数量是否达到上限。因为已借数量是 int,而我从某个配置文件读出的上限是 Integer,写成了:
if (reader.getBorrowedCount() == maxBorrowLimit) { // 提示超出上限 }看着没问题,但 maxBorrowLimit 是 Integer 对象,int 与 Integer 比较时,会自动拆箱成 int 再比较,所以这个用法本身没出错。真正的坑在另一个地方:我在统计书籍数量时用了两个 Integer 对象直接比较:
Integer total = calculateTotal(); Integer expected = 100; if (total == expected) { ... }如果 total 和 expected 都在 -128 到 127 之间的缓存区间,它竟然能比较成功;一旦超出 127,比如 total 是 128,它就直接返回 false。这是因为 Integer 在 -128 到 127 之间有缓存池,== 比较的是地址而不是值。最终的教训很简单:包装类型比较值,一律用 equals 或先调 intValue(),别和我说你记得住,不写一次这种 bug 你记不住。
5.2 for-each 循环里删元素的 ConcurrentModificationException
删除某本书时,最自然想到的写法是:
for (Book book : bookList) { if (book.getBookId().equals(id)) { bookList.remove(book); } }跑起来立刻抛出 ConcurrentModificationException。原因不是"一边遍历一边删除就不允许",而是 for-each 在循环内部使用的是迭代器,每次 next() 都会检查 modCount 是否和预期一致。你直接调用 list.remove() 修改了列表结构,迭代器发现 modCount 变了,于是宁可错杀也不放过。
两种正确写法:
// 方式一:用迭代器自己的 remove Iterator<Book> it = bookList.iterator(); while (it.hasNext()) { Book book = it.next(); if (book.getBookId().equals(id)) { it.remove(); } } // 方式二:JDK 8 的 removeIf bookList.removeIf(book -> book.getBookId().equals(id));我最终选了 removeIf,简练而且意图清晰。复盘时我顺手把"为什么迭代器删除是安全的"理了一遍:因为 it.remove() 会同步修改迭代器内部的 expectedModCount,让迭代器和集合的认识保持一致。
5.3 Scanner 的 nextInt 和 nextLine 混用
控制台菜单常用 Scanner,于是一个经典问题出现了:
int choice = scanner.nextInt(); String bookId = scanner.nextLine(); // 结果读到的是空串nextInt 只读取数字,不会消费数字后面的换行符;nextLine 从换行符开始读,读到的就是空内容。于是用户输入菜单选项后,程序跳过了图书编号输入,直接报错。
我处理的方式有两种,最终选了最稳的:
// 读整行再解析,避免残留换行 int choice = Integer.parseInt(scanner.nextLine());如果用户输入非数字,Integer.parseInt 抛异常,再被 catch 住提示重新输入。这个方法比"nextLine 清空缓冲"的可读性更好,也不会因为 nextInt 和 nextLine 混用而产生边界 bug。
5.4 重启数据丢失与写文件的时机
最初版本我只在退出程序时保存数据。后来测试时不小心直接点了窗口关闭按钮,或者程序抛了个异常提前退出,所有数据全没保存。这让我意识到"退出时保存"并不可靠,正确做法是每次数据变更后立即保存。
于是我把保存逻辑从"某个具体业务方法结束时"抽成一个公共方法,在 createBook、deleteBook、borrowBook、returnBook 这些方法末尾调用。牺牲一点性能换取稳定性,对文件存储的体量来说完全值得。
保存时机还有一个细节:不要在对象状态改到一半时保存。比如借书方法是先检查、再改库存、再生成记录,保存必须放在所有字段变化完成后。否则文件里存下来一个中间态数据,下次启动加载后,图书库存减少了但借阅记录没有生成,业务数据就错乱了。这点和数据库事务里的"最后提交"思想一致。
5.5 控制台中文乱码的尴尬
项目在 IDE 里运行正常,一拿到系统命令行运行就出现中文乱码,这个问题困扰了我很久。最终发现是文件编码不一致:IDE 默认用 UTF-8 保存,而 Windows 命令行默认用 GBK 解码。
虽然我不打算在命令行里长期运行这个控制台程序,但乱码问题本身提醒我注意编码一致性。解决方式是在读写文件时明确指定字符集。不过用 ObjectOutputStream 序列化时,字符串编码由 Java 内部机制处理,乱码问题基本出现在打印输出阶段。稳妥的经验是:IDE 统一设置 UTF-8,保存和运行用同一种编码;如果追求跨平台兼容,尽量在涉及 Reader/Writer 的地方显式传入 UTF-8,不要依赖平台默认编码。
6. 复盘后的收获与可以接着升级的方向
6.1 这次复盘帮我补齐了哪些知识点
整个复盘下来,收获最大的不是某个具体 API,而是三个层面认识的转变:
第一,对象等价性和集合存储机制是联动的关系。equals 怎么定义、hashCode 怎么实现,直接决定了对象放进 HashSet、HashMap 后的行为。过去我总觉得这是两套知识,现在知道它们是同一件事。
第二,异常处理不是"try-catch 包起来就算会了"。业务异常和系统异常要有所区分:预期内的业务错误,比如库存不足,应该用自定义业务异常给用户友好提示;IO 异常、序列化异常这类不可预期的问题,要打印详细错误并保证程序不直接崩溃。两者在代码里不能混为一谈。
第三,方法命名和组织结构决定了代码能不能被讲清楚。重构后的方法名基本都是"动词+宾语",比如 saveData、findBookById、borrowBook、returnBook。任何一段代码拿到面试官面前,不需要额外解释,对方扫一眼函数名就知道你的思路。这一点在真实工作里比任何骚操作都重要。
6.2 如果重写一遍,我会怎么改
复盘必然少不了"如果重来"的假设。我的重写方案大概是这样:
首先,包结构更清晰。将来若想做成 web 项目或者接数据库,包结构可以先按 entity、service、dao、ui 四层拆。entity 放实体,dao 放数据访问,service 放业务规则,ui 放控制台交互。现阶段用 Main 类直接驱动也能跑,但会越写越乱。
其次,接口编程。给 BookService 定义一个接口,再用 BookServiceImpl 实现。目前只有一个实现类,确实看不出接口的价值,但如果以后想把存储从文件换成数据库,接口的存在可以让上层调用不用改动太多。我也理解一个道理:接口不是为当前需求准备的,是为变化点准备的。
最后,泛型和防御式编程再强一点。比如 borrowBook 方法的入参如果传值为空字符串或 null,方法开始就要进行参数校验,而不是等业务运行到一半才发现找不到数据。NullPointerException 是基础阶段最常遇到的错误,但大多数情况下它可以在代码入口就避免。
6.3 给同样在写 Java 基础项目的人几句实在话
如果你也想拿图书管理系统做自己的复盘或练手项目,我有几条实在建议:
一是不要追求功能多,追求"每个功能都能讲清楚"。与其做十个半吊子功能,不如把录入、查询、借还书这三个核心功能做到逻辑严谨、状态一致。所谓"状态一致",就是程序任何时候被强制中断,重启后数据都不会出现自相矛盾。这个能力在真实系统里相当值钱。
二是大胆用命令行交互。GUI 或 Web 界面会分散你对 Java 基础的注意力,控制台反而让你专注在核心逻辑上。等你把核心逻辑理顺,以后套一层界面大概率只是时间问题。
三是写代码前先列计算和校验规则。比如借书,至少要想清楚:书不存在怎么办、库存为 0 怎么办、读者超限怎么办、重复借同一本书怎么办。把这些场景写在小本子上,再对照代码看是否都覆盖了。这比背十道面试题管用。
四是要保留一份"复盘笔记"。我专门建了一个 markdown 文件,记录每次排查 bug 的根因、解决思路和相关知识点。这个文件的价值会在你三个月后回头看时迅速放大,很多当时觉得折磨的问题,后来反过来成了最牢固的记忆。
说到底,Java 基础并不在于你背了多少关键词,而在于你能否用这些关键词搭出一个逻辑自洽的小世界。图书管理系统恰好是一个足够小又足够完整的世界。我在这次复盘中重新认识了 equals、hashCode、集合选型、序列化、时间 API 这些老面孔,它们不再是孤立的面试题,而是能在关键位置真正承担职责的零件。如果你也拿着类似的项目在复盘,别急着堆功能,先把自己代码里的"为什么"都答上,收获一定会比多写几个界面大得多。