跨平台Minecraft启动器核心原理:从版本解析到进程拉起
很多人在用 PCL、HMCL 或者官方启动器的时候通常不会去想一个启动器内部到底做了什么。直到某天你换了一台电脑Windows 上能正常启动的游戏版本放到 macOS 上就报Could not find or load main class换到 Linux 上又变成UnsatisfiedLinkError。这时候你才会意识到“跨平台启动器”这五个字真正困难的地方不在界面而在底层。我写了一个跨平台 Minecraft 启动器这篇文章不是“我发布了什么”的展示帖而是把从版本解析、依赖下载、native 库处理到最终构建命令行并拉起游戏进程的完整链路拆开来讲。读完你会理解一个启动器应该怎么做也会知道“跨平台”真正的坑在哪里。1. 为什么值得自己写一个 Minecraft 启动器Minecraft 的启动器生态其实已经很成熟。官方启动器、PCL、HMCL、Boat、Prism Launcher、ATLauncher任何一款都经过大量用户验证功能覆盖下载、登录、Mod 管理、整合包导入等场景。对绝大多数普通玩家来说根本没有必要自己写一个启动器。但换一个角度如果你是一名开发者或者你正在给某个整合包社区做定制分发工具自己写一个启动器的价值就完全不同。第一自己写一次才能把 Minecraft 的启动链路彻底搞清楚。很多人知道启动器要下载游戏但不知道启动器下载的到底是一份“版本 JSON”而不是直接下载一个完整的游戏客户端。版本 JSON 里描述了这个版本依赖哪些 libraries、需要哪个主类、怎么传 JVM 参数、资源文件放在哪里。不写代码你会发现这些细节永远是黑盒。第二自己写启动器得到的最大收益是可控。官方启动器并不允许你随意修改 JVM 参数、自定义下载源、或者把游戏目录隔离到任意位置。社区启动器虽然功能强但你是“使用方”遇到问题只能等作者修复。自己写的时候你可以决定下载源是官方还是镜像源可以决定启动参数怎么生成可以决定游戏目录的物理组织方式。第三跨平台需求往往是社区工具走向开源分发的关键一步。一个启动器如果只支持 Windows那它在 macOS 和 Linux 用户那里就是废的。而要做到跨平台不是把同一段 Java 代码放到三个系统上跑一遍那么简单它牵扯到 native 库的分类、路径规范、Java 运行时发现、进程管理方式、系统签名与权限等一堆问题。所以我对这个问题的判断是如果你只是想把游戏跑起来用 PCL、HMCL 或官方启动器就够了千万别自找麻烦。如果你想知道“游戏到底是怎么被启动的”或者需要给团队定制一个启动工具自己写一次非常值得。如果你考虑开源或发布工具那跨平台能力不是加分项而是底线。2. Minecraft 启动器核心原理版本、库与启动参数在写代码之前必须先理解 Minecraft 官方启动器的工作方式。整个流程可以概括成一句话启动器把“版本 JSON”翻译成一个 Java 命令行然后让 Java 进程自己跑完游戏主流程。2.1 版本清单与版本 JSONMinecraft 官方维护了一份版本清单version manifest里面是所有游戏版本的 ID、类型和下载地址。每个版本对应一份独立的“版本 JSON”这份 JSON 描述了该版本的完整信息。版本 JSON 里的关键字段如下字段作用容易误解的地方libraries第三方依赖库列表不是所有库都要无条件下载还要看平台规则mainClassJava 入口主类不同版本可能不同argumentsJVM 参数与游戏参数1.13 之后结构发生明显变化assetIndex资源文件索引它不直接存放资源文件的下载地址downloads客户端 jar 等信息有些版本可能缺失部分字段一个常见的误解是启动器下载的是“游戏客户端压缩包”。实际上启动器是读取版本 JSON 后按列表下载 libraries、下载客户端 jar、读取 assetIndex 后再下载资源文件。2.2 libraries 与 nativeslibraries是启动过程中最复杂的一块。它记录了当前版本依赖的所有 Java 库例如 LWJGL、Gson、Guava 等。这些库大多来自 Maven 仓库风格的结构每个库的下载地址由downloads.artifact.url给出。难点在于部分库包含 native 本地库这些库是按照操作系统区分的。比如 LWJGL 在不同平台上的实现不同它在版本 JSON 里的形式是{ name: org.lwjgl:lwjgl:3.3.1, downloads: { artifact: { path: org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar, url: https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar, sha1: ..., size: 123456 }, classifiers: { natives-windows: { path: org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar, url: https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar }, natives-linux: { path: org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-linux.jar }, natives-osx: { path: org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-osx.jar } } } }这就是跨平台启动器第一个真正的分叉点如果不按当前系统选择对应的natives-windows或natives-linux或natives-osx启动时就会遇到本地库加载失败。此外版本 JSON 里还有rules数组用来进一步过滤某些库在特定操作系统上是否启用。例如某个库声明只在 Linux 上使用那么 Windows 启动时就应当跳过它。2.3 assets 资源索引assetIndex指向一个资源索引 JSON里面记录了游戏运行需要的纹理、语言文件、字体等资源。启动器需要下载索引文件然后根据索引下载缺失的资源文件并把资源目录组织为assets/objects和assets/indexes等结构。资源文件的特征是文件数量多、总大小大但是单文件体积通常不大因此比较适合做并发下载。多数成熟启动器会维护一个“本地已经有哪些文件”的清单避免重复下载。2.4 启动参数的本质启动器最终要调用 Java 启动一个进程核心命令大致是java \ -Xmx4G \ -Djava.library.pathnatives \ -cp 库1.jar:库2.jar:...:客户端jar \ net.minecraft.client.main.Main \ --username 玩家名 \ --version 1.20.1 \ --gameDir 游戏目录 \ --assetsDir assets目录 \ --assetIndex 资源索引ID \ --uuid UUID \ --accessToken token \ --user_type legacy \ --versionType release这个命令行在版本 JSON 中是有对应描述的arguments.jvm是给 JVM 的参数arguments.game是给游戏主类的参数。启动器要做的事情就是把这两类参数和用户自己的设置内存大小、用户名、游戏目录等合并最终交给ProcessBuilder来执行。理解了上面这些启动器最核心的部分已经完成了 70%。剩下的工作就是把这些逻辑写成健壮的代码并处理好跨平台差异。3. 技术选型JVM、Electron、Tauri 还是 Go自研启动器面临的第一个选择题是技术栈。社区里现成的启动器其实已经覆盖了各大主流方案PCL 用 C#HMCL 用 JavaBoat 用 Java部分新启动器用 Electron 或 Tauri。我倾向于根据团队情况和技术目标来选择而不是盲目追求“热门”。方案UI 表现打包体积上手难度适合场景JVM JavaFX / Swing / Compose中等可定制中等需要 JVM中与 MC 生态同构适合 Java 团队Electron好大中前端团队需要强 UI 定制Tauri好小较高前端 Rust 团队追求体积Go Wails好小中轻量工具偏好 Go我最终选择的是 JVM 原生方案核心原因有三个第一用户玩 Minecraft本身就必然安装了 Java启动器不需要额外绑定运行时也更容易和游戏共用同一套 Java 版本管理逻辑。第二启动器最核心的使命是通过ProcessBuilder拉起一个子 Java 进程。JVM 天生就适合做这件事标准 API 就能完成进程创建、输出重定向、退出码监听。第三版本 JSON 本身就是 JSON 结构用 Java 配合 Jackson 解析非常顺手而且 Minecraft 生态里的工具也大量基于 Java后续如果要接 Forge、Fabric、Modrinth 等生态直接在一个语言体系里搞定更省事。如果你的团队是前端背景那么 Tauri 或 Electron 完全可行但有一个工程原则要记住把“启动逻辑”和“UI”彻底分离。即使 UI 用 TypeScript 写核心的版本解析、库下载、启动参数构造逻辑也应该独立成可测试的模块这样后续替换 UI 层不会伤筋动骨。还有一个常见的误区是把“跨平台”等同于“跨 UI 框架”。实际上跨平台最难的从来不是按钮长什么样而是文件路径、平台识别、native 库选择、进程行为差异。这些和 UI 框架没有任何关系。4. 启动器架构设计从版本清单到实例管理启动器虽然看起来功能繁多但架构上可以拆分成清晰的模块。我自己实现时采用了以下模块划分模块职责version-manager管理版本清单、版本 JSON 的下载与解析library-resolver解析 libraries 规则按平台过滤计算需要下载的库asset-manager下载资源索引对比本地文件增量下载资源java-runtime发现本机 Java执行java -version判断版本process-launcher构建启动命令创建子进程管理进程输出instance-manager管理多个游戏实例的独立目录和配置settings-store保存启动器设置和账号信息数据流大致是用户选择一个游戏版本。version-manager加载版本列表下载对应版本 JSON。library-resolver解析版本 JSON过滤平台规则下载缺失的库和 native 库。asset-manager检查资源索引增量下载资源文件。java-runtime找到合适的 Java。process-launcher组合全部参数启动游戏子进程。游戏退出后process-launcher收集退出码和日志。这个结构里最容易被低估的是instance-manager。很多人写启动器时只有一个游戏目录导致不同整合包之间互相污染。比如装了 A 整合包的 Mod再去玩 B 整合包就冲突。实际成熟启动器都会用“实例”隔离每个游戏实例有独立的gameDir、mods、config、saves这样版本和 Mod 环境彼此独立。实例管理在启动器里的实现并不复杂无非是在启动参数里把--gameDir指向不同目录但它对工程质量和用户体验的影响很大。5. 跨平台处理的关键细节平台识别、路径与 Java 运行时跨平台不是一句口号。真正写代码时至少有三个地方必须特殊处理。5.1 平台识别与 natives 选择Java 可以通过System.getProperty(os.name)得到当前操作系统名称但不同系统返回值是不同的系统os.name可能的取值WindowsWindows 10,Windows 11,Windows ServermacOSMac OS X,macOS等Linux安装发行版名称不同但基本都包含LinuxMinecraft 版本 JSON 里的规则使用的是windows、osx、linux这类分类名。因此启动器需要一个Platform枚举把系统名称映射为 Minecraft 的分类// 文件路径src/main/java/com/example/launcher/Platform.java public enum Platform { WINDOWS(natives-windows), MAC(natives-osx), LINUX(natives-linux); private final String nativesClassifier; Platform(String nativesClassifier) { this.nativesClassifier nativesClassifier; } public static Platform current() { String os System.getProperty(os.name).toLowerCase(); if (os.contains(win)) { return WINDOWS; } if (os.contains(mac) || os.contains(darwin)) { return MAC; } return LINUX; } public String nativesClassifier() { return nativesClassifier; } }这个nativesClassifier会被用来查找版本 JSON 中downloads.classifiers[natives-windows]这类条目。如果平台判断错误启动时就会出现本地库加载失败。5.2 路径与文件系统差异游戏默认目录在不同平台是不同的系统默认游戏根目录Windows%APPDATA%\.minecraftmacOS~/Library/Application Support/minecraftLinux~/.minecraft这里特别需要注意的是 macOS 路径中包含空格。如果你用字符串拼接命令很容易拼出一个错误路径。解决方法是绝对不要手工拼接启动命令行而是使用ProcessBuilder传入参数列表让底层 API 处理转义。同时启动器内部应该使用java.nio.file.Path而不是简单字符串处理路径这样在 Windows 和 macOS/Linux 之间切换时路径分隔符可以自动处理。另外在 macOS 和 Linux 上native 库解压后可能需要设置可执行权限。很多启动器在解压 natives 后会执行chmod x否则本地库加载会失败。Windows 没有这个问题但这也是跨平台差异之一。5.3 Java 运行时发现启动器还需要找到可用的 Java。我的优先级是用户手动指定的 Java 路径。环境变量JAVA_HOME。PATH中的java。各平台常见安装目录WindowsC:\Program Files\Java、C:\Program Files\Eclipse AdoptiummacOS/Library/Java/JavaVirtualMachinesLinux/usr/lib/jvm找到候选 Java 后还需要判断它的版本是否匹配。Minecraft 新版本普遍要求 Java 17 或更高版本而老版本通常需要 Java 8。判断版本的方式是执行java -version然后解析输出。这一步看似简单但不同 Java 版本的输出格式有差异建议用宽容的正则解析。6. 核心代码实现版本解析、依赖下载与启动命令构建下面进入代码部分。我会用最小可运行的 Java 示例展示启动器最核心的三个环节。完整项目还涉及大量边界处理但下面这段代码足以跑通一个基础流程。6.1 获取版本清单并解析版本 JSON首先定义一个最简单的版本信息模型// 文件路径src/main/java/com/example/launcher/model/GameVersion.java public class GameVersion { private String id; private String type; private String url; public String getId() { return id; } public void setId(String id) { this.id id; } public String getType() { return type; } public void setType(String type) { this.type type; } public String getUrl() { return url; } public void setUrl(String url) { this.url url; } }然后从版本清单中拉取版本列表// 文件路径src/main/java/com/example/launcher/VersionManager.java package com.example.launcher; import com.example.launcher.model.GameVersion; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.net.URL; import java.util.ArrayList; import java.util.List; public class VersionManager { // 官方版本清单地址如果 Mojang 调整接口需要同步更新 private static final String MANIFEST_URL https://piston-meta.mojang.com/mc/game/version_manifest_v2.json; private final ObjectMapper mapper new ObjectMapper(); public ListGameVersion fetchVersionList() throws IOException { JsonNode root mapper.readTree(new URL(MANIFEST_URL)); ListGameVersion list new ArrayList(); for (JsonNode node : root.path(versions)) { GameVersion v new GameVersion(); v.setId(node.path(id).asText()); v.setType(node.path(type).asText()); v.setUrl(node.path(url).asText()); list.add(v); } return list; } public JsonNode fetchVersionJson(String url) throws IOException { return mapper.readTree(new URL(url)); } }这段代码做了最基本的两件事拉取版本清单以及根据单个版本的 URL 拉取版本 JSON。实际项目中建议把版本清单和版本 JSON 都做本地缓存避免每次启动都重复请求。6.2 按平台过滤 libraries 并下载版本 JSON 中的libraries并不是全部要下载需要先根据rules过滤然后选择当前平台的 native classifier。// 文件路径src/main/java/com/example/launcher/LibraryResolver.java package com.example.launcher; import com.fasterxml.jackson.databind.JsonNode; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.net.URL; import java.nio.channels.Channels; import java.nio.channels.ReadableByteChannel; import java.nio.file.Path; public class LibraryResolver { private final Platform platform Platform.current(); public boolean matches(JsonNode library) { JsonNode rules library.path(rules); if (rules.isMissingNode()) { return true; } boolean allow false; for (JsonNode rule : rules) { if (matchRule(rule)) { String action rule.path(action).asText(); if (allow.equals(action)) { allow true; } else if (disallow.equals(action)) { return false; } } } return allow; } private boolean matchRule(JsonNode rule) { JsonNode os rule.path(os); if (os.isMissingNode()) { return true; } String osName os.path(name).asText(); if (windows.equals(osName)) { return platform Platform.WINDOWS; } if (osx.equals(osName)) { return platform Platform.MAC; } if (linux.equals(osName)) { return platform Platform.LINUX; } return true; } public void downloadLibraries(JsonNode versionJson, Path libDir) throws IOException { for (JsonNode lib : versionJson.path(libraries)) { if (!matches(lib)) { continue; } JsonNode artifact lib.path(downloads).path(artifact); if (!artifact.isMissingNode()) { downloadIfMissing(artifact, libDir); } JsonNode classifiers lib.path(downloads).path(classifiers); String classifierKey platform.nativesClassifier(); if (!classifierKey.isEmpty() classifiers.has(classifierKey)) { downloadIfMissing(classifiers.get(classifierKey), libDir); } } } private void downloadIfMissing(JsonNode fileNode, Path libDir) throws IOException { String path fileNode.path(path).asText(); Path target libDir.resolve(path); if (target.toFile().exists()) { return; } String url fileNode.path(url).asText(); target.getParent().toFile().mkdirs(); try (ReadableByteChannel channel Channels.newChannel(new URL(url).openStream()); FileOutputStream out new FileOutputStream(target.toFile())) { out.getChannel().transferFrom(channel, 0, Long.MAX_VALUE); } } }这里有一个可以继续完善的重要点下载完成后应做 sha1 校验。版本 JSON 中每个下载项都提供了sha1值校验它可以在网络异常或镜像源损坏时及时重试。实际项目中我会把下载逻辑独立封装用线程池做并发下载同时记录下载进度。上面这个单线程版本仅用于理解核心流程。6.3 构建启动命令并拉起进程最后把解析结果组合成启动命令。这里必须使用ProcessBuilder而不是手工拼接字符串。// 文件路径src/main/java/com/example/launcher/GameLauncher.java package com.example.launcher; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; public class GameLauncher { public Process launch(LaunchOptions opts) throws IOException { ListString command new ArrayList(); command.add(opts.javaPath()); command.add(-Xmx opts.maxMemory() M); command.add(-Xms opts.minMemory() M); command.add(-Djava.library.path opts.nativesDir()); command.add(-cp); command.add(String.join(File.pathSeparator, opts.classpath())); command.add(opts.mainClass()); // 游戏参数 command.add(--username); command.add(opts.username()); command.add(--version); command.add(opts.versionId()); command.add(--gameDir); command.add(opts.gameDir().toString()); command.add(--assetsDir); command.add(opts.assetsDir().toString()); command.add(--assetIndex); command.add(opts.assetIndex()); command.add(--uuid); command.add(opts.uuid()); command.add(--accessToken); command.add(opts.accessToken()); command.add(--user_type); command.add(opts.userType()); command.add(--versionType); command.add(release); ProcessBuilder pb new ProcessBuilder(command); pb.directory(opts.gameDir().toFile()); pb.redirectErrorStream(true); return pb.start(); } }LaunchOptions是一个把全部启动参数聚合在一起的配置类。它的字段包括javaPath、nativesDir、classpath、mainClass、username、uuid、accessToken等。这里不展开所有 getter/setter实际编码时把它们补全即可。这段代码里最容易踩坑的是-Djava.library.path它必须指向解压后的 natives 目录。如果 natives 目录不存在或者解压出来的是普通 jar 而不是可用本地库游戏启动会在加载 LWJGL 时失败。7. 运行结果与效果验证代码写完之后不要一上来就启动完整游戏。建议先做两步验证第一步把启动命令打印出来检查命令是否合理java -Xmx4096M -Xms1024M -Djava.library.path/path/to/natives \ -cp /path/to/libs/*:/path/to/client.jar \ net.minecraft.client.main.Main \ --username test \ --version 1.20.1 \ --gameDir /path/to/game \ --assetsDir /path/to/assets \ --assetIndex 1.20 \ --uuid 00000000-0000-0000-0000-000000000000 \ --accessToken 0 \ --user_type legacy \ --versionType release确认命令无误后再正式启动。第二步订阅子进程的输出流实时打印日志。一个正常的启动日志应当包含类似内容[INFO] GameLauncher: 子进程已启动, PID12345 [INFO] Minecraft: Backend library: LWJGL version 3.3.1 [INFO] Minecraft: OpenGL Version: 3.2.0 [INFO] Minecraft: Sound engine initialized [INFO] Minecraft: Created: 1024x768判断启动成功最直接的标准是进程没有在几秒内退出。日志里能看到 LWJGL 版本号。能看到 OpenGL 初始化信息。最终进入主菜单界面。如果启动后立即退出第一步应该先查看日志中的异常堆栈而不是盲目调整参数。大多数情况下UnsatisfiedLinkError指向 natives 问题ClassNotFoundException指向 classpath 问题UnsupportedClassVersionError指向 Java 版本问题。8. 常见问题与排查思路我在开发过程中遇到过的问题基本都能归入下面这张表格问题现象可能原因排查方式解决建议启动后瞬间退出无错误日志Java 版本与游戏不匹配查看启动器日志文件切换 Java 版本UnsatisfiedLinkErrornatives 平台选错或解压失败检查 natives 目录内容删除 natives 目录重新解压ClassNotFoundExceptionclasspath 不完整检查启动命令中的-cp确认所有 libraries 已下载库下载 403 或超时官方源或镜像源不可用查看下载 URL 和响应切换到可用的下载源macOS 提示“无法打开”启动器未签名查看系统安全提示右键打开或完成签名公证路径含空格导致启动失败手工拼接了命令字符串打印启动命令检查改用ProcessBuilder传参Java 路径找不到用户本机未安装 Java检查自动发现逻辑提示用户手动指定 Java 路径这里特别想强调两个典型问题。第一个是 native 库的“平台污染”。Windows 上启动正常但你直接把整个.minecraft/libraries目录拷到 macOS 上就可能出现UnsatisfiedLinkError。原因是在 Windows 上下载到的natives-windows并不适用于 macOS启动器必须按当前平台选择并解压对应 classifier而不是直接复用别的平台已经下载好的文件。第二个是 Java 版本匹配。很多老版本整合包需要 Java 8而新版本需要 Java 17。如果启动器只会找PATH里的最新 Java很容易出现UnsupportedClassVersionError。更稳妥的做法是启动器维护一个 Java 版本下载器按游戏版本自动下载对应的 Java 运行时或者至少明确提示用户哪个 Java 不满足要求。9. 工程化、分发与安全建议当启动器功能跑通后就进入到工程化阶段。这个阶段如果不重视后面分发和长期维护会非常痛苦。9.1 打包与 CIJVM 项目可以用maven-shade-plugin打成一个 fat jar即所有依赖都打进同一个 jar 文件。也可以用jpackage打成对应系统的安装包Windows 是 exe 或 msimacOS 是 dmgLinux 是 deb 或 rpm。跨平台分发建议用 GitHub Actions 这类 CI 编排三个系统的构建任务每个系统只构建自己的安装包。这样就不需要一个人手动在三个系统上来回切换。9.2 日志与崩溃定位启动器必须建日志体系。否则玩家反馈“启动失败”时你根本不知道发生了什么。至少要做到把启动器自身日志写到logs/launcher.log。把游戏子进程输出同时同步到该文件。日志按时间段切分不要无限增长。启动失败时把启动命令和日志尾部打包导出。9.3 更新机制启动器自身也需要更新。比较简单的方案是在启动器内置一个当前版本号首次运行时访问一个版本地址如果远程版本号大于本地版本号就提示用户下载新包。进阶的方案是做增量更新只下载变更的 jar 或二进制文件不过这对小型开源项目来说不是必需的。9.4 安全边界启动器是一个可以执行本地进程、下载远端文件、读取用户账号信息的程序安全边界必须清晰下载校验所有文件下载完成后都应校验 sha1避免镜像源被污染或文件损坏。账号安全只支持官方微软登录或离线模式不要把账号 token 写入日志不要传给第三方服务器。参数白名单不要允许任意配置项直接拼进 JVM 参数。比如用户输入-Dfoobar;-jar hack.jar这类字符串如果直接拼接可能被注入恶意命令。所有可配置参数都要做白名单校验。最小权限不要让文档、教程或预设配置引导用户关闭系统安全机制。签名和公证是正确做法绕过 Gatekeeper 只是临时方案。这里要明确一点写启动器时不能打着“方便用户”的旗号去内置绕过正版验证的逻辑。这类功能既不符合规范也会给项目带来法律和用户信任风险。启动器支持离线模式和官方微软登录已经能覆盖绝大多数使用场景。10. 总结与后续方向从一个只有功能想法的“写一个跨平台启动器”开始到最后把游戏进程从自己的代码里拉起整个过程真正有价值的地方不是那个启动按钮而是你彻底弄懂了版本 JSON 的结构、libraries 的平台规则、natives 的处理方式、assets 的资源索引以及ProcessBuilder如何把一个复杂的 Java 命令安全地执行起来。如果你也想实现一个启动器我的建议是不要一上来就做图形界面。先写一个命令行版本只做三件事拉取版本清单、解析版本 JSON、打印启动命令。跑到这一步你对启动原理的理解已经超过大多数人。然后再加上依赖下载、natives 解压、asset 资源同步最后再做实例管理、UI 和自动更新。下一步值得深入研究的方向是Fabric 与 Forge 的安装逻辑、Modrinth 与 CurseForge 整合包导入格式、微软账号 OAuth 登录流程以及 Compose Multiplatform 这类新的跨平台 UI 方案。每个方向单独拿出来都是一篇长文的体量但有了这篇文章的启动链路基础你已经知道它们各自应该挂在架构的哪个位置了。