← Java 基础教程

第 29 / 40 课 · 免费教程

保存世界

用 UTF-8 保存数据,跨过程序重启

100–120 分钟本章使用 JDK 21,无需预览功能。集合与程序实践

本课作品

制作计分板存档 WorldSave

程序退出,内存里的分数也会消失。给它一份中文可读的文本存档,明确覆盖与追加,逐行检查损坏数据,用往返测试证明保存可靠。 前置背包:Map、字符串拆分和异常处理。今天让内存里的钻石不再随着程序关闭掉进岩浆。

开始前:建议先完成第 26、28 课

相对路径从程序工作目录出发
  1. Path描述位置,不创建文件
  2. 相对路径由启动工作目录决定
  3. 绝对路径帮助找到真实存档

01 / 12

开始前:先预测,再让程序回答

本章使用 JDK 21,无需预览功能。故事借用方块世界,示例运行在普通 Java 控制台;数值和规则若是课程模型,都会明确说明。

每个普通示例都是完整独立文件,按框上标注的文件名保存。各示例放在不同文件夹,避免辅助类重名。完整项目分段时,按顺序接在同一个文件中。

终端命令:中文占位符换成实际文件名与类名

javac -encoding UTF-8 示例文件名.java
java 示例类名
  • 预测:先写预期输出,或画清引用和数据的变化。
  • 验证:运行后逐行核对,再只改一个条件。
  • 解释:说清结果来自 Java 规则、课程合同还是输入数据。

02 / 12

Path 指向存档位置

Path 只描述位置,不代表文件已经存在。相对路径的实际位置取决于程序从哪里启动。

相对路径从程序工作目录出发
  1. Path描述位置,不创建文件
  2. 相对路径由启动工作目录决定
  3. 绝对路径帮助找到真实存档

L29Example01.java · 完整独立示例

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class L29Example01 {
    public static void main(String[] args) {
        Path path = Path.of("saves", "lesson29-scores.txt");
        System.out.println(path.getFileName());
        System.out.println(path.toAbsolutePath().isAbsolute());
    }
}

运行结果

lesson29-scores.txt
true

03 / 12

UTF 8 保存中文

编码是字符与字节之间的规则。保存和加载使用同一编码,中文名字才不会变成问号方块。

写入和读取使用同一字符集
  1. 字符例如小马=12
  2. UTF-8写入和读取明确相同编码
  3. 结果核对中文内容保持一致

L29Example02.java · 完整独立示例

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class L29Example02 {
    public static void main(String[] args) throws IOException {
        Path folder = Files.createTempDirectory("java-utf8-");
        Path file = folder.resolve("scores.txt");
        try {
            Files.writeString(file, "小马=12\n", StandardCharsets.UTF_8);
            System.out.print(Files.readString(file, StandardCharsets.UTF_8));
        } finally { Files.deleteIfExists(file); Files.deleteIfExists(folder); }
    }
}

运行结果

小马=12

04 / 12

覆盖 追加与目录

完整计分板通常每次覆盖保存,聊天日志可能追加。选择错误会导致旧记录重复或新记录消失。

默认写入会覆盖旧内容
  1. 覆盖文件只含新内容
  2. 追加保留原文后添加
  3. 目录createDirectories 创建父目录
验收对照表
任务方式结果
完整存档覆盖文件只含最新状态
事件日志追加新内容放到末尾
首次创建目录createDirectories已有目录也安全

L29Example03.java · 完整独立示例

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class L29Example03 {
    public static void main(String[] args) throws IOException {
        Path folder = Files.createTempDirectory("java-write-");
        Path file = folder.resolve("log.txt");
        try {
            Files.writeString(file, "旧\n", StandardCharsets.UTF_8);
            Files.writeString(file, "新\n", StandardCharsets.UTF_8);
            Files.writeString(file, "追加\n", StandardCharsets.UTF_8,
                StandardOpenOption.APPEND);
            System.out.print(Files.readString(file, StandardCharsets.UTF_8));
        } finally { Files.deleteIfExists(file); Files.deleteIfExists(folder); }
    }
}

