研发交付

Kiki Agent 动画
客户端实现方案

首期统一实现工作中、创建 Flow、有未读消息和 Needs You 四个状态。默认首页使用轻量敲电脑动画,其他页面由同一个状态组件切换表现。

4 个上线状态状态驱动支持 Reduce Motion前后台自动暂停
工作中 · 正在整理手头的工作
00 · RELEASE SCOPE

首期需要上线的动画

四个状态都属于同一个 KikiPresence 组件。业务页面只传入状态,不直接控制图层或关键帧。工作中是首页默认状态。

状态优先级:Needs You > 未读消息 > Doing 0 / New Flow > 工作中。Needs You 清空后才展示未读;两者都没有且 Doing 为 0 时,首页切换为拿笔记板的等待状态。
BONUS · IDLE MOMENTS

首页长时间停留彩蛋

彩蛋不是业务状态,不显示状态文字,也不改变任务。仅在首页普通工作状态下低频出现,让 Kiki 更有生命感。

A

面向用户,拿着咖啡杯

沿用 New Flow 的双眼睑叠层与 4.2 秒时间轴,仅针对咖啡素材重新校准眼睛坐标。

idleMoment: .coffee
B

低头滑手机

同样使用双眼睑叠层和 4.2 秒眨眼时间轴;另外只让点击屏幕的手指做约 0.7pt 轻微动作。

idleMoment: .phone
眨眼实现→三种姿势共用 BlinkOverlay:左右眼睑独立叠层、周期 4.2 秒、延迟 1 秒;仅眼睛坐标按素材配置,禁止缩放整张人物图模拟眨眼。
关键帧→0–92% 睁眼 → 94–96% 闭眼 → 98–100% 睁眼;咖啡与手机严格沿用 New Flow 的同一时间轴。
开始条件→首页持续停留且当前为普通工作中;建议首次等待 45–90 秒后随机触发。
选择规则→咖啡与手机随机二选一,避免连续两次播放同一个;单次建议持续 8–12 秒。
立即打断→出现 Needs You、未读消息、用户点击输入框、离开首页或 App 进入后台时,立即回到对应业务状态。
无障碍→Reduce Motion 开启时不触发彩蛋;咖啡和手机可保留为静态姿势,但不主动切换。
完整优先级:Needs You > 未读消息 > 页面上下文(New Flow)/ Doing 0 > 工作中 > 随机彩蛋。彩蛋永远不能覆盖任何需要用户注意或表达真实工作状态的界面。
01 · MOTION

动画结构与参数

动画只负责表达“正在工作”,任务名称由独立的状态文案承载。不要把角色整体上下移动,也不要用高频、明显的敲击幅度。

01

双图层完全重合

底图保持静止;手部透明层使用与底图相同的像素尺寸、画布原点和缩放方式。

02

极轻的局部位移

0 → 0.9 → -0.3 → 0.6 → 0 pt
总时长 650ms,easeInOut,无限循环。

03

状态驱动,而非视频

由客户端状态机切换 idle / typing / noting / unread。动画本身不依赖模型 token 流。

Agent 开始执行→Doing 大于 0 时进入 typing,播放手部循环,并展示当前工作摘要。
Needs You→使用完整静态人物图,不拆分或裁切手部。沿用 4.2 秒眨眼时间轴,并约每 5.2 秒做一次不足 1px 的轻微点头。
Doing 为 0→首页进入 noting:拿着笔记板等待新工作,表现与 New Flow 房间一致。
同时有两种提醒→优先显示 needsYou;处理完成后,如仍有未读消息,再回落为 unread。
页面不可见→暂停动画,不重置角色状态;重新激活后继续。
02 · ASSETS

视觉素材交付

同一姿势的分层 PNG 必须同尺寸、同画布、同定位。客户端只需要以完全相同的 content mode 叠放。

完整 Kiki 底图

kiki_typing_base

工作中完整静态人物。建议透明背景,并提供 @2x / @3x 或 Android density 资源。

Transparent
hands only

kiki_typing_hands

仅保留需要移动的手部像素,其余区域透明;画布尺寸与底图完全一致。

Kiki 拿着笔记板

