我用 Bézier 曲线创造了一个机器人
效果预览

可以先思考一下,如何使用代码实现上面的效果呢?
实现思路
开始之前,还是要从一个故事讲起,在一个遥远的未来世界,有一个独特的机器人,它的名字叫做小白,小白不像传统的机器人那样呆板,而是由流畅的曲线和精巧的结构构成。它的头部,特别引人注目。开始的时候,它的头部只是一个简单的贝塞尔曲线构成的轮廓,仿佛是一幅未完成的艺术品。
然而,随着时间的推移,小白的头部逐渐丰富起来。它学会了表情和情感,曲线逐渐变得更加细腻,仿佛一个真正的人类头部。小白的眼睛是最吸引人的地方,它们不仅能够传达情感,还能够扫描和分析周围的环境…
哈哈,言归正传,要实现上面的效果,其实是以下的技术实现思路:
- 四段三次贝塞尔曲线
在 使用四段三次 Bézier 曲线拟合圆 这篇博客里面,介绍了通过四段三次贝塞尔曲线去绘制一个圆,不止是圆,定义四段三次贝塞尔曲线能形成很多的形状。
- 形状组合和表情设计
定义一个表示形状的数据结构,其中包含四段贝塞尔曲线的描述,将多个形状组合成一个表情。通过组合不同的形状来设计表情,每个形状代表表情的一部分,例如眼睛、嘴巴、脸部等,然后在画布上绘制这些部分。
- 动作设计
定义一个机器人的数据结构,头部加入表情,机器人的其它部分和表情共同组成一个动作。
- 动作串联
将多个动作串联实现动画效果。可以通过在时间上连续绘制不同的动作,设计一个动画系统,根据时间轴逐步绘制不同的动作,从而实现机器人的动画效果。
在 Android 开发中,可以利用 Canvas 绘图功能、动画框架等来实现这种复杂的图形效果。从一段贝塞尔曲线开始,到最终的动画效果,这将是一个奇妙的旅程。
四段三次贝塞尔曲线组成基础形状
首先,通过四段三次贝塞尔曲线组成一系列形状做为基础,如下图所示:


因为需要形状的大小可以自由变化,定义了以下函数来获取一段三次贝塞尔曲线
/** * 获取一段三次贝塞尔曲线,centerX/centerY 确定中心点的位置,四个控制点都基于 radius 计算的出,这样改变 radius,即可改变形状的大小 */ fun getCurve( centerX: Float, centerY: Float, radius: Float, anchorRatioTR1: Float, anchorAngle1: Float, anchorRatioTR2: Float, anchorAngle2: Float, contRatioTR1: Float, contAngle1: Float, contRatioTR2: Float, contAngle2: Float ): Curve { val curve = Curve() curve.anchorPoint1.x = centerX + Math.cos(Math.toRadians(anchorAngle1.toDouble())).toFloat() * radius * anchorRatioTR1 curve.anchorPoint1.y = centerY + Math.sin(Math.toRadians(anchorAngle1.toDouble())).toFloat() * radius * anchorRatioTR1 curve.contPoint1.x = curve.anchorPoint1.x + Math.cos(Math.toRadians(contAngle1.toDouble())).toFloat() * radius * contRatioTR1 curve.contPoint1.y = curve.anchorPoint1.y + Math.sin(Math.toRadians(contAngle1.toDouble())).toFloat() * radius * contRatioTR1 curve.anchorPoint2.x = centerX + Math.cos(Math.toRadians(anchorAngle2.toDouble())).toFloat() * radius * anchorRatioTR2 curve.anchorPoint2.y = centerY + Math.sin(Math.toRadians(anchorAngle2.toDouble())).toFloat() * radius * anchorRatioTR2 curve.contPoint2.x = curve.anchorPoint2.x + Math.cos(Math.toRadians(contAngle2.toDouble())).toFloat() * radius * contRatioTR2 curve.contPoint2.y = curve.anchorPoint2.y + Math.sin(Math.toRadians(contAngle2.toDouble())).toFloat() * radius * contRatioTR2 return curve }定义一个数据结构描述四段贝塞尔曲线
/** * 四段贝塞尔曲线组成一个形状 */open class CurveShape { // 四段贝塞尔曲线之间的链接状态 (现在只考虑首尾相连的情况,不考虑交叉想连) var isLink = true var centerX = 0f var centerY = 0f //半径 做为一个基础值,所有的计算都基于这个值 var radius = 0f //存储四段贝塞尔曲线 val curveList = ArrayList<Curve>()}定义一个 CurveShapeView 来负责绘制形状
class CurveShapeView @kotlin.jvm.JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { // 默认颜色 透明 private var DEFAULT_COLOR = Color.TRANSPARENT // View 宽高 private var viewWidth = 0f private var viewHeight = 0f private var centerX = 0f // 画笔 private val paint: Paint by lazy { Paint(Paint.ANTI_ALIAS_FLAG) } //画笔 // 用于颜色渐变处理 private val argbEvaluator = ArgbEvaluator()//渐变色计算类 private var startColor: Int = DEFAULT_COLOR private var endColor: Int = DEFAULT_COLOR private var startStrokeWidth = 0f private var endStrokeWidth = 0f private var paintCap = Paint.Cap.ROUND private var curProgress: Float = 0f private lateinit var startVisualInfo: VisualInfo private lateinit var endVisualInfo: VisualInfo // 起始时的 curveShape private var startCurveShape: CurveShape? = null // 缓存绘制过程中生成的 curveShape,以便发送切换时作为起始 private var cacheCurCurveShape: CurveShape? = null init { debug("init()") initPaint() initStartAndEndVisualInfo() } private fun initPaint() { paint.isAntiAlias = true paint.style = Paint.Style.FILL_AND_STROKE paint.strokeCap = paintCap paint.color = DEFAULT_COLOR } private fun initStartAndEndVisualInfo() { startVisualInfo = VisualInfo(DrawInfo(), ViewPropertyInfo()) endVisualInfo = VisualInfo(DrawInfo(), ViewPropertyInfo()) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) debug("onSizeChanged()") viewWidth = w.toFloat() viewHeight = h.toFloat() centerX = viewWidth / 2 startStrokeWidth = centerX * 0f endStrokeWidth = centerX * 0f paint.strokeWidth = startStrokeWidth } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) drawCurveShape(canvas) } private fun drawCurveShape(canvas: Canvas) { // 与 changeVisualInfo 呼应,这个时候 centerX >0 if (startCurveShape == null) { this.startCurveShape = startVisualInfo.drawInfo.getCurveShape(centerX) updatePaint(endVisualInfo.drawInfo) if (curProgress > 0) { updateViewPropertyByProgress(centerX,startVisualInfo.viewPropertyInfo, endVisualInfo.viewPropertyInfo, curProgress) } } if (startColor != endColor) { paint.color = argbEvaluator.evaluate(curProgress, startColor, endColor) as Int } if (startStrokeWidth != endStrokeWidth) { paint.strokeWidth = startStrokeWidth + (endStrokeWidth - startStrokeWidth) * curProgress } startCurveShape?.let { cacheCurCurveShape = it.getCurrentCurveShape(endVisualInfo.drawInfo.getCurveShape(centerX), curProgress) val paths = cacheCurCurveShape!!.getCurveShapePaths() for (path in paths) { canvas.drawPath(path, paint) } } } /** * 切换 */ fun changeVisualInfo(visualInfo: VisualInfo, isInvalidate: Boolean = true, progress: Float = 0f) { this.curProgress = progress this.startVisualInfo = getCurrentVisualInfo() this.endVisualInfo = visualInfo debug("changeVisualInfo()") // 如果 changeVisualInfo 时,还没有进行测量,直接返回,延迟到 onDraw 里面执行切换后的逻辑 if (centerX <= 0) { return } if (cacheCurCurveShape != null) { this.startCurveShape = cacheCurCurveShape } else { this.startCurveShape = startVisualInfo.drawInfo.getCurveShape(centerX) } if (curProgress > 0) { updateViewPropertyByProgress(centerX,startVisualInfo.viewPropertyInfo, endVisualInfo.viewPropertyInfo, curProgress) } updatePaint(endVisualInfo.drawInfo) if (isInvalidate) { invalidate() } } fun setProgress(progress: Float) { curProgress = progress updateViewPropertyByProgress(centerX,startVisualInfo.viewPropertyInfo, endVisualInfo.viewPropertyInfo, curProgress) invalidate() } fun updateStrokeWidthRatio(endStrokeWidthRatio: Float, startStrokeWidthRatio: Float = 0f) { this.endStrokeWidth = centerX * endStrokeWidthRatio this.startStrokeWidth = centerX * startStrokeWidthRatio invalidate() } /** * 是否需要更新画笔 */ private fun isNadeUpdatePaint(orderInfo: DrawInfo): Boolean { var isNadeUpdatePaint = false startStrokeWidth = paint.getStrokeWidth() endStrokeWidth = centerX * orderInfo.paintStrokeWidthRatio if (startStrokeWidth != endStrokeWidth) { isNadeUpdatePaint = true } startColor = paint.getColor() endColor = orderInfo.targetColor if (startColor != endColor) { isNadeUpdatePaint = true } if (paint.style != orderInfo.paintSytle) { paint.style = orderInfo.paintSytle isNadeUpdatePaint = true } if (paint.strokeCap != orderInfo.paintCap) { isNadeUpdatePaint = true paint.strokeCap = orderInfo.paintCap } return isNadeUpdatePaint } /** * 根据切换到的状态更新画笔 */ private fun updatePaint(targetDrawInfo: DrawInfo) { startStrokeWidth = paint.getStrokeWidth() endStrokeWidth = centerX * targetDrawInfo.paintStrokeWidthRatio startColor = paint.getColor() endColor = targetDrawInfo.targetColor paint.style = targetDrawInfo.paintSytle paint.strokeCap = targetDrawInfo.paintCap } /** * 获取当前的 visualInfo */ fun getCurrentVisualInfo(): VisualInfo { var viewPropertyInfo = getCurrentViewPropertyInfo() var drawInfo: DrawInfo = getCurrentDrawInfo() val visualInfo = VisualInfo(drawInfo, viewPropertyInfo); return visualInfo } private fun getCurrentViewPropertyInfo(): ViewPropertyInfo { if (centerX <= 0f) { return return endVisualInfo.viewPropertyInfo } return this.getCurrentViewPropertyInfo(this.centerX) } private fun getCurrentDrawInfo(): DrawInfo { var drawInfo = DrawInfo() drawInfo.shapeType = endVisualInfo.drawInfo.shapeType drawInfo.isLink = endVisualInfo.drawInfo.isLink drawInfo.radiusRatio = endVisualInfo.drawInfo.radiusRatio drawInfo.targetColor = paint.color drawInfo.centerXRatio = endVisualInfo.drawInfo.centerXRatio drawInfo.centerYRatio = endVisualInfo.drawInfo.centerYRatio drawInfo.paintStrokeWidthRatio = endVisualInfo.drawInfo.paintStrokeWidthRatio drawInfo.paintSytle = endVisualInfo.drawInfo.paintSytle drawInfo.paintCap = endVisualInfo.drawInfo.paintCap return drawInfo } // 动画执行 private var actionValueAnimator = ValueAnimator.ofFloat(0f, 1f) fun execAnim(visualInfos: ArrayList<VisualInfo>, callback: AnimExecCallback? = null) { if (visualInfos.isNullOrEmpty()) { return } changeVisualInfo(visualInfos[0]) cancelAnim() actionValueAnimator.setDuration(visualInfos[0].duration) val listener = object : MultiAnimatorListener { override fun onAnimationUpdate(valueAnimator: ValueAnimator) { if (!visualInfos[0].isDelay) { setProgress(valueAnimator.animatedValue as Float) } } override fun onAnimationEnd(animation: Animator) { if (!visualInfos[0].isDelay) { setProgress(1f) } visualInfos.removeAt(0) if (visualInfos.isEmpty()) { callback?.onAnimationAllFinish() } else { execAnim(visualInfos, callback) } } } actionValueAnimator.addUpdateListener(listener) actionValueAnimator.addListener(listener) actionValueAnimator.start() } fun cancelAnim() { actionValueAnimator.removeAllUpdateListeners() actionValueAnimator.removeAllListeners() actionValueAnimator.cancel() }}下图演示从一个形状切换到另一个形状,并基于进度控制切换状态。

因为要做状态保存,定义一个 VisualInfo 类,其中 DrawInfo 用来控制绘制(作用于 View#onDraw()),ViewPropertyInfo 用于控制 View 本身属性(供 Android 属性动画调度)
/** * 协调 CurveShapeView 的位置和绘制等 */open class VisualInfo { var duration: Long = Constants.DEFAULT_DURATION var interpolatorType: Int = 8 var isDelay: Boolean = false var drawInfo = DrawInfo() var viewPropertyInfo = ViewPropertyInfo() constructor() constructor(drawInfo: DrawInfo, viewPropertyInfo: ViewPropertyInfo) { this.drawInfo = drawInfo this.viewPropertyInfo = viewPropertyInfo }}形状组合
有了形状,就可动手把他们组合成表情了

这里我定义了一个 EmoteInfo 类,其中包含面部、左右眼、嘴巴等形状
/** * 表情信息 */open class EmoteInfo { var duration: Long = Constants.DEFAULT_DURATION var interpolatorType: Int = 8 var isDelay: Boolean = false var faceVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_FACE, ViewPropertyInfoConst.DEFAULT_RENDERINFO_FACE) var leftEyeVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_LEFT_ORBIT) var leftCheekVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_LEFT_ORBIT) var rightEyeVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_RIGHT_ORBIT) var rightCheekVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_RIGHT_ORBIT) var mouseVisualInfo = VisualInfo(DrawInfoConst.DEFAULT_DRAWINFO_ORBIT, ViewPropertyInfoConst.DEFAULT_RENDERINFO_DOWN_ORBIT)}同样定义一个 FaceView 用来绘制表情
class FaceView @kotlin.jvm.JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : RelativeLayout(context, attrs, defStyleAttr) { private var _binding: LayoutFaceViewBinding? = null val binding get() = _binding!! private var curProgress: Float = 0f init { _binding = LayoutFaceViewBinding.inflate(LayoutInflater.from(context), this) initViews() } private fun initViews() { } fun changeEmote(emoteInfo: EmoteInfo, isInvalidate: Boolean = true, progress: Float = 0f) { this.curProgress = progress binding.cvFaceOrbit.changeVisualInfo(emoteInfo.faceVisualInfo, isInvalidate, progress) binding.cvLeftCheekOrbit.changeVisualInfo(emoteInfo.leftCheekVisualInfo, isInvalidate, progress) binding.cvLeftEyeOrbit.changeVisualInfo(emoteInfo.leftEyeVisualInfo, isInvalidate, progress) binding.cvRightCheekOrbit.changeVisualInfo(emoteInfo.rightCheekVisualInfo, isInvalidate, progress) binding.cvRightEyeOrbit.changeVisualInfo(emoteInfo.rightEyeVisualInfo, isInvalidate, progress) binding.cvMouseOrbit.changeVisualInfo(emoteInfo.mouseVisualInfo, isInvalidate, progress) } fun setProgress(progress: Float) { this.curProgress = progress binding.cvFaceOrbit.setProgress(progress) binding.cvLeftCheekOrbit.setProgress(progress) binding.cvLeftEyeOrbit.setProgress(progress) binding.cvRightCheekOrbit.setProgress(progress) binding.cvRightEyeOrbit.setProgress(progress) binding.cvMouseOrbit.setProgress(progress) } // 动画执行 private var actionValueAnimator = ValueAnimator.ofFloat(0f, 1f) fun execAnim(emoteInfos: ArrayList<EmoteInfo>, callback: AnimExecCallback? = null) { if (emoteInfos.isNullOrEmpty()) { return } changeEmote(emoteInfos[0]) cancelAnim() actionValueAnimator.setDuration(emoteInfos[0].duration) val listener = object : MultiAnimatorListener { override fun onAnimationUpdate(valueAnimator: ValueAnimator) { if (!emoteInfos[0].isDelay) { setProgress(valueAnimator.animatedValue as Float) } } override fun onAnimationEnd(animation: Animator) { if (!emoteInfos[0].isDelay) { setProgress(1f) } emoteInfos.removeAt(0) if (emoteInfos.isEmpty()) { callback?.onAnimationAllFinish() } else { execAnim(emoteInfos, callback) } } } actionValueAnimator.addUpdateListener(listener) actionValueAnimator.addListener(listener) actionValueAnimator.start() } fun cancelAnim() { actionValueAnimator.removeAllUpdateListeners() actionValueAnimator.removeAllListeners() actionValueAnimator.cancel() } override fun onDetachedFromWindow() { super.onDetachedFromWindow() cancelAnim() _binding = null }} 下图演示从一个表情切换到另一个表情,并基于进度控制切换表情,由于基于属性,可以在任何时间切换时获取当前属性,从而是切换时画面自然过渡。

其实到这一步,就可以通过变换表情来执行动画了,但总感觉少了点什么,所以下面底部加了个小影子
动作设计
定义一个 PoseInfo 类,代表一个动作,主要包含表情、底部的影子、头部的属性控制等
/** * pose (摆pose,或者理解为动画的关键帧) * TODO: 这里实现形式有问题,影子应该是现实意义上的“影子”,影子跟随头部移动(头部移动,影子基于头部属性跟随移动)这样就不用定义影子相关的动画执行了,设计动作时只考虑表情就好了,可以极大简化设计动作时的复制度。 * 并且跟随动画的形式,也避免了多重动画(上下摆动和 PoseInfo 里面定义的动画)冲突的问题。过了一天才想到这个点,以后再尝试吧。 */open class PoseInfo { var duration: Long = Constants.DEFAULT_DURATION var interpolatorType: Int = 8 var isDelay: Boolean = false var emoteInfo: EmoteInfo = EmoteInfo() var trayVisualInfo: VisualInfo = VisualInfo() // 跟随动画的实现方式,这块只保留大小和颜色就好了 var robotViewPropertyInfo: ViewPropertyInfo = ViewPropertyInfo() var headViewPropertyInfo: ViewPropertyInfo = ViewPropertyInfo() var trayContainerViewPropertyInfo: ViewPropertyInfo = ViewPropertyInfo()// 跟随动画的实现方式,这块就不需要了}同样定义一个 RobotView 负责展示和执行动作
class RobotView @kotlin.jvm.JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : RelativeLayout(context, attrs, defStyleAttr) { private var _binding: LayoutRobotViewBinding? = null private val binding get() = _binding!! private var centerX = 0f private var curProgress: Float = 0f private lateinit var startPoseInfo: PoseInfo private lateinit var endPoseInfo: PoseInfo private val selfViewAnimDelegate: ViewAnimDelegate by lazy { ViewAnimDelegate(this, centerX) } private val headViewAnimDelegate: ViewAnimDelegate by lazy { ViewAnimDelegate(binding.flHeadContainer, centerX) } private val trayViewAnimDelegate: ViewAnimDelegate by lazy { ViewAnimDelegate(binding.flTrayContainer, centerX) } init { _binding = LayoutRobotViewBinding.inflate(LayoutInflater.from(context), this) initViews() } private fun initViews() { startPoseInfo = PoseInfo(EmoteInfo(), VisualInfo()) endPoseInfo = PoseInfo(EmoteInfo(), VisualInfo()) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) centerX = w.toFloat() / 2 } fun changePose(poseInfo: PoseInfo, isInvalidate: Boolean = true, progress: Float = 0f) { this.curProgress = progress binding.fvFace.changeEmote(poseInfo.emoteInfo, isInvalidate, progress) binding.cvTrayShape.changeVisualInfo(poseInfo.trayVisualInfo, isInvalidate, progress) endPoseInfo = poseInfo if (centerX == 0f) { return } startPoseInfo.robotViewPropertyInfo = this.getCurrentViewPropertyInfo(centerX) startPoseInfo.headViewPropertyInfo = binding.flHeadContainer.getCurrentViewPropertyInfo(centerX) startPoseInfo.trayContainerViewPropertyInfo = binding.flTrayContainer.getCurrentViewPropertyInfo(centerX) this.updateViewPropertyByProgress(centerX, startPoseInfo.robotViewPropertyInfo, poseInfo.robotViewPropertyInfo, progress) binding.flHeadContainer.updateViewPropertyByProgress( centerX, startPoseInfo.headViewPropertyInfo, poseInfo.headViewPropertyInfo, progress ) binding.flTrayContainer.updateViewPropertyByProgress( centerX, startPoseInfo.trayContainerViewPropertyInfo, poseInfo.trayContainerViewPropertyInfo, progress ) } fun setProgress(progress: Float) { this.curProgress = progress binding.fvFace.setProgress(progress) binding.cvTrayShape.setProgress(progress) this.updateViewPropertyByProgress(centerX, startPoseInfo.robotViewPropertyInfo, endPoseInfo.robotViewPropertyInfo, progress) binding.flHeadContainer.updateViewPropertyByProgress( centerX, startPoseInfo.headViewPropertyInfo, endPoseInfo.headViewPropertyInfo, progress ) binding.flTrayContainer.updateViewPropertyByProgress( centerX, startPoseInfo.trayContainerViewPropertyInfo, endPoseInfo.trayContainerViewPropertyInfo, progress ) } override fun onDetachedFromWindow() { super.onDetachedFromWindow() cancelActionAnim() _binding = null }}动作串联
在 RobotView 定义了动画执行控制器,其实就是一个属性动画
/** * 整体一起执行动画,依次执行设计好的动作 */ private var actionValueAnimator = ValueAnimator.ofFloat(0f, 1f) fun execAction(posts: ArrayList<PoseInfo>, callback: AnimExecCallback? = null) { if (posts.isNullOrEmpty()) { return } changePose(posts[0]) cancelActionAnim() actionValueAnimator.setDuration(posts[0].duration) val listener = object : MultiAnimatorListener { override fun onAnimationUpdate(valueAnimator: ValueAnimator) { if (!posts[0].isDelay) { setProgress(valueAnimator.animatedValue as Float) } } override fun onAnimationEnd(animation: Animator) { if (!posts[0].isDelay) { setProgress(1f) } posts.removeAt(0) if (posts.isEmpty()) { callback?.onAnimationAllFinish() } else { execAction(posts, callback) } } } actionValueAnimator.addUpdateListener(listener) actionValueAnimator.addListener(listener) actionValueAnimator.start() } private fun cancelActionAnim() { actionValueAnimator.removeAllUpdateListeners() actionValueAnimator.removeAllListeners() actionValueAnimator.cancel() } fun execEmotesAnim( emotes: ArrayList<EmoteInfo>, emotesCallback: AnimExecCallback? = null ) { binding.fvFace.execAnim(emotes, emotesCallback) }考虑到机器人外部执行动画的同时,做表情变化(比如演示中上下摆动的同时做表情变换),在 FaceView 里面同样定义了动画执行控制器。
至此,按照开始时的思路已一一实现。
做这个小机器人有什么用呢
除了拿来写博客,它还有别的用处吗?
- 可以设计一系列表情,做矢量图形使用。
- 人脸验证的时候,提示张嘴、闭眼,可以用表情动画做对应的提示。
- 多设计几组动作,结合 ChatGpt Api 做一个聊天机器人应该也挺有意思的吧。
相关资料
这份代码虽然还有很多不足的地方(对象频繁创建,整个代码结构可以使用设计模式做规整,捏和图形像是徒手磨螺丝,机器人整体动画实现方式不合理),再休息一段时间之后再来优化吧。或许做减法,只到表情那块刚刚好。 另外,通过这个作品感受到了 kotlin 的简洁性,如果用 java,今天应该还完不成。