运行结果

新
追加

05 / 12

每条记录占一行

文本存档先从可读格式开始。一行一条记录,等号左边是玩家名,右边是整数分数。

简单格式让人和程序都能检查
  1. 名字非空,不含等号和换行
  2. 分数非负 int 整数
  3. 一行记录名字=分数

L29Example04.java · 完整独立示例

public class L29Example04 {
    static boolean validName(String name) {
        return name != null && !name.isBlank() && name.equals(name.strip())
            && !name.contains("=") && !name.contains("\n") && !name.contains("\r");
    }
    public static void main(String[] args) {
        System.out.println(validName("小马"));
        System.out.println(validName("A=B"));
        System.out.println(validName(" Alex "));
        String[] parts = "小马=12".split("=", 2);
        System.out.println(parts[0]);
        System.out.println(Integer.parseInt(parts[1]));
    }
}

运行结果

true
false
false
小马
12

06 / 12

逐行解析并拒绝坏数据

加载器不能假装文件永远完美。每一行都要检查分隔符、名字和数字。

split limit 2 保留右侧剩余内容
  1. 格式检查分隔符与合法名字
  2. 数字检查解析、非负、整数上限
  3. 重复名字第一条合法记录保留

L29Example05.java · 第1/3段,顺序接在同一文件

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class L29Example05 {
    static boolean validName(String name) {
        return name != null && !name.isBlank() && name.equals(name.strip())
            && !name.contains("=") && !name.contains("\n") && !name.contains("\r");
    }
    static void save(Path path, Map<String, Integer> scores) throws IOException {
        if (path == null || scores == null)
            throw new IllegalArgumentException("路径和计分板不能为null");
        List<String> lines = new ArrayList<>();
        for (var entry : scores.entrySet()) {
            if (!validName(entry.getKey()) || entry.getValue() == null
                    || entry.getValue() < 0)
                throw new IllegalArgumentException("存档记录不合法");
        }
        for (var entry : new TreeMap<>(scores).entrySet())
            lines.add(entry.getKey() + "=" + entry.getValue());
        Path parent = path.getParent();
        if (parent != null) Files.createDirectories(parent);
        Files.write(path, lines, StandardCharsets.UTF_8);
    }
    static Map<String, Integer> load(Path path) throws IOException {
        if (path == null) throw new IllegalArgumentException("路径不能为null");
        List<String> lines;
        try { lines = Files.readAllLines(path, StandardCharsets.UTF_8); }

L29Example05.java · 第2/3段,顺序接在同一文件

        catch (NoSuchFileException missing) { return new HashMap<>(); }
        Map<String, Integer> scores = new HashMap<>();
        int lineNumber = 0;
        for (String line : lines) {
            lineNumber++;
            String[] parts = line.split("=", 2);
            if (parts.length != 2 || !validName(parts[0])) {
                System.out.println("跳过坏行=" + lineNumber);
                continue;
            }
            try {
                int score = Integer.parseInt(parts[1].strip());
                if (score < 0) throw new NumberFormatException("负分数");
                if (scores.containsKey(parts[0])) {
                    System.out.println("跳过重复名字=" + lineNumber);
                    continue;
                }
                scores.put(parts[0], score);
            } catch (NumberFormatException error) {
                System.out.println("跳过坏分数=" + lineNumber);
            }
        }
        return scores;
    }
    public static void main(String[] args) throws IOException {
        Path folder = Files.createTempDirectory("java-load-");
        Path file = folder.resolve("scores.txt");
        try {

L29Example05.java · 第3/3段,顺序接在同一文件

            Files.write(file, List.of("Alex=12", "bad line", "Mia=很多",
                "Alex=99", "小马=8", "Zero=-1"), StandardCharsets.UTF_8);
            Map<String, Integer> loaded = load(file);
            System.out.println(loaded.get("Alex"));
            System.out.println(loaded.get("小马"));
            System.out.println(loaded.size());
        } finally { Files.deleteIfExists(file); Files.deleteIfExists(folder); }
    }
}