kiki_noting_base

创建 Flow 房间姿势。人物和笔记板保持静止,眼睑作为单独透明图层。

Kiki 坐在电脑前举手

kiki_needs_you

Needs You 专用姿势:保持坐姿和电脑场景,一只手举起;人物保持完整,不拆分手部图层;客户端仅叠加眼睑,并对完整人物做极轻的点头。橙色提示由客户端 UI 绘制。

Kiki 双手拿手机看向用户

kiki_unread_message

未读消息专用姿势:双手拿手机并看向用户;眨眼仍由独立眼睑图层完成。

Kiki 拿着咖啡杯

kiki_idle_coffee

首页咖啡彩蛋,透明背景;眨眼图层与 New Flow 采用相同频率。

Kiki 低头滑手机

kiki_idle_phone

首页手机彩蛋,透明背景;正式交付需再导出同画布的手指透明层。

当前资产注意:网页现用角色素材均为 1254 × 1254 透明 PNG,并已统一为蓝色中袖上衣。正式接入仍需从相同画布额外导出工作中手部与各姿势眼睑透明层;网页目前用 CSS 裁切完整图片,只适合作为动效验证。
03 · iOS

推荐使用 CAKeyframeAnimation

Core Animation 对持续循环、暂停恢复及功耗控制更直接。SwiftUI 页面中可以用 UIViewRepresentable 包装统一组件。

KikiTypingView.swift · 推荐
final class KikiTypingView: UIView {
    private let base = UIImageView(image: UIImage(named: "kiki_typing_base"))
    private let hands = UIImageView(image: UIImage(named: "kiki_typing_hands"))

    override init(frame: CGRect) {
        super.init(frame: frame)
        [base, hands].forEach {
            $0.contentMode = .scaleAspectFit
            $0.translatesAutoresizingMaskIntoConstraints = false
            addSubview($0)
            NSLayoutConstraint.activate([
                $0.leadingAnchor.constraint(equalTo: leadingAnchor),
                $0.trailingAnchor.constraint(equalTo: trailingAnchor),
                $0.topAnchor.constraint(equalTo: topAnchor),
                $0.bottomAnchor.constraint(equalTo: bottomAnchor)
            ])
        }
    }

    required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }

    func setTyping(_ typing: Bool) {
        hands.layer.removeAnimation(forKey: "kiki.typing")
        guard typing, !UIAccessibility.isReduceMotionEnabled else { return }

        let animation = CAKeyframeAnimation(keyPath: "transform.translation.y")
        animation.values = [0, 0.9, -0.3, 0.6, 0]
        animation.keyTimes = [0, 0.25, 0.5, 0.75, 1]
        animation.duration = 0.65
        animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
        animation.repeatCount = .infinity
        animation.isRemovedOnCompletion = false
        hands.layer.add(animation, forKey: "kiki.typing")
    }
}
KikiTypingView.swift · iOS 17+
struct KikiTypingView: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        ZStack {
            Image("kiki_typing_base").resizable().scaledToFit()
            Image("kiki_typing_hands")
                .resizable().scaledToFit()
                .keyframeAnimator(initialValue: 0.0, repeating: !reduceMotion) { image, y in
                    image.offset(y: y)
                } keyframes: { _ in
                    KeyframeTrack {
                        LinearKeyframe(0.9, duration: 0.1625)
                        LinearKeyframe(-0.3, duration: 0.1625)
                        LinearKeyframe(0.6, duration: 0.1625)
                        LinearKeyframe(0, duration: 0.1625)
                    }
                }
        }
    }
}
04 · ANDROID

推荐使用 Jetpack Compose

Android 同样使用两张同尺寸图片叠放,只对手部层做 Y 轴关键帧。推荐 rememberInfiniteTransition;传统 View 项目可使用 ObjectAnimator。

