pop
pop
Published on 2025-05-20 / 43 Visits
0
0

使用Compose构建一个Nomi?

如有侵权,请联系邮箱删除。

如果你了解新能源汽车,那你一定知道蔚来。而Nomi正是蔚来(NIO)汽车推出的车载 智能语音助手 / 人机交互系统,也被称为“车载人工智能伙伴”。它是当前智能座舱的一部分,被广泛用于提升用户在车内的智能交互体验。

Nomi 是全球首个有拟人形象的 AI 语音助手,如上,是Nomi的各种表情。

基于Nomi的形象,我使用Compose在Android平台开发了一套2D的 “类Nomi”形象。

项目地址

相对于使用帧动画对于内存的占用,使用Canvas绘制表情能够不仅更流畅,而且能够避免表情之间硬切的割裂感。

核心结构

我们常常需要对“眨眼”“移动”“缩放”“旋转”等多种属性进行统一管理,并能动态驱动它们的平滑过渡。RobotStatus 就是一种 “全局状态容器”,它把与眼部和动作相关的所有可过渡属性(TransitionProperty)集中到一个对象里,方便后续在 Compose 动画或其他渲染管线中统一取值和更新。

TransitionProperty

所有需要过渡的数值一律用此类型包装,简化参数一致性和复用逻辑。

@Serializable
data class TransitionProperty(
    val initialValue: Float = 0F,
    val targetValue: Float = 0F,
    val duration: Int = 200,
    val infinite: Boolean = false,
    val repeatMode: RepeatMode = RepeatMode.Restart,
    val centerPivotLevel: PivotLevel = PivotLevel.Center,
    @Transient val easing: Easing = LinearEasing,
    @Transient var animationSpec: AnimationSpec<Float>? = null
)

RobotStatus

动画驱动值一目了然,后端逻辑或 UI 只需要读取对应字段即可,无需分散在各处维护。


