![]() |
咆哮的枇杷 · PickerView居中,循环显示一系列_a ...· 1 年前 · |
![]() |
仗义的莲藕 · Java GUI 容器与布局-阿里云开发者社区· 1 年前 · |
![]() |
谦虚好学的大脸猫 · java - Error calling ...· 1 年前 · |
![]() |
踢足球的鼠标 · Youtube player ...· 1 年前 · |
搜狗地图发布了新版的移动端地铁图,改版初衷是为了用户交互体验的提升以及性能的改善。原版地铁图被用户吐槽最多的是pinch缩放不流畅、无过渡动画、拖拽边界不合理等等,大体上都是交互体验上的问题。实际上原版的问题不仅仅存在于交互体验上,源代码也是一团糟:
以上问题其实跟业务以及技术选型无关,可以说是任何一个“历史悠久”的项目都难以避免的问题。针对以上问题的重构方案不是本文要阐述的核心,所以就一笔带过。如下:
本文重点讨论搜狗地铁图对SVG的使用和优化方案。在讨论技术细节之前,我们先说明一下为什么要使用SVG。
不论是从业务类型还是操作方式的角度考虑,地铁图都可以被视为一种微型或者简易的地图。我们可以先回想一下手机地图的一些基本操作,举几个简单的例子:
以上几种操作的技术实现需要遵循以下几个基本原则:
注:之所以将“矢量”加引号是因为地图的实现包括栅格瓦片和矢量瓦片两种不同的技术方案。顾名思义,矢量瓦片是真正意义上的矢量地图,由OpenGL或者WebGL实现;而由栅格瓦片实现的地图并不是矢量的,缩放时会看到明显的模糊效果,但是缩放动作完成后会展示对应等级的栅格图片,也就是说缩放后的内容是清晰的,只是缩放过程中存在模糊效果。随着WebGL的普及,栅格瓦片技术逐渐退出了历史舞台。
简单概括,地图必须是:
即使是栅格瓦片地图,POI点也是动态绘制的,感兴趣的读者可以自行查阅相关信息。
地铁图同样如此,而Web展示矢量内容只有两种方案:WebGL和SVG。虽然WebGL更富有视觉表现力,但是地铁图业务的体量较小,并没有达到值得用WebGL实现的程度,所以SVG便成了唯一的选择。
旧版的搜狗地铁图虽然也是使用SVG绘制UI,但是并没有将SVG的动态优势发挥出来,而是将其视为静态的图片。 图1 是旧版地铁的DOM结构:
蓝色框的svg是地铁图的UI内容,除了尺寸以外没有任何其他的属性。红色框是地铁图外层容器,可以看到所有的偏移、缩放等交互都是借由外层容器的transform实现。黑色框的各个DOM节点包括了定位、求路、信息气泡等内容,这些DOM往往需要跟随用户操作被改动,而且某些操作可能需要同时操作多个DOM。
接下来我们看看这样的DOM结构存在什么问题。
定位、求路、信息气泡等内容是与地铁图强耦合的 ,假设我选中了某个地铁站,如 图2 :
红色框内的信息气泡对应到上图的
container3
节点,地铁底图对应
container1
节点。如果此时我们拖拽地铁图,底图和信息气泡都会随着手势而改变位置,那么就需要同时改变
container1
和
container3
的位置。
我们把同样的问题带入到求路,如 图3 :
我并没有画出每个UI对应的节点,因为实在是太多了。上图中包括了2个转乘节点、2个起终节点和3个气泡节点,拖拽过程中这7个DOM节点全部需要被操作。并且不仅仅是改写DOM属性那么简单,而是需要先获取每个节点的坐标然后再进行计算,而我们都知道, 获取DOM的offset是非常消耗性能的 。此外, 求路状态下的地铁图必须缩放到完整展示求路路线的等级 ,那么就需要计算求路路线的轮廓尺寸,其中也会涉及到大量的计算和DOM操作。
其实拖拽是非常基本的操作,如果是缩放呢?抛开大量的计算和DOM操作不谈,从视觉上表现如 图4 所示:
为什么气泡和起终点等节点没有同比例缩放?因为这些节点不是矢量的SVG,缩放会失真。如果想得到“矢量”的缩放效果只能重新计算这些节点的尺寸,这样的代价太大了。所以我们不得不忍受这些问题。
总结以上的问题可以概括出两点:
以上问题的症结可以归纳为:
container1
实现,坐标的获取只能借助于常规的DOM API;
简单来讲,旧版地铁图的核心问题是DOM结构不合理,并且没有把SVG的动态特性发挥出来。
重构后的DOM结构如 图5 所示:
handler
节点负责直接响应手势操作,拖拽、缩放等操作首先会改变
handler
的
transform
样式;
container
节点是svg容器,负责以浏览器窗口为参考将地铁图居中;
view
节点是所有与地铁图展示相关内容的容器,包括底图、定位、气泡、求路等等等等。同时,手势操作最终会修改
view
的transform属性,以实现地铁图本身的缩放。
以上说明可能有些难以理解,我们用具象的图形加以说明。分层的结构大致如 图6 所示,从外到里分别是handler/container/view:
此时如果用户进行了手势操作,以pan-拖动为例:
panstart
事件触发后记录拖动的初始坐标,不影响分层结构中的任何一层,也就是说不改变任何一层的任何属性或样式;
panmove
事件频繁触发,即拖动过程中,映射为handler层
transform
的改动,container和View无任何变化。如下
图7
:
3. pancancel/panend
事件触发后修正handler合理的偏移量(详情请阅读下文的边界控制),同时将修正后的
transform
属性值换算为view的
transform
,最后将handler的
transform
归零。如
图8
:
代码如下:
1 /**
2 * @constant PrevOffset 前一次拖拽的坐标偏移量
3 * @type {Object}
4 */
5 const PrevOffset = {
6 x: undefined,
7 y: undefined
8 };
10 EventRuntime.on('panstart panmove pancancel panend', e => {
11 e.preventDefault();
12 e.srcEvent.stopPropagation();
13 // panstart事件记录初始坐标
14 if (e.type === 'panstart') {
15 PrevOffset.x = e.deltaX;
16 PrevOffset.y = e.deltaY;
17 } else if (e.type === 'panmove') {
18 // handler位移设置增量
19 subway.setTranslate(e.deltaX - PrevOffset.x, e.deltaY - PrevOffset.y);
20 PrevOffset.x = e.deltaX;
21 PrevOffset.y = e.deltaY;
22 } else {
23 // 拖拽结束后换算hander和view的transform,同时修正合理偏移量
24 subway.adjustTransform('translate');
25 }
26 });
分层结构中三者的作用可以简单概括为:
可能你会疑问为什么不直接改变view的
transform
?额外加一层handler的作用是什么?在回答这个问题之前我们不妨先思考一下如果直接改变view的
transform
来响应拖动和缩放会有哪些不足。
动画是前端交互中的重点,为了提供顺畅的操作体验,最典型的优化动画方向是:
搜狗地铁图有三种基本的操作: 1) 点击某个站点,将此站点居中,期间有缓动动画如下 图9 :
2) 拖动到地铁图边界后,拖动结束(即手指离开屏幕)后需要修正拖动边界,否则会停留在拖动结束的状态可能造成大面积空白。这种修正类似 Safari IOS的橡皮筋效果 。修正过程中有缓动动画如下 图10 :
3) 与拖动类似,缩放同样有边界限制,否则会无限制的放大/缩小。修正缩放边界期间有缓动动画如下 图11 :
GIF图片表现力有限,不能表现完美的效果。体验真实的效果请下载搜狗地图APP进入到地铁图查看。
回到最初的问题:如果直接改变view的
transform
如何实现缓动效果?
这里需要注明两个前提知识点:
transform
是一个属性,与CSS的
transform
是两个不同的概念,两者使用的坐标体系有一定差异;
transition
的属性,也就是说SVG没有原生支持过渡动画的功能。
关于SVG transform的详细知识可以参考 理解SVG transform坐标变换 。
所以如果我们在view的
transform
上下功夫实现缓动动画的话,只能通过JS结合缓动公式和
requestAnimationFrame
计算每一帧的SVG
transform
值,或者使用第三方现有的动画工具库,比如
TweenJS
。
transform
的计算非常复杂,尤其是同时存在
scale
和
transiton
的场景下。既然CSS的
transiton
可以使用浏览器提供的缓动动画,那我们为什么不把复杂的工作交给浏览器呢?
transiton
作为偏移、缩放的缓动动画媒介必须搭配CSS的
transform
,但是我们不能直接通过view的style修改
transform
。原因有二:
transform
和SVG的
transform
不能等同;
transform
进行边界控制(下文详述),也就是说
偏移和缩放的效果最终需要换算为SVG的
transform
但在动画执行期间不能修改
。
那么我们便得出了handler存在必要性的证明之一,也就是优化动画的第一条: 缓动 。接下来我们尝试进一步优化动画的性能。
我们都知道CSS的3D
transform
可以强制启用GPU加速以优化动画的表现,自然会想到SVG可不可以使用GPU加速呢?很可惜,答案是否定的。SVG是一种表现2D矢量图形的技术,它在设计之初便没有考虑3D的场景,所以SVG并没有3D
transform
,也无法借助GPU对动画进行加速。
那么我们便得出了handler存在必要性的第二个证明: GPU加速 。
其实业内对于借助GPU加速动画的方案褒贬不一,即便是启用GPU加速也有 方案的优劣 。我们此次重构只是第一步,后续仍旧会不断探索进一步的优化方案。
SVG没有
transform-origin
概念,
transform
的原点永远都是自身的左上角,即
(0,0)
。
大家可以想象一下在手机上用两根手指缩放地铁图的场景,我们需要知道地铁图应该以屏幕上的哪一点作为中心进行缩放。从技术角度来讲,我们
需要知道两个触控点的中心位置坐标
。不论是IOS系统原生的
gesture
事件,还是通过touch事件模拟的
pinch
事件(如HammerJS)使用的都是浏览器坐标系,也就是CSS坐标系。
如果一定要把中心点坐标映射到SVG坐标系,则需要一定的计算量(下文详述)。在缩放操作过程中需要频繁地改变被缩放DOM的
transform
从而引起重绘(re-render),这期间浏览器本身就进行着大量计算,所以在应用程序层面应该尽可能减少计算量。
关于重绘和重排,可以参考 浏览器的重绘与重排 。
这也是handler节点存在必要性的第三个证明: 减轻计算量 。
有了handler节点的辅助,缩放操作进行中(请注意是进行中,不包括起始和结束时刻)唯一的计算便是handler的
transform
,无需将其转换为SVG的
transform
。当然,换算仍然是必须的,但是我们将其推迟到缩放操作结束之后进行,这样便可以在一次完整的操作流程中只进行一次换算工作,大大减少了总体的计算量。具体的换算公式下文详述。
上文并没有过多的描述container节点,因为它的作用非常简单。 container作为svg的容器,同时在初始化时以浏览器窗口为参考将地铁图居中 。如下 图12 所示:
container节点的高宽均为2000,决定这个数字的唯一原则是:
只要比view节点的尺寸大即可
。所以我们设置了一个比较大的值。container节点的尺寸会影响它自身的
left
和
top
,上图中红色标注是container节点居中的偏移量:
1 Offset.x = (container.width - window.innerWidth)/2;
2 Offset.y = (container.height - window.innerHeight)/2;
那么container节点的CSS便是:
1 container.style.cssText = [
2 'postion: absolute;',
3 `left: -${Offset.x};`,
4 `top: -${Offset.y};`
5 ].join('');
transform是应用到view节点,边界控制同样是以view节点的尺寸为计算因子。所以,在初始化之后container不再进行任何改动,它的作用至此便完全体现了。
transform是应用到view节点,边界控制同样是以view节点的尺寸为计算因子。所以,在初始化之后container不再进行任何改动,它的作用至此便完全体现了。
可能你会冒出这样一个疑问:handler使用的是CSS的坐标体系,那么它的
transform
要换算成SVG坐标的计算一定很复杂吧?这个问题的有两个难点:
transform-origin
的概念和功能,但是我们需要借助CSS的
transform-origin
计算缩放中心,这进一步复杂化了换算逻辑。
如果SVG设置了
viewBox
属性,那么它所使用的坐标系便不同于CSS坐标系。此外,SVG的
preserveAspectRatio
也会影响坐标系的细节。这两个属性在实现SVG缩放时非常关键,但搜狗地铁图并没有借助
viewBox
实现缩放,而是将全部的展示交给了view节点的
transform
,一定程度上减轻了CSS和SVG坐标差异性造成的计算复杂度。同时,我们将
preserveAspectRatio
属性值设置为
"xMinYMin meet"
,即强制宽高等比例缩放。
远于SVG坐标系的更多细节可以参考 理解SVG坐标系和变换:视窗,viewBox和preserveAspectRatio
剩下的问题就是如何将CSS的
transform-origin
换算成SVG的
transform
了。
transform-origin
”
SVG与CSS
transform
的相同点是:两者都是以自身为变换坐标系。但SVG的
transform
原点不能改变,永远都是自身的左上角,即
(0,0)
。
那么SVG如何实现类似CSS
transform-origin
效果呢?
假设我想让SVG以点
(50,30)
为原点放大1.5倍,我需要按照下述顺序依次对SVG进行变换:
translate(50 30)
->
scale(1.5 1.5)
->
translate(-50 -30)
。先将SVG偏移到点
(50,30)
;然后再将SVG放大1.5倍(请谨记SVG
transform
的原点是自身的左上角);最后再将SVG反向偏移
(50,30)
。具体变换过程可以参考
图13
:
更多技术细节请参考 这篇文章 。
SVG的
transform
属性值为
translate(50 30) scale(1.5 1.5) translate(-50 -30)
。由于地铁图的操作频繁是,涉及到大量变换,所以我们用matrix表示。以上的
transform
属性值换算为matrix表示为
matrix(1.5 0 0 1.5 ${(1-1.5)*50} ${(1-1.5)*30})
。
至此我们便总结出SVG以点
(ox,oy)
为原点进行缩放的
transform
计算公式:
transform = matrix(sx 0 0 sy (1-sx)*ox (1-sy)*oy)
接下来我们根据以上的前提知识点推导出具体的换算公式。
为了更清晰地推算换算公式,我们假设在缩放地铁图之前已经有了一定的偏移量和缩放比例,如下 图14 :
假设此时View节点的
transform
属性值为
matrix(scale 0 0 scale dx dy)
,简化为:
View.scale
- view节点的初始缩放值;
View.dx
&
View.dy
- View节点的初始偏移量。
因为我们为SVG设置了
preserveAspectRatio="xMinYMin meet"
,即强制宽高等比例缩放,所以scaleX = scaleY,我们统一使用scale表示。
同时我们将handler的样式设置为:
1 `transform: translate3d(${dx}, ${dy}, 0px) scale(${scale});`
2 `transform-origin: ${ox} ${oy} 0px;`
即:
Handler.dx
&
Handler.dy
- handler节点的偏移量;
Handler.scale
- handler节点的缩放值;
Handler.
ox&
Handler.oy
- handler节点的
transform-origin
坐标。
需要特别注意的一点是,handler节点的
transform
我们并未使用matrix表示,而是直接用
translate3d
和
scale
。
非matrix表示
transform
时的变换顺序非常重要
,按照从左往右的顺序后面的变换是以前面的变换为基础。也就是说,handler节点的
transform
是先进行
translate3d
-偏移变换,然后在偏移之后的状态基础上再进行
scale
-缩放变换。
另外还有一个重要前提:目前版本我们 将缩放和拖动操作割裂开 ,同一时间只能进行缩放或者拖动操作。也就是说,缩放操作只改变Handler.scale和Handler.ox&Handler.oy,拖动操作只改变Handler.dx&Handler.dy。后续版本会探索将两种操作耦合的可行性方案。
接下来我们详细讲解一下scale的换算公式,大家请先仔细研究下 图15 所示的缩放状态
transform
属性值为
matrix(scale 0 0 scale dx dy)
;
此时对应的DOM状态如下 图16 所示
(50px,40px)
为原点缩放了1.2倍;
transform="matrix(1.1 0 0 1.1 194 75)"
,即缩放了1.1倍,X轴偏移194,Y轴偏移75。
接下来要做的事情是吧Handler的
transform
以及
transform-origin
换算为SVG的
transform
,然后将Handler节点
transform
和
transform-origin
归零。换算公式如下:
1 View.scale = View.scale * Handler.scale;
2 View.dx = View.dx + (1 - Handler.scale)*(Handler.ox + Offset.x - View.dx);
3 View.dy = View.dy + (1 - Handler.scale)*(Handler.oy + Offset.y - View.dy);
公式的推导过程并不复杂,因为我们并没有改变SVG的Viewbox,所以其坐标系与CSS坐标系并无二致。所以只需要将场景代入CSS坐标系,同时将
transform-origin
设置为
(0,0)
,在此前提下进行推导公式便非常简单了。
将CSS的transform-origin设置为’0,0’后,transform的规则与SVG的transform便完全一样了。如果你熟悉CSS的transform,SVG的transform便不会有任何问题。因为CSS的transform属性本身就是从SVG的transform借鉴而来,只是加入了transform-origin这个语法糖。
顾名思义,边界控制的作用是限制地铁图的可操作边界,包括拖拽边界和缩放边界。 拖拽边界 指的是地铁图上下左右四个方向上的可拖动的最大距离。 缩放边界 指的是地铁图可被缩放的最大和最小比例。两种边界控制的具体的交互表现可参考上文“缓动动画”一节的图10和图11。
从图12很容易得出 初始的拖拽边界 ,请参考以下伪代码:
ViewBox <- 计算View的坐标和尺寸
Viewport <- 获取浏览器的尺寸
Offset <- 计算Container相对浏览器的偏移量
往右拖动的最大距离MaxX = Offset.x - BBox.x
往左拖动的最大距离MinX = ViewBox.width-(Offset.x - BBox.x + Viewport.width)
往下拖动的最大距离MaxY = Offset.y - BBox.y
往上拖动的最大距离MinY = ViewBox.height-(Offset.y - BBox.y + Viewport.height)
注意
,因为拖拽的边界最终映射到
translate
上,所以左拖动边界和上拖动边界的值是上述伪代码所计算出来结果的相反数,即始终为负数或者0。
随后用户进行拖拽和缩放操作后,拖拽边界便随之动态变化。计算动态拖拽边界的时候需要考虑两点:
transform-origin
,是重要的计算因子;
将以上规则带入计算,伪代码如下:
Viewport <- 获取浏览器的尺寸
TransformOrigin <- transform-origin的值
Scale <- 缩放比例
Translate <- 偏移量
往右拖动的最大距离MaxX = Prev_MaxX*Scale + TransformOrigin.x*(Scale-1) - Translate.dx;
往左拖动的最大距离MinX = Prev_MinX*Scale - (Viewport.width-TransformOrigin.x)*(Scale-1) - Translate.dx;
往下拖动的最大距离MaxY = Prev_MaxY*Scale + TransformOrigin.y*(Scale-1) - Translate.dy;
往上拖动的最大距离MinY = Prev_MinY*Scale - (Viewport.height-TransformOrigin.y)*(Scale-1) - Translate.dy;
THEN 修正
MinX: MinX<MaxX?MinX:Math.min(0,MinX)
MaxX: MaxX>MinX?MaxX:Math.max(1,MaxX)
MinY: MinY<MaxY?MinY:Math.min(0,MinY)
MaxY: MaxY>MinY?MaxY:Math.max(1,MaxY)
这些公式的推导过程说复杂也复杂,说简单其实也很简单。道理与上文的scale换算一样,因为SVG的viewBox没有改变,所以只需将SVG带入CSS坐标系即可迎刃而解。篇幅所限,具体的推导过程便不再赘述。
与拖拽边界不同的是,缩放边界是固定的,一经初始化便不会再改动。具体如何控制缩放的边界其实并没有统一的方案,不同的团队可能有不同的见解,比如高德和百度的地铁图最小缩放比例小仍然无法展示底图的全貌。搜狗地铁图在评审和开发过程中有过几次商讨,最终定下的方案是:
也就是说,不同城市地铁图的最小缩放比例是不同的,因为每个城市的地铁线路个数、长度均有所差异,需要动态计算。计算的方法很简单,唯一需要注意的是一定要将浏览器的宽高比作为计算的因子。请参考以下伪代码:
ViewBox <- 计算View的坐标和尺寸
Viewport <- 获取浏览器的尺寸
AspectRatioOfWindow <- 浏览器的宽高比
最大缩放比例 = 1.5
最小缩放比例 = ViewBox.width/ViewBox.height < AspectRatioOfWindow ? Viewport.height/ViewBox.height : Viewport.width/ViewBox.width;
其实我个人觉得高德和百度的方案更佳,因为手机屏幕尺寸比较小,即使展示地铁全貌也看不清楚细节,索性不如将最小比例写死为一个能够看清楚细节的临界值。这样不仅能减少计算量,而且从整体交互上也比较人性化。但是胳膊拧不过大腿,最终还是信了PM的邪。。。
为什么要把这一条单拎出来讲,是想提醒一下大家千万不要一味的追求所谓的流行技术和框架。我曾经见过很多前端工程师在介绍React/Vue的优点时一定要唾弃直接操作DOM和jQuery/PrototypeJS等“老家伙们”。不可否认React/Vue确实很大程度上解放了生产力,但是并非所有的场景均适合使用它们,比如地铁图的手势操作。地铁图响应手势操作的过程中需要频繁的改变底图的
transform
,那么请大家思考以下两种方式哪个性能更好:
v-bind:transform="transform"
;
this.$refs.handler.cssText=transform
第二种实现是不是Vue的“反模式”?仁者见仁。但是从实际效果来看第二种具有绝对的性能优势,其背后的道理很简单。对于手势操作这种几乎每一帧都需要响应的场景来说,逻辑越少越好,而Vue在改变DOM之前需要处理一系列复杂的逻辑,与直接操作DOM相比,性能孰好孰坏显而易见。
Vue的动态绑定把DOM操作封装在框架内部,高内聚的框架让开发者无需关心具体实现,但是基本的原理仍然未脱离DOM这一核心因素。
首先加载主逻辑文件index.js,然后index.js中的逻辑获取url的城市参数名称,随后异步加载对应城市的数据文件,加载完成后进行解析和渲染。如下图:
这种流程对于常规的web站点没有任何问题,因为常规的web网站所有城市共用一套代码,只能从参数区分城市名称。但是Hybrid地铁图使用的是离线包而不是web站点,每个城市均打包为对应名称的离线包,比如北京的源码被打包为beijing.zip。也就是说,每个城市的代码是互不影响的,这是优化的重要前提。
针对离线包的构建流程中加入额外的功能,即 把每个城市的数据js引用在构建阶段注入到index.html中 。如下:
这样可以实现数据文件的同步加载,与旧版的对比节省了以下时间:
需要说明的是,虽然单纯加载数据文件,不论是同步还是异步方式,两者的时间完全一致。但是如果按照原本的异步加载流程,数据文件便无法利用浏览器http并行加载的优势,即使这个时间可能微乎其微。
历史原因,地铁数据被制备为XML格式的字符串,解析数据需要先将其转换为XML对象,然后再转换为JSON格式。且所有的解析工作均在客户端浏览器执行,如下:
将数据的解析工作提前到源码构建阶段 ,客户端直接接触的是解析后的JSON格式数据,减少客户端负载和用户的等待时间。如下:
此外,旧版的解析数据中存在大量冗余的字段,本次重构将这些冗余字段删除,进一步减小了文件体积。
以北京的地铁数据为例,分别对比优化前后的数据文件的体积以及解析所消耗的时间。
1> 文件体积
- |
XML |
JSON-未优化 |
JSON-优化 |
---|---|---|---|
未压缩 |
145KB |
288KB |
149KB |
压缩 |
30KB |
58KB |
31KB |
结论:单纯从文件体积衡量,优化前后的差距几乎可以忽略。
2> 解析时间
设备信息:
模拟环境:Chrome
测试结果(取十次平均值):
设备性能 |
原始 |
慢4倍 |
慢6倍 |
---|---|---|---|
解析时间-优化前 |
45.6ms |
281.2ms |
294.3ms |
解析时间-优化后 |
0 |
0 |
0 |
结论:优化后无需解析,直接进行底图渲染。 设备性能越差,优化前后的对比越明显 。
技术栈本身并无好坏之分,优劣体现在与业务的契合度上。老版本搜狗地铁图的问题核心并非在于技术栈的不合理,甚至以当时开发第一版地铁图的时间节点来看,其技术栈算得上优秀。技术架构和实现方式上的混乱是造成老版本地铁图性能和交互问题的根本。
优化技术架构是重构的第一步,但完成架构的升级只算完成了一半。特殊的运行方式(离线包)决定了不能将地铁图等同为常规的Web站点,这种特殊性也提供了进一步优化的空间,这是重构工作的第二步。所以在本次地铁图重构项目过程中可以提炼出重构的两个基本点:
![]() |
仗义的莲藕 · Java GUI 容器与布局-阿里云开发者社区 1 年前 |
![]() |
谦虚好学的大脸猫 · java - Error calling `jakarta.validation.Validation#buildDefaultValidatorFactory` - Stack Overflow 1 年前 |