运行结果

跳过坏行=2
跳过坏分数=3
跳过重复名字=4
跳过坏分数=6
12
8
2

07 / 12

缺失文件与 IOException

存档不存在可能只是第一次运行,不一定是错误。权限不足或磁盘故障才是无法完成读写。

首次启动返回空计分板
  1. 文件缺失返回空计分板
  2. 其他 IOException继续传播并说明路径
  3. 坏行报行号后跳过

L29Example06.java · 第1/2段,顺序接在同一文件

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class L29Example06 {
    static boolean validName(String name) {
        return name != null && !name.isBlank() && name.equals(name.strip())
            && !name.contains("=") && !name.contains("\n") && !name.contains("\r");
    }
    static void save(Path path, Map<String, Integer> scores) throws IOException {
        if (path == null || scores == null)
            throw new IllegalArgumentException("路径和计分板不能为null");
        List<String> lines = new ArrayList<>();
        for (var entry : scores.entrySet()) {
            if (!validName(entry.getKey()) || entry.getValue() == null
                    || entry.getValue() < 0)
                throw new IllegalArgumentException("存档记录不合法");
        }
        for (var entry : new TreeMap<>(scores).entrySet())
            lines.add(entry.getKey() + "=" + entry.getValue());
        Path parent = path.getParent();
        if (parent != null) Files.createDirectories(parent);
        Files.write(path, lines, StandardCharsets.UTF_8);
    }
    static Map<String, Integer> load(Path path) throws IOException {
        if (path == null) throw new IllegalArgumentException("路径不能为null");
        List<String> lines;
        try { lines = Files.readAllLines(path, StandardCharsets.UTF_8); }

L29Example06.java · 第2/2段,顺序接在同一文件

        catch (NoSuchFileException missing) { return new HashMap<>(); }
        Map<String, Integer> scores = new HashMap<>();
        int lineNumber = 0;
        for (String line : lines) {
            lineNumber++;
            String[] parts = line.split("=", 2);
            if (parts.length != 2 || !validName(parts[0])) {
                System.out.println("跳过坏行=" + lineNumber);
                continue;
            }
            try {
                int score = Integer.parseInt(parts[1].strip());
                if (score < 0) throw new NumberFormatException("负分数");
                if (scores.containsKey(parts[0])) {
                    System.out.println("跳过重复名字=" + lineNumber);
                    continue;
                }
                scores.put(parts[0], score);
            } catch (NumberFormatException error) {
                System.out.println("跳过坏分数=" + lineNumber);
            }
        }
        return scores;
    }
    public static void main(String[] args) throws IOException {
        Path folder = Files.createTempDirectory("java-missing-");
        try { System.out.println(load(folder.resolve("missing.txt")).size()); }
        finally { Files.deleteIfExists(folder); }
    }
}

运行结果

0

08 / 12

往返测试验证存档

文件功能最重要的测试是 round trip:准备数据,保存,加载,再比较内容。

往返测试:走完两段路再比较
  1. 保存前准备 Map 内容
  2. 往返读取save 后再 load
  3. 比较内容相等才算通过
验收对照表
场景操作预期
正常保存两名玩家再加载Map内容相同
边界保存空Map加载为空
字符玩家名含中文UTF-8保持不变
错误坏数字行提示并跳过
缺失读取不存在文件返回空Map