open class RobotStatus(
    /**
     * 左眼边缘角度
     */
    val leftEyeCornerRadius: TransitionProperty = TransitionProperty(initialValue = 30F, targetValue = 30F),

    /**
     * 右眼边缘角度
     */
    val rightEyeCornerRadius: TransitionProperty = TransitionProperty(initialValue = 30F, targetValue = 30F),

    /**
     * 左眼皮水平半径
     */
    val leftEyeHorizontalRadius: TransitionProperty = TransitionProperty(initialValue = 30F, targetValue = 30F),

    /**
     * 右眼皮水平半径
     */
    val rightEyeHorizontalRadius: TransitionProperty = TransitionProperty(initialValue = 30F, targetValue = 30F),

    /**
     * 左上眼皮垂直半径
     */
    val leftTopEyeRadius: TransitionProperty = TransitionProperty(initialValue = 60F, targetValue = 60F),

    /**
     * 左下眼皮垂直半径
     */
    val leftBottomEyeRadius: TransitionProperty = TransitionProperty(initialValue = 60F, targetValue = 60F),

    /**
     * 右上眼皮垂直半径
     */
    val rightTopEyeRadius: TransitionProperty = TransitionProperty(initialValue = 60F, targetValue = 60F),

    /**
     * 右下眼皮垂直半径
     */
    val rightBottomEyeRadius: TransitionProperty = TransitionProperty(initialValue = 60F, targetValue = 60F),

    /**
     * 眼部填充颜色
     */
    val eyesFillColor: String = "#00000000",

    /**
     * 眼部旋转角度
     */
    val eyesRotate: TransitionProperty = TransitionProperty(),

    /**
     * 眼部水平位移
     */
    val eyesHorizontalTransition: TransitionProperty = TransitionProperty(),

    /**
     * 眼部垂直位移
     */
    val eyesVerticalTransition: TransitionProperty = TransitionProperty(),

    /**
     * 眼部X方向缩放
     */
    val eyesScaleX: TransitionProperty = TransitionProperty(initialValue = 1F, targetValue = 1F),

    /**
     * 眼部Y方向缩放
     */
    val eyesScaleY: TransitionProperty = TransitionProperty(initialValue = 1F, targetValue = 1F),

    /**
     * 左眼旋转角度
     */
    val leftEyeRotate: TransitionProperty = TransitionProperty(),

    /**
     * 左眼水平位移
     */
    val leftEyeHorizontalTransition: TransitionProperty = TransitionProperty(),

    /**
     * 左眼垂直位移
     */
    val leftEyeVerticalTransition: TransitionProperty = TransitionProperty(),

    /**
     * 左眼X方向缩放
     */
    val leftEyeScaleX: TransitionProperty = TransitionProperty(initialValue = 1F, targetValue = 1F),

    /**
     * 左眼Y方向缩放
     */
    val leftEyeScaleY: TransitionProperty = TransitionProperty(initialValue = 1F, targetValue = 1F),

    /**
     * 右眼旋转角度
     */
    val rightEyeRotate: TransitionProperty = TransitionProperty(),

    /**
     * 右眼水平位移
     */
    val rightEyeHorizontalTransition: TransitionProperty = TransitionProperty(),

    /**
     * 右眼垂直位移
     */
    val rightEyeVerticalTransition: TransitionProperty = TransitionProperty(),

    /**
     * 右眼X方向缩放
     */
    val rightEyeScaleX: TransitionProperty = TransitionProperty(initialValue = 1F, targetValue = 1F),

    /**
     * 右眼Y方向缩放
     */
    val rightEyeScaleY: TransitionProperty = TransitionProperty(initialValue = 1F, targetValue = 1F),

    /**
     * 动作标识符
     */
    val actionSample: ActionSample = ActionSample(),

    /**
     * 动作标识符自身的旋转角度(center)
     */
    val actionSampleRotate: TransitionProperty = TransitionProperty(),

    /**
     * 动作标识符的旋转角度
     */
    val actionRotate: TransitionProperty = TransitionProperty(),

    /**
     * 动作标识符自身的缩放,不建议设置为可变值
     */
    val actionScale: TransitionProperty = TransitionProperty(initialValue = 1F, targetValue = 1F),

    /**
     * 动作画布X方向的缩放
     */
    val actionScaleX: TransitionProperty = TransitionProperty(initialValue = 1F, targetValue = 1F),

    /**
     * 动作画布Y方向的缩放
     */
    val actionScaleY: TransitionProperty = TransitionProperty(initialValue = 1F, targetValue = 1F),

    /**
     * 动作画布水平位移
     */
    val actionHorizontalTransition: TransitionProperty = TransitionProperty(),

    /**
     * 动作画布垂直位移
     */
    val actionVerticalTransition: TransitionProperty = TransitionProperty()
)

示例

使用抽象后的数据类,可以通过序列化string,完成动作的下发,从而远程控制形象的变化,如 Focus 的表情,可以通过配置如下字符串并在本地序列化完成形象演示。

{
	"leftEyeCornerRadius": {
		"targetValue": 50.0
	},
	"rightEyeCornerRadius": {
		"targetValue": 50.0
	},
	"leftEyeHorizontalRadius": {
		"targetValue": 50.0
	},
	"rightEyeHorizontalRadius": {
		"targetValue": 50.0
	},
	"leftTopEyeRadius": {
		"targetValue": 10.0
	},
	"leftBottomEyeRadius": {
		"targetValue": 50.0
	},
	"rightTopEyeRadius": {
		"targetValue": 10.0
	},
	"rightBottomEyeRadius": {
		"targetValue": 50.0
	},
	"eyesFillColor": "#FF0000",
	"eyesVerticalTransition": {
		"targetValue": 10.0,
		"duration": 600,
		"infinite": true,
		"repeatMode": "Reverse"
	},
	"leftEyeRotate": {
		"targetValue": 15.0,
		"duration": 300
	},
	"rightEyeRotate": {
		"targetValue": -15.0,
		"duration": 300
	},
	"actionSample": {
		"sample": "🫵"
	},
	"actionHorizontalTransition": {
		"targetValue": -80.0
	},
	"actionVerticalTransition": {
		"initialValue": 90.0,
		"targetValue": 100.0,
		"duration": 600,
		"infinite": true,
		"repeatMode": "Reverse"
	}
}

