简介:JavaFX Ensemble 是 Oracle 官方示例合集,面向学习桌面端 Java 界面开发、希望掌握控件与视觉效果的中级开发者,可用于对照理解 API 用法、搭建原型与排查布局。压缩包共 673 个文件、约 20.24MB,以 243 个 Java 源码为核心,辅以大量 PNG、JPG 界面素材,以及 XML 配置、WAV 音频、CSS 样式表和 FXML 布局文件,较完整呈现示例工程的代码、资源与皮肤组织方式。内容覆盖图表、媒体播放、动画过渡、按钮与登录界面等场景,CSS 可用于研究换肤,Java 与 FXML 便于对比手写和声明式布局。示例按控件类别分模块组织,便于检索源码、样式与素材,也能观察动画和媒体组件的实现。已有 276 人学习,适合拆解示例、复用素材样式或作为项目参考。
1. 从一份跑不起来的控件样例说起:JavaFX Ensemble 到底解决什么问题
去年接手一个内部工具迁移,团队从 Swing 搬到 JavaFX,头一件事是选控件、定皮肤、写设计规范。有人丢下一句「去看 JavaFX Ensemble 就行」,结果三个同事打开示例翻了一下午,回来还是不知道自己的可编辑表格该用TableView还是TreeTableView,也不知道这两个类默认的 CSS 类名长什么样。这不是他们不认真,是大多数人把 Ensemble 当成了效果预览器。
Ensemble 真正的价值在于:它把每一个控件的构造代码、默认样式类名、初始尺寸、事件绑定方式,放在同一套可运行的容器里,左边目录树点哪一项,右边就是这一项的活代码。也就是说,它提供的是一份可执行的接口约定,而非单纯的演示。你和设计师争论按钮圆角该写-fx-background-radius还是-fx-border-radius的时候,翻文档不如在 Ensemble 里改一行看结果。
适合谁看:正在做 JavaFX 桌面端选型、准备把 Ensemble 改造成内部组件展示台、或者想拿它当控件回归基线的人。不适合只想截几张图交差的人。
2. 把 JavaFX Ensemble 在本地跑起来:JDK 21 加 OpenJFX 的最小工程
2.1 JDK 11 之后示例工程为什么从 JDK 里消失了
JDK 8 时代,JavaFX 是 JDK 的一部分,装完 JDK 就能在安装目录的示例目录里找到整套示例,双击或者写个短脚本就能跑;那时候jfxrt.jar躺在扩展目录里,连依赖都不用声明。JDK 11 把 JavaFX 整体剥离成独立的 OpenJFX 项目之后,示例工程也跟着搬走了。你在新版 JDK 目录里翻半天找不到,原因在于它根本不在那儿。
剥离带来的第二个变化是模块化。JavaFX 被拆成javafx.base、javafx.graphics、javafx.controls、javafx.fxml、javafx.media、javafx.web、javafx.swing这几个模块,Ensemble 这类全量示例几乎把七个模块用了个遍——图表和控件要 controls,视频示例要 media,浏览器示例要 web,还有几个 Swing 互操作的页签要 swing。少声明一个模块,编译能过,跑到对应页签直接抛NoClassDefFoundError。
第三个变化是本地库。javafx.graphics底下有一层 native 代码,负责窗口、渲染、字体。Maven 坐标必须带平台分类器(win、linux、mac、mac-aarch64),不带分类器拿到的是个空壳 jar,运行时报UnsatisfiedLinkError。这个坑在 CI 上尤其常见:开发机是 x86 的 Linux,构建机换成了 arm64,同一个build.gradle直接翻车。
提示:先确认
java -version和依赖里的 JavaFX 主版本对得上。JDK 17 配 JavaFX 21 大概率能跑,但jlink生成镜像时会报模块版本冲突,别在这上面浪费半天。
2.2 用 Gradle 配一个能跑的最小工程
我不太推荐直接下 SDK 压缩包手动配 classpath,换台机器就得重来。用构建工具把依赖和模块路径管起来,是更省事的路子。
plugins { id 'java' id 'application' } // JavaFX 坐标必须带平台分类器,否则只有 API 没有本地库 ext { javafxVersion = '21' osName = System.getProperty('os.name').toLowerCase() osArch = System.getProperty('os.arch').toLowerCase() javafxPlatform = osName.contains('win') ? 'win' : (osName.contains('mac') ? (osArch.contains('aarch64') ? 'mac-aarch64' : 'mac') : 'linux') } repositories { mavenCentral() } dependencies { implementation "org.openjfx:javafx-base:${javafxVersion}:${javafxPlatform}" implementation "org.openjfx:javafx-graphics:${javafxVersion}:${javafxPlatform}" implementation "org.openjfx:javafx-controls:${javafxVersion}:${javafxPlatform}" implementation "org.openjfx:javafx-fxml:${javafxVersion}:${javafxPlatform}" implementation "org.openjfx:javafx-media:${javafxVersion}:${javafxPlatform}" implementation "org.openjfx:javafx-web:${javafxVersion}:${javafxPlatform}" implementation "org.openjfx:javafx-swing:${javafxVersion}:${javafxPlatform}" } application { mainModule = 'com.example.ensemble' mainClass = 'com.example.ensemble.Launcher' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' // 示例源码里有中文注释,不设编码会乱码 }这段脚本的关键点有三处。javafxPlatform按os.name和os.arch推导,是为了让同一份脚本在开发机和 CI 上都能拿到正确的 native 产物,arm64 的 Mac 一定要走mac-aarch64,走mac会在启动时崩在图形初始化。版本集中在一个变量里,升级时只改一行。options.encoding别省,示例工程的注释和部分字符串带着非 ASCII 字符,不设成 UTF-8,代码面板打开就是一堆问号。
模块描述符也要跟着写,这是模块化启动的前提:
module com.example.ensemble { requires javafx.controls; requires javafx.fxml; requires javafx.web; requires javafx.media; requires javafx.swing; requires java.desktop; requires java.logging; exports com.example.ensemble; exports com.example.ensemble.spi; opens com.example.ensemble.samples to javafx.fxml; // FXML 反射需要 uses com.example.ensemble.spi.SampleModule; }java.desktop是为了SwingFXUtils和ImageIO,做快照导出绕不开。opens那一行容易漏,漏了之后凡是走FXMLLoader的示例页签会抛IllegalAccessException,报错信息里只提「class not open」,看不出是模块问题。uses声明的是后面第 4 章要用的服务发现接口。
再加一个不继承Application的启动类:
package com.example.ensemble; public final class Launcher { public static void main(String[] args) { // 入口类不继承 Application,避免 java -jar 时的运行时组件检查 EnsembleApp.main(args); } }这个类看起来多余,作用是绕开一个很烦的报错。当主类直接继承javafx.application.Application并且用java -jar启动时,JVM 会检查 JavaFX 运行时组件是否在主 classpath 上,模块路径上的模块不算,于是直接抛JavaFX runtime components are missing。加一层普通类做跳板,检查就绕过去了。
2.3 三种启动方式,选哪个看你要干什么
| 方式 | 关键配置 | 适用场景 | 典型失败 |
|---|---|---|---|
| IDE 直接 Run | VM options 里加--module-path和--add-modules | 日常改示例、调试布局 | 忘加 VM 参数,报运行时组件缺失 |
Gradlerun | mainModule与mainClass配对 | CI 冒烟、批量快照 | mainModule拼错,提示找不到主类 |
| jlink 镜像 | jlink --add-modules把七个模块全列上 | 交给非开发同事试用 | 漏了javafx.web,浏览器页签白屏无报错 |
IDE 那种方式最容易踩坑,因为 Run Configuration 是本地文件,不跟着仓库走,换个人拉下来就得重新配一遍。我一般会在项目根目录放一个run.sh,把参数固化下来:
#!/usr/bin/env bash set -euo pipefail # JAVAFX_LIB 指向解压后的 JavaFX SDK lib 目录,或 Gradle 缓存中对应平台的 jar 目录 JAVAFX_LIB="${JAVAFX_LIB:?请先设置 JAVAFX_LIB}" java \ --module-path "$JAVAFX_LIB" \ --add-modules javafx.controls,javafx.fxml,javafx.web,javafx.media,javafx.swing \ -Dprism.verbose=true \ -Dfile.encoding=UTF-8 \ -m com.example.ensemble/com.example.ensemble.Launcher--add-modules那一串逗号之间不能有空格,有空格后面的模块会被当成主类参数,症状是启动后只显示一个空窗口或者直接退出。-Dprism.verbose=true会在控制台打印最终选中的渲染管线,排查白屏时第一眼就看这个。
2.4 首次启动必须调的三个参数
第一个是-Dprism.order=es2,sw。它定义渲染管线的尝试顺序:先试 OpenGL,失败回落软件渲染。在远程桌面、虚拟机、老核显这几类环境里,OpenGL 初始化失败是白屏的头号原因,加上这个参数至少能看见界面,虽然帧率低但能确认代码没问题。
第二个是-Dprism.verbose=true。启动时打印类似「使用 ES2 管线」「D3D 初始化失败」的日志,把黑匣子打开。我习惯在排查渲染相关问题时先加它,比猜快得多。
第三个是 HiDPI 缩放,Linux 下用-Dglass.gtk.uiScale=1.5,Windows 下用-Dglass.win.uiScale=1.5。4K 屏上不加这个,整个界面字小得像蚂蚁,示例里的字体度量全部失真,你拿去当设计参考就是错的。
测动画流畅度再加一个-Djavafx.animation.fullspeed=true,把动画脉冲从默认的 60fps 限制放开,能看出某个示例是不是真的在拖后腿。
3. 读懂 Ensemble 的骨架:一棵树、一张注册表、一套 CSS
3.1 Sample 接口与目录树:控件目录是怎么长出来的
把示例工程的骨架拆开,核心就三样东西:一个描述单个示例的接口、一份把所有示例登记进去的注册表、一棵由注册表生成的TreeItem树。理解了这三者的关系,二次开发就不会乱。
示例接口最精简的形态是这样:
package com.example.ensemble.spi; import javafx.scene.Node; public interface SampleEntry { String id(); // 稳定标识,搜索和快照对比都靠它 String name(); // 侧边栏显示名 String category(); // 分组名,决定挂在树的哪个父节点下 Node createPanel(double width, double height); // 懒创建,不在启动时全量 new String sourcePath(); // jar 内源码资源路径,代码面板用 }createPanel带宽高两个参数,这个设计很关键。示例面板经常需要按容器尺寸调整,比如图表的轴标签密度、TilePane的列数,硬编码 800×600 在别的窗口尺寸下就露馅。懒创建则是性能底线——几百个示例如果启动时全new一遍,光 CSS 应用就要好几秒。
注册表生成树的逻辑:
private TreeItem<String> buildTree(List<SampleEntry> entries) { TreeItem<String> root = new TreeItem<>("全部示例"); Map<String, TreeItem<String>> groups = new LinkedHashMap<>(); for (SampleEntry e : entries) { // computeIfAbsent 保证分组顺序与 entries 顺序一致,LinkedHashMap 兜底 TreeItem<String> group = groups.computeIfAbsent(e.category(), c -> { TreeItem<String> t = new TreeItem<>(c); root.getChildren().add(t); return t; }); TreeItem<String> leaf = new TreeItem<>(e.name()); leaf.getProperties().put("entryId", e.id()); // 选中时靠它反查 SampleEntry group.getChildren().add(leaf); } return root; }用LinkedHashMap而不是HashMap,是因为分组顺序要跟注册表声明顺序一致,用HashMap出来的目录顺序每次启动都可能变,用起来很别扭。getProperties()挂entryId是低成本做法,比在TreeItem上做子类化省事,选中事件里getValue()拿到名字,再取属性拿 id,反查Map<String, SampleEntry>即可。
注意:
TreeItem的value是显示文本,不要拿它当唯一键。同一个分类下出现两个同名示例(比如「基础用法」在三个分类里都有)时,反查会串。
3.2 JavaFX CSS 与 modena 主题:样式覆盖的加载顺序
JavaFX 的默认主题叫 modena,打在javafx.controls模块内部,Scene创建时会自动应用。它的实现是一份普通 CSS,里面定义了-fx-base、-fx-background、-fx-control-inner-background这一组颜色变量,所有控件的默认外观都从这些变量派生。
自定义样式表的加载方式是追加:
Scene scene = new Scene(root, 1280, 800); scene.getStylesheets().add( Objects.requireNonNull( getClass().getResource("/com/example/ensemble/ensemble.css") ).toExternalForm() );追加到Scene上的样式表,优先级按 CSS 选择器的特异性排序,特异性相同则后加载的胜出。这就是为什么你的覆盖规则有时候生效有时候不生效——如果 modena 里那条规则用了.button:hover这样的复合选择器,而你只写.button,特异性不够,压不住。
覆盖时优先改变量,而不是逐条改属性:
/* 改主题色,所有派生控件自动跟着变 */ .root { -fx-base: #2f3542; -fx-accent: #4a90d9; -fx-focus-color: #4a90d9; } /* 局部结构调整才写具体类 */ .sample-pane { -fx-background-color: -fx-background; -fx-padding: 12; }-fx-base这一层是主题的根,改了它,按钮、输入框、滚动条的底色全部联动。逐条写-fx-background-color的做法在换主题时会把代码变成一堆散落的硬编码颜色,改一次配色要动几十处。
还有一个必须避开的做法:node.setStyle("-fx-background-color: red")。内联样式优先级高于任何外部样式表,写上去之后主题切换对它完全无效,而且没法在 CSS 文件里搜索到。示例工程里如果出现内联样式,基本都是历史遗留,二次开发时顺手清掉。
3.3 图表页和动画页的代码结构拆解
图表类示例的结构非常统一,基本都是坐标轴加图表对象再加数据序列的三段式:
@Override public Node createPanel(double width, double height) { CategoryAxis xAxis = new CategoryAxis(); NumberAxis yAxis = new NumberAxis(); BarChart<String, Number> chart = new BarChart<>(xAxis, yAxis); // 示例页里关掉动画:切换目录时图表重建,动画会造成明显的布局抖动 chart.setAnimated(false); chart.setPrefSize(width, height); XYChart.Series<String, Number> series = new XYChart.Series<>(); series.setName("样本量"); series.getData().add(new XYChart.Data<>("一月", 120)); series.getData().add(new XYChart.Data<>("二月", 168)); chart.getData().add(series); return chart; }setAnimated(false)这行在演示页里几乎必加。默认开启动画时,每次切换示例都会看到柱子从零长起来,单看挺好看,连续翻十几个页面就是纯噪音。setPrefSize用传入的宽高,是为了让面板自适应容器。
动画类示例的风险点在资源释放。Timeline和各类Transition都会持有目标节点的强引用,用户切走页面后节点从场景里移除,但动画如果还在跑,节点和它的整棵子树都回收不掉:
@Override public Node createPanel(double width, double height) { Circle circle = new Circle(24, Color.web("#4a90d9")); ScaleTransition st = new ScaleTransition(Duration.millis(900), circle); st.setFromX(1.0); st.setToX(1.6); st.setAutoReverse(true); st.setCycleCount(Animation.INDEFINITE); st.play(); // 节点离开场景时停掉动画,否则 Transition 一直持有节点引用 circle.sceneProperty().addListener((obs, oldScene, newScene) -> { if (newScene == null) { st.stop(); } }); return circle; }sceneProperty监听是这里最省事的生命周期钩子——节点从场景树上摘下来时newScene变成null,就地停掉动画。翻几十个动画示例之后内存曲线一路向上,八成就是漏了这一步。
4. 把 Ensemble 改造成自己的组件展示台:注册表驱动的三个改造点
4.1 用 ServiceLoader 把示例模块拆出去
原版示例工程把所有示例塞在一个工程里,几十个类共享一个巨型注册表。团队内部用的时候,问题立刻暴露:每个业务组都想往展示台里加自己的组件示例,全挤在一个工程里,改一次要合并冲突三天。
可行的做法是把注册表换成服务发现。先定接口:
package com.example.ensemble.spi; import java.util.List; public interface SampleModule { String category(); // 该模块负责的分类名 int order(); // 目录排序权重,小的排前面 List<SampleEntry> entries(); }模块描述符里声明使用和提供:
module com.example.ensemble.samples.charts { requires com.example.ensemble.spi; requires javafx.controls; provides com.example.ensemble.spi.SampleModule with com.example.ensemble.samples.charts.ChartSampleModule; }主工程汇总:
List<SampleEntry> loadAll() { return ServiceLoader.load(SampleModule.class).stream() .map(ServiceLoader.Provider::get) .sorted(Comparator.comparingInt(SampleModule::order)) .flatMap(m -> m.entries().stream()) .toList(); }逻辑说明:ServiceLoader.load在模块路径上扫描所有provides声明,stream()返回的是懒加载的 Provider,map(Provider::get)才真正实例化。先按order排序再展平,保证分类顺序稳定。
参数说明:order()我给的经验值是按十位留空档,图表 100、控件 200、布局 300、动画 400,中间插入新分类不用重排。子模块的module-info必须写requires com.example.ensemble.spi,只requires javafx.controls会在运行时抛ServiceConfigurationError,提示找不到提供者类型。
打包时还有一处必改:jlink和--add-modules里要把所有子模块列全,漏一个那一组示例就整体消失,控制台一声不吭。
4.2 加一个可搜索的控件索引和代码面板
示例数量过了五十,纯靠目录树找东西就难受了。加一个搜索框,用FilteredList包一层,改动很小:
ObservableList<SampleEntry> master = FXCollections.observableArrayList(loadAll()); FilteredList<SampleEntry> filtered = new FilteredList<>(master, s -> true); searchField.textProperty().addListener((obs, oldText, newText) -> { String kw = newText == null ? "" : newText.trim().toLowerCase(Locale.ROOT); filtered.setPredicate(s -> kw.isEmpty() || s.name().toLowerCase(Locale.ROOT).contains(kw) || s.category().toLowerCase(Locale.ROOT).contains(kw) || s.id().toLowerCase(Locale.ROOT).contains(kw)); });Locale.ROOT别省,土耳其语环境下toLowerCase()会把I转成点less的ı,搜ImageView搜不到,这种问题线上排查起来很费时间。FilteredList是视图层的包装,master本身不动,清空搜索框直接还原,不需要手动重建。
代码面板的做法是构建期把示例源码复制进资源目录:
tasks.register('copySampleSources', Copy) { from('src/main/java') { include '**/samples/**/*.java' } into layout.buildDirectory.dir('generated/sample-resources/sources') } sourceSets.main.resources.srcDir layout.buildDirectory.dir('generated/sample-resources') processResources.dependsOn copySampleSources路径要保持和sourcePath()返回值一致,否则运行时读到的是null。读取时显式指定字符集:
private String loadSource(String path) { try (InputStream in = getClass().getResourceAsStream(path)) { if (in == null) { return "// 源码未随包发布:" + path; } return new String(in.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { return "// 读取失败:" + e.getMessage(); } }getResourceAsStream在模块化环境下会受封装规则约束,资源所在的包如果没有opens或者不在同一个模块里,返回null。所以示例资源和示例类放在同一模块下最省事。
4.3 用 jlink 打包成内部演示镜像
给非开发同事用的时候,让他们装 JDK 不现实。jlink打一个自带运行时的镜像,双击就能开:
jlink \ --module-path "$JAVAFX_LIB:$APP_LIB" \ --add-modules com.example.ensemble,javafx.controls,javafx.fxml,javafx.web,javafx.media,javafx.swing \ --strip-debug \ --no-header-files \ --no-man-pages \ --compress=zip-6 \ --output build/image--module-path要同时包含 JavaFX 的模块 jar 和自己的模块 jar。--strip-debug去掉调试符号,镜像小一截。--compress在 JDK 21 上接受zip-0到zip-9的写法,早期版本是--compress=2,照抄网上的脚本很容易在这里报参数错误。
镜像出来之后验证一条命令就够:build/image/bin/java --list-modules | grep javafx,七个模块应该都在。少了哪个,对应的示例页签就是白屏或者静默消失。
5. 避坑与排查:JavaFX Ensemble 二次开发中最常见的五个翻车现场
现象一:java -jar启动直接报JavaFX runtime components are missing。原因是主类继承了Application,JVM 的启动器检查只看 classpath,不看模块路径。解决办法是加一层不继承Application的Launcher类做入口,或者干脆走java -m module/main的模块化启动。我倾向前者,脚本简单,同事复制粘贴不会错。
现象二:CI 上跑得好好的,同事本地一启动就UnsatisfiedLinkError,报某个 native 库加载失败。原因是平台分类器推错了。构建机是 x86_64 的 Linux,同事用的是 M 系列芯片的 Mac,mac-aarch64和mac拿到的是两套产物。解决办法是把javafxPlatform的推导逻辑写进构建脚本,同时在 README 里明确写一句「换架构要清~/.gradle/caches重拉」,缓存里混着两种分类器的 jar 时,症状会变得非常难查。
现象三:代码面板里中文注释全是问号。两个原因叠在一起:JavaCompile没设options.encoding = 'UTF-8',读资源时又用了平台默认字符集。解决是两头都设,编译期一行options.encoding,读取期明确写StandardCharsets.UTF_8。血泪经验是这类问题在开发机(默认 UTF-8)上永远复现不了,一到 Windows 同事手里就爆。
现象四:翻了几十个示例之后,堆内存一路涨,不 GC 也不释放。原因通常有两个:动画类示例的Transition没停,或者图表示例的Series在切换时被某个静态缓存持有。排查手段是加-Dprism.verbose=true之外再看jcmd <pid> GC.class_histogram,看哪个类的实例数只增不减。解决是给每个示例的createPanel结果挂sceneProperty监听,离开场景时清掉动画和监听器,静态缓存一律改成WeakHashMap。
现象五:自定义 CSS 写进去了,界面上一点反应没有。先看有没有setStyle()内联样式压着,再看选择器特异性够不够,最后检查样式表是不是加在了Scene上而不是某个子节点上——加在子节点上只对那棵子树生效,切换示例后自然就失效了。还有一个隐蔽原因:CSS 文件里存在语法错误时,JavaFX 只会在日志里打一行警告,整份文件后续规则全部丢弃,界面上看起来就是「前半截生效后半截不生效」。排查时先把文件砍到只剩一条规则,逐段加回去。
6. 用快照对比做控件回归:把「看着差不多」变成可判定的数字
示例工程改到后期,最怕的是改了一处 CSS 变量,几十个页面的外观悄悄变了,谁也没发现。靠人眼翻页签不现实,可行的办法是把每个示例渲染成图片存基线,改动后跑一遍像素对比。
核心就三步:创建节点、等一次渲染脉冲、截图。
public static void snapshotAll(List<SampleEntry> entries, Path outDir) throws IOException { Files.createDirectories(outDir); for (SampleEntry e : entries) { Node panel = e.createPanel(800, 600); Stage stage = new Stage(); stage.setScene(new Scene(new StackPane(panel), 800, 600)); stage.show(); // 必须等一次 pulse:CSS 应用和布局都发生在脉冲里,show 之后立刻截图拿到的是空节点 waitForPulse(() -> { try { WritableImage img = panel.snapshot(new SnapshotParameters(), null); ImageIO.write(SwingFXUtils.fromFXImage(img, null), "png", outDir.resolve(e.id() + ".png").toFile()); } catch (IOException ex) { throw new UncheckedIOException(ex); } stage.close(); }); } } private static void waitForPulse(Runnable after) { new AnimationTimer() { @Override public void handle(long now) { stop(); after.run(); } }.start(); }AnimationTimer的第一次handle调用发生在下一个脉冲,用它做同步点比Platform.runLater稳,因为runLater只保证在 FX 线程执行,不保证布局和 CSS 已经算完。SnapshotParameters里如果要固定背景,记得设setFill(Color.WHITE),否则透明区域在两台机器上可能呈现不同。
对比环节不需要引入重型库,把两张 PNG 转成像素数组逐点比较,统计差异比例就行。判定的经验阈值:
| 检查项 | 手段 | 判定标准 |
|---|---|---|
| 主题变量误改 | 全量快照 diff | 有变化的页面数应为 0 |
| 布局塌陷 | 快照尺寸检查 | 节点宽高不为 0,且与基线偏差小于 2px |
| 渲染管线差异 | 分别用sw和es2各跑一遍 | 差异集中在大面积渐变区属正常 |
| 字体缺失 | 文本区域 diff | 差异呈块状分布时优先怀疑字体回落 |
阈值不要设成零。抗锯齿和字体光栅化在不同机器上本来就有细微差别,全零判定会让 CI 天天红。我的习惯是单页差异像素占比低于 0.5% 直接放过,超过 5% 才拦下来人工看,中间区间只记录不阻断。
这套东西我最初嫌麻烦没做,直到有一次改了个全局-fx-font-size,把三张图表的轴标签挤成了省略号,过了两周才被业务同事发现,回头找是哪次提交引入的,翻了一下午 git log。后来把基线图和示例代码放在同一个仓库里,每次改 CSS 顺手更新基线,diff 里一眼能看出改了什么,后悔药提前备好。
如果只从这篇里带走一件事:把 Ensemble 当接口约定,而非当成演示看。挑一个你正在纠结的控件,照第 2 章的最小工程把它跑起来,改一行参数看一眼结果,比读十页文档快得多。希望帮到你。
本文还有配套的精品资源,点击获取