L29Example07.java · 第1/3段,顺序接在同一文件

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class L29Example07 {
    static boolean validName(String name) {
        return name != null && !name.isBlank() && name.equals(name.strip())
            && !name.contains("=") && !name.contains("\n") && !name.contains("\r");
    }
    static void save(Path path, Map<String, Integer> scores) throws IOException {
        if (path == null || scores == null)
            throw new IllegalArgumentException("路径和计分板不能为null");
        List<String> lines = new ArrayList<>();
        for (var entry : scores.entrySet()) {
            if (!validName(entry.getKey()) || entry.getValue() == null
                    || entry.getValue() < 0)
                throw new IllegalArgumentException("存档记录不合法");
        }
        for (var entry : new TreeMap<>(scores).entrySet())
            lines.add(entry.getKey() + "=" + entry.getValue());
        Path parent = path.getParent();
        if (parent != null) Files.createDirectories(parent);
        Files.write(path, lines, StandardCharsets.UTF_8);
    }
    static Map<String, Integer> load(Path path) throws IOException {
        if (path == null) throw new IllegalArgumentException("路径不能为null");
        List<String> lines;
        try { lines = Files.readAllLines(path, StandardCharsets.UTF_8); }

L29Example07.java · 第2/3段,顺序接在同一文件

        catch (NoSuchFileException missing) { return new HashMap<>(); }
        Map<String, Integer> scores = new HashMap<>();
        int lineNumber = 0;
        for (String line : lines) {
            lineNumber++;
            String[] parts = line.split("=", 2);
            if (parts.length != 2 || !validName(parts[0])) {
                System.out.println("跳过坏行=" + lineNumber);
                continue;
            }
            try {
                int score = Integer.parseInt(parts[1].strip());
                if (score < 0) throw new NumberFormatException("负分数");
                if (scores.containsKey(parts[0])) {
                    System.out.println("跳过重复名字=" + lineNumber);
                    continue;
                }
                scores.put(parts[0], score);
            } catch (NumberFormatException error) {
                System.out.println("跳过坏分数=" + lineNumber);
            }
        }
        return scores;
    }
    public static void main(String[] args) throws IOException {
        Path folder = Files.createTempDirectory("java-roundtrip-");
        Path file = folder.resolve("scores.txt");
        Map<String, Integer> original = Map.of("小马", 12, "Alex", 0);

L29Example07.java · 第3/3段,顺序接在同一文件

        try {
            save(file, original);
            System.out.println(original.equals(load(file)));
            save(file, Map.of());
            System.out.println(load(file).isEmpty());
        } finally { Files.deleteIfExists(file); Files.deleteIfExists(folder); }
    }
}

运行结果

true
true

09 / 12

挑战 蓝图计分板存档

挑战 程序叫 WorldSave。它把 Map 保存为 UTF-8 文本,再加载并验证两名玩家和总数量。

按键排序保存 逐行安全加载
  1. 稳定保存TreeMap 按名字排序
  2. 安全入口先验证全部记录
  3. 已有存档主程序只加载

验收顺序

  • 保存Alex=12和Steve=8。
  • 文件使用UTF-8且每人一行。
  • 加载后按名字查询得到相同分数。
  • 缺失文件返回空Map。
  • 坏行打印提示并继续读取。

10 / 12

世界存档程序

新建 WorldSave.java,使用 Java 21 编译运行。

完整保存与加载流程
  1. 首次运行创建演示文件
  2. 再次运行不覆盖已有数据
  3. 失败显示路径与异常类型