DrawEyes

绘制眼部

使用Compose原生Canvas,绘制左右眼。眼部整体和单个眼睛支持旋转、平移和缩放。

translate(left = eyesHorizontalTransition, top = eyesVerticalTransition) {
            rotate(
                degrees = eyesRotatedAngle,
                pivot = eyesRotate.centerPivotLevel.calculateRotateCenterPivot(center, size.width / 2)
            ) {
                scale(
                    scaleX = eyesScaleX,
                    scaleY = eyesScaleY,
                    pivot = eyesCenterPivot
                ) {
                    drawEye(
                        center = leftEyeCenter,
                        horizontalRadius = leftEyeHorizontalRadius,
                        upperEyelidRadius = leftUpperEyelidRadius,
                        lowerEyelidRadius = leftLowerEyelidRadius,
                        cornerRadius = leftEyeCornerRadius,
                        horizontalTranslation = leftEyeHorizontalTransition,
                        verticalTranslation = leftEyeVerticalTransition,
                        rotateAngle = leftEyeRotatedAngle,
                        scaleX = leftEyeScaleX,
                        scaleY = leftEyeScaleY,
                        fillColor = hexStringToColor(eyesFillColor)
                    )

                    drawEye(
                        center = rightEyeCenter,
                        horizontalRadius = rightEyeHorizontalRadius,
                        upperEyelidRadius = rightUpperEyelidRadius,
                        lowerEyelidRadius = rightLowerEyelidRadius,
                        cornerRadius = rightEyeCornerRadius,
                        horizontalTranslation = rightEyeHorizontalTransition,
                        verticalTranslation = rightEyeVerticalTransition,
                        rotateAngle = rightEyeRotatedAngle,
                        scaleX = rightEyeScaleX,
                        scaleY = rightEyeScaleY,
                        fillColor = hexStringToColor(eyesFillColor)
                    )
                }
            }
        }

DrawAction

绘制手部动作

简化开发,渲染标准unicode并通过位移、旋转和缩放展示动作。

    Canvas(
        modifier = modifier
    ) {
        val textStyle = TextStyle(
            fontSize = 40.sp * scale,
            color = Color.White,
            fontFamily = FontFamily.Default,
            fontWeight = FontWeight.ExtraBold,
        )

        val textLayoutResult = textMeasurer.measure(
            text = actionSample.sample,
            style = textStyle
        )

        translate(left = horizontalTransition, top = verticalTransition) {
            /**
             * action画布旋转角度
             */
            rotate(
                degrees = rotate,
                pivot = actionRotate.centerPivotLevel.calculateRotateCenterPivot(center, size.width / 2)
            ) {
                /**
                 * Sample自身旋转角度
                 */
                rotate(
                    degrees = sampleRotate,
                    pivot = center
                ) {
                    scale(scaleX, scaleY) {
                        drawText(
                            textLayoutResult = textLayoutResult,
                            topLeft = Offset(
                                center.x - (textLayoutResult.size.width) / 2,
                                center.y - (textLayoutResult.size.height) / 2
                            ),
                            drawStyle = Stroke(width = 3F)
                        )
                    }
                }
            }
        }
    }

预设表情

  1. 基础表情

    • Ordinary: 普通表情

    • Blink: 眨眼

    • Sadness: 伤心

    • Happiness: 开心

    • Angry: 生气

  2. 特殊表情

    • Music: 音乐表情

    • Rainy: 下雨表情

    • Sunny: 晴天表情

    • Cloudy: 多云表情

    • SparkLight: 闪光表情

    • Coffee: 咖啡表情

    • SunGlasses: 墨镜表情

    • TakePhoto: 拍照表情

    • Focus: 专注表情


Comment