KikiTyping.kt · 推荐
@Composable
fun KikiTyping(
    isTyping: Boolean,
    modifier: Modifier = Modifier
) {
    val transition = rememberInfiniteTransition(label = "kikiTyping")
    val offsetY by transition.animateFloat(
        initialValue = 0f,
        targetValue = 0f,
        animationSpec = infiniteRepeatable(
            animation = keyframes {
                durationMillis = 650
                0f at 0 using FastOutSlowInEasing
                0.9f at 162
                -0.3f at 325
                0.6f at 487
                0f at 650
            }
        ), label = "handsOffset"
    )

    Box(modifier) {
        Image(painterResource(R.drawable.kiki_typing_base), null,
            modifier = Modifier.matchParentSize(), contentScale = ContentScale.Fit)
        Image(painterResource(R.drawable.kiki_typing_hands), null,
            modifier = Modifier.matchParentSize().graphicsLayer {
                // Compose follows the system animator duration scale.
                translationY = if (isTyping) offsetY * density else 0f
            }, contentScale = ContentScale.Fit)
    }
}
KikiTypingView.kt · View 系统
val animator = ObjectAnimator.ofFloat(
    handsImageView,
    View.TRANSLATION_Y,
    0f, dp(0.9f), dp(-0.3f), dp(0.6f), 0f
).apply {
    duration = 650L
    interpolator = AccelerateDecelerateInterpolator()
    repeatCount = ValueAnimator.INFINITE
    repeatMode = ValueAnimator.RESTART
}

// onStart / onResume
if (ValueAnimator.areAnimatorsEnabled()) animator.start()

// onStop / onPause
animator.cancel()
handsImageView.translationY = 0f
Android 单位:动作参数以视觉 pt 为基准,Android 实现换算为 dp,并在真机上微调。不要直接把位移理解为物理像素,否则不同密度设备上幅度会不一致。
05 · BEHAVIOR

状态机制、生命周期与无障碍

两个平台行为保持一致。业务层负责解析状态,动画组件只负责渲染结果与处理页面可见性。

首页状态解析器

func resolveHomePresence(
  needsYouCount: Int,
  unreadCount: Int,
  doingCount: Int
) -> KikiPresenceState {
  if needsYouCount > 0 {
    return .needsYou(needsYouCount)
  }
  if unreadCount > 0 {
    return .unread(unreadCount)
  }
  if doingCount == 0 {
    return .noting
  }
  return .typing
}

KikiPresence(
  state: resolveHomePresence(...),
  isPaused: scenePhase != .active
)
  • 输入来源:三个 count 均由业务层提供,动画组件不读取列表数据
  • 互斥输出:解析器每次只返回一个视觉状态,不叠加角色素材
  • 回退:高优先级状态归零后,立即重新计算并回到下一个有效状态

页面与状态边界

1
首页严格使用左侧解析器;Doing = 0 时复用 New Flow 的 noting 动画。
2
New Flow 房间页面上下文固定为 noting,不读取首页 Doing 数量。
3
首页彩蛋只能临时替换 typing;任何 count 变化都要先结束彩蛋再重新解析。
needsYouCount > 0→needsYou · 电脑前静态举手,仅眨眼并轻微点头;忽略 unreadCount 和 doingCount。
unreadCount > 0→unread · 双手拿手机、抬头看用户并眨眼;仅在 needsYouCount = 0 时生效。
doingCount = 0→noting · 拿笔记板眨眼;仅在前两个 count 都为 0 时生效。
doingCount > 0→typing · 首页默认工作动画;此时才允许进入随机彩蛋。
开发注意:Doing 从 1 变成 0 时,不进入独立的“空状态动画”,而是直接复用 .noting 及其素材、眨眼参数和 Reduce Motion 规则。Doing 从 0 变成 1 时切回 .typing。
06 · QA

联合验收清单

设计负责动作观感与素材准确性,客户端开发负责组件封装、生命周期、性能和无障碍。

✓
人物主体完全稳定头、身体、电脑和背景不随敲击上下抖动。
✓
手部没有重影和白边透明层与底图像素级对齐,缩放模式一致。
✓
动作足够轻能感知正在输入,但不会变成持续吸引注意力的装饰动画。
✓
暂停行为正确切后台、离开页面、Reduce Motion 开启后停止;回到前台不叠加多个循环。
✓
多尺寸设备无错位至少验证小屏手机、标准手机和大屏设备;Android 覆盖两种不同 density。