WorldSave.java · 第1/3段,顺序接在同一文件

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
public class WorldSave {
static boolean validName(String name) {
    return name != null && !name.isBlank() && name.equals(name.strip())
        && !name.contains("=") && !name.contains("\n") && !name.contains("\r");
}
static void save(Path path, Map<String, Integer> scores) throws IOException {
    if (path == null || scores == null)
        throw new IllegalArgumentException("路径和计分板不能为null");
    List<String> lines = new ArrayList<>();
    for (var entry : scores.entrySet()) {
        if (!validName(entry.getKey()) || entry.getValue() == null
                || entry.getValue() < 0)
            throw new IllegalArgumentException("存档记录不合法");
    }
    for (var entry : new TreeMap<>(scores).entrySet())
        lines.add(entry.getKey() + "=" + entry.getValue());
    Path parent = path.getParent();
    if (parent != null) Files.createDirectories(parent);
    Files.write(path, lines, StandardCharsets.UTF_8);
}
static Map<String, Integer> load(Path path) throws IOException {
    if (path == null) throw new IllegalArgumentException("路径不能为null");
    List<String> lines;
    try { lines = Files.readAllLines(path, StandardCharsets.UTF_8); }

WorldSave.java · 第2/3段,顺序接在同一文件

    catch (NoSuchFileException missing) { return new HashMap<>(); }
    Map<String, Integer> scores = new HashMap<>();
    int lineNumber = 0;
    for (String line : lines) {
        lineNumber++;
        String[] parts = line.split("=", 2);
        if (parts.length != 2 || !validName(parts[0])) {
            System.out.println("跳过坏行=" + lineNumber);
            continue;
        }
        try {
            int score = Integer.parseInt(parts[1].strip());
            if (score < 0) throw new NumberFormatException("负分数");
            if (scores.containsKey(parts[0])) {
                System.out.println("跳过重复名字=" + lineNumber);
                continue;
            }
            scores.put(parts[0], score);
        } catch (NumberFormatException error) {
            System.out.println("跳过坏分数=" + lineNumber);
        }
    }
    return scores;
}
    public static void main(String[] args) {
        Path path = args.length == 0
            ? Path.of("saves", "lesson29-scores.txt") : Path.of(args[0]);
        try {

WorldSave.java · 第3/3段,顺序接在同一文件

            if (Files.notExists(path)) {
                save(path, Map.of("Alex", 12, "Steve", 8));
                System.out.println("首次运行:已建立演示存档");
            } else {
                System.out.println("已有存档:本次只加载");
            }
            Map<String, Integer> loaded = load(path);
            System.out.println("文件=" + path.getFileName());
            System.out.println("Alex=" + loaded.get("Alex"));
            System.out.println("Steve=" + loaded.get("Steve"));
            System.out.println("玩家数=" + loaded.size());
        } catch (IOException error) {
            System.out.println("读写失败:" + path.toAbsolutePath());
            System.out.println(error.getClass().getSimpleName());
        }
    }
}

运行结果

首次运行:已建立演示存档
文件=lesson29-scores.txt
Alex=12
Steve=8
玩家数=2

11 / 12

拓展任务与通关复盘

作业必须标出文件实际路径、编码、覆盖或追加方式,并提交一次往返测试。

三档任务继续扩建存档系统
  1. 编码证据明确 UTF-8
  2. 路径证据记录绝对地址
  3. 读写证据往返测试和坏行测试

三档作业

  • 木镐:把一句中文告示保存为UTF-8,再读取显示。
  • 铁镐:保存多个玩家分数,加载时跳过坏行。
  • 钻石镐:在首行加入版本号,版本不支持时拒绝加载并给出提示。
验收对照表
自评能还要练
我会使用Path和Files□□
我能明确使用UTF-8□□
我会做往返测试□□

12 / 12

自己的验收记录

漂亮的输出是起点,能解释边界才算掌握。把正常、边界与错误各选一个案例,运行前写预期,运行后留下证据。

为判断留下依据
类别你选择的操作核对重点
正常一个常用输入或一次成功动作结果是否逐行一致
边界范围端点与相邻值有没有多一步、少一项
错误非法数量、缺失对象或坏数据是否明确拒绝,状态是否保持
  • Bug 记录:触发条件、实际现象、原因、最小修复。
  • 图示记录:为每条箭头说明起点、终点和含义。
  • 改编记录:只改一个规则,同时修改预期和测试。

TAKE IT WITH YOU

下载本课 PDF

完整课文、代码、图解与练习,选择你需要的语言。