用 Expo、Reanimated 和 Skia 让 AI 在移动应用里显得像人
看看 Most Creative Expo App Awards 得主 Callie 如何运用 Expo、Reanimated 和 Skia shader,打造温暖、情绪安全的移动端体验。
中文
复制

本文是 Expo App Award 获奖者、资深 React Native 工程师 Daehyeon Mun 的客座文章。他在 React Native 社区以注重设计的工程风格和富有表现力的界面作品闻名,他打造的产品总让人感叹:“原来 React Native 还能做到这个。”
...
Callie 是一款有情绪感知能力的自我关怀应用,为正在从进食障碍中康复的人提供支持。
这款应用想营造的是一种温暖、平静又迷人的体验,打开的那一刻就让人感到被接纳。借助 Expo、React Native Reanimated 和 React Native Skia,Callie 呈现出细腻的视觉效果和富有表现力的交互,帮助用户在日常作息中获得舒适与稳定感。
整个产品从零开始构建,达到了完全原生品质的移动端体验。从界面和交互语言,到动效设计和基于 shader 的视觉效果,每一处细节都围绕进食障碍康复者的特殊需求来打磨。

Callie 被评为 2025 年 Expo App Awards 最具创意应用,获奖理由是它深思熟虑的设计和独特的交互模式。目前它在 App Store 上拥有超过 3.2k+ 条评价,平均评分 4.9+,用户参与度和口碑都相当出色。
一个人撑起 Callie 的移动端体验
我以创始工程师的身份加入 Nabi,全面负责移动应用的开发。整个移动端体验都由我一手打造——从细微的交互和动画,到基于 shader 的视觉效果,再到应用整体的手感。
打磨出一款精致的产品从来不是偶然。它需要你对细节敏感,愿意尝试不同的想法,并经历无数次微调。这种程度的投入需要大量时间和专注,而在早期创业公司里,最难的就是挤出这样的空间。
这篇文章里,我会讲讲自己是如何构建 Callie 这套细腻、有情绪感知的移动端体验的——以及作为早期创业公司里唯一的移动端开发者,我如何在保持快速迭代的同时,依然交付高质量的产品。
在设计开发中顾及饮食失调患者的敏感感受
设计 Callie 时,我们必须仔细理解饮食失调康复期人群的特点与敏感之处。这个群体中很多人情绪格外脆弱,哪怕是很小的视觉变化或交互方式,都可能决定这款应用让人感到被支持还是不堪承受。因此,整体体验必须避免突兀的转场和不必要的刺激,始终保持平稳、可预期的节奏。
在设计层面,我们优先采用平静的配色、柔和的过渡,以及绝不让人感到被要求、被侵入的交互方式。在技术层面,仅靠静态界面不足以传达我们想要的细腻程度和平静感。我们需要更精细的动效和更具表现力的视觉元素,才能呈现出这种体验所需的微妙情绪质感。
用自定义 shader 实现富有表现力的视觉效果

我们想在 Callie 中实现的那种体验,无法仅靠静态界面表达。由于这款应用的使用者是饮食失调康复期人群,界面中的许多部分都需要在心理上让人感到舒适——柔和、平稳,没有任何突兀的视觉变化。React Native Skia Shaders 因此成为实现这种细腻效果的自然选择。
在探索如何把这种微妙的情绪质感带进应用的过程中,我也试过 Lottie 和 Rive 等其他方案。这两个工具都很适合预先设计好的动效图形,但面对需要实时响应用户输入、逐帧贴合心理细微变化的交互,它们并不合适。
对 Callie 来说,我需要像素级的控制,去塑造每一个动作在每一刻的感受。这要求根据用户交互对内容进行形变,而不只是播放一段动画。Skia shader 提供了我需要的表现力和灵活性,能够实现有物理依据的运动,以及柔软、有机的过渡——这些用传统动画工具是不可能做到的。
Callie 里有一项功能用到了自定义 shader,就是 “Callie 关于你的手记”。它会分析用户与 AI 的互动记录,每天生成一份总结,以信件的形式给出鼓励和正向的话。做 AI 功能,最要紧的一点是别让体验显得冷冰冰、像机器吐出来的,而要尽量带点人味。
为了让这份手记更像私人信件、更让人愿意打开,我希望这个界面看起来不像一大段 AI 生成的文字,而更像一封专门写给用户的、用心写下的信。于是我用 shader 加了一个“翻页”效果。
这点视觉处理给一个 AI 生成的功能添了些许但实在的人情味,让整个体验更暖、更有心,也更完整。
翻页效果怎么实现
/**
* Page Curl Shader Effect
*
* Simulates a realistic page curl by modeling the page as a cylinder.
* The shader divides the screen into three regions:
* 1. Beyond the curl (transparent/hidden)
* 2. The curl surface (cylindrical mapping with shadow)
* 3. Before the curl (normal flat page)
*
* Algorithm:
* - Find the "curl line" perpendicular to curl direction
* - Calculate each pixel's signed distance from the curl line
* - Map pixels onto a cylinder surface based on distance
* - Apply shadow gradient on the curled region
*
* Uniforms:
* u_image - Source texture to be curled
* u_resolution - Canvas resolution (width, height) in pixels
* u_originPos - Fixed corner where curl originates (typically bottom-right)
* u_currPos - Animated curl tip position
*/
uniform shader u_image;
uniform vec2 u_resolution;
uniform vec2 u_originPos;
uniform vec2 u_currPos;
const float PI = 3.14159265359;
const float RADIUS = 0.1; // Cylinder radius in normalized space
const float SHADOW_EXPONENT = 0.2; // Controls shadow gradient falloff
vec4 main(vec2 fragCoord) {
// 1. Setup coordinate systems
float aspect = u_resolution.x / u_resolution.y;
vec2 aspectScale = vec2(aspect, 1.0); // Scale to square aspect ratio
vec2 uvScale = vec2(u_resolution.x / aspect, u_resolution.y); // For texture sampling
vec2 uv = fragCoord * aspectScale / u_resolution; // Normalized pixel coord [0,1]
// 2. Define curl geometry
vec2 curlTip = u_currPos * aspectScale / u_resolution; // Current curl tip position
vec2 curlDir = normalize(abs(u_originPos) - u_currPos); // Direction from origin to tip
// Find where the curl line intersects the left edge (y-axis)
// This is the "hinge" point where the curl begins
vec2 origin = clamp(
curlTip - curlDir * curlTip.x / curlDir.x,
0.0,
1.0
);
// 3. Calculate curl line length
// Base length from origin to tip, plus adjustment for canvas edge
float curlLength = clamp(
length(curlTip - origin) +
(aspect - (abs(u_originPos.x) / u_resolution.x) * aspect) / curlDir.x,
0.0,
aspect / curlDir.x
);
// Special case: leftward curls don't need edge adjustment
if (curlDir.x < 0.0) {
curlLength = distance(curlTip, origin);
}
// 4. Calculate pixel distance from curl line
// Project pixel onto curl direction, subtract curl length
// Positive = beyond curl, Negative = before curl, Near-zero = on curl surface
float dist = dot(uv - origin, curlDir) - curlLength;
// Find closest point on the curl line to current pixel
vec2 linePoint = uv - dist * curlDir;
// 5. Render based on distance from curl line
// REGION 1: Beyond the curl cylinder → fully transparent
if (dist > RADIUS) {
return vec4(0.0, 0.0, 0.0, 0.0);
}
// REGION 2: On the curl surface [0, RADIUS]
// Map flat coordinates onto cylinder using arc length
else if (dist >= 0.0) {
// Angle around the cylinder (from front to back)
float theta = asin(dist / RADIUS);
// Two possible texture coordinates: back side and front side of cylinder
vec2 backPoint = linePoint + curlDir * (PI - theta) * RADIUS;
vec2 frontPoint = linePoint + curlDir * theta * RADIUS;
// Use back side if it's within bounds, otherwise show front
bool isInBounds =
backPoint.x <= aspect && backPoint.y <= 1.0 &&
backPoint.x > 0.0 && backPoint.y > 0.0;
uv = isInBounds ? backPoint : frontPoint;
// Sample texture at the mapped position
vec4 fragColor = u_image.eval(uv * uvScale);
// Apply shadow gradient: darker near the curl edge (dist=RADIUS), lighter at center (dist=0)
float shadowFactor = pow(
clamp((RADIUS - dist) / RADIUS, 0.0, 1.0),
SHADOW_EXPONENT
);
fragColor.rgb *= shadowFactor;
return fragColor;
}
// REGION 3: Before the curl (dist < 0)
// Show flat page, but account for paper "consumed" by the cylinder
else {
// Offset by the cylinder's circumference (PI * RADIUS)
vec2 mappedPoint = linePoint + curlDir * (abs(dist) + PI * RADIUS);
// Use mapped point if valid, otherwise use original UV
bool isInBounds =
mappedPoint.x <= aspect && mappedPoint.y <= 1.0 &&
mappedPoint.x > 0.0 && mappedPoint.y > 0.0;
uv = isInBounds ? mappedPoint : uv;
return u_image.eval(uv * uvScale);
}
}
下面说的是 “Callie 关于你的手记” 界面里用到的翻页 shader。
这个 shader 接收一张图片作为输入,再用一个 progress 值做出纸张缓缓展开的视觉效果。它没有简单地缩放或淡入淡出,而是靠几何变换让页面看起来真的在卷起、摊开。
要做出自然的翻页效果,需要把三角函数、坐标空间扭曲和基于几何的形变结合起来。这些计算衔接得足够顺,动画才会有逼真的纸张质感。
如果你想更深入地了解这个效果背后的数学原理,强烈推荐 William Candillon 的这篇教程:
让 AI 更像人
AI 体验里最关键的一环,是拿捏生成回复的语气。即便模型给出的内容准确,某些措辞仍可能显得过于直接、机械,或者情绪上失衡。为此我们加了一层语气调整逻辑,在每条 AI 消息送到用户面前之前,先调整它的结构、温度和节奏。
这包括过滤掉可能被理解为评判性的表达,确保语言始终是非指令性的、支持性的,并引导 AI 保持一贯温和的叙事风格。这些调整让体验更像一位体贴的伙伴,而不是一个通用的 AI 助手,也加强了用户与 Callie 之间的情感连接。
如何打造高质量的 Expo 应用
技巧 1:使用 worklet 相关库,获得原生手感
要在 React Native 中实现原生级别的 UI 性能,最重要的一点是选择运行在 worklet 和 UI 线程上的库。当动画或触摸交互依赖 JavaScript 线程时,它们会与其他逻辑争抢资源,很容易导致掉帧、输入延迟和性能瓶颈。
对于 Callie 这样的应用——细腻的交互是体验的核心——这些看似微小的性能问题会很快破坏产品的整体手感。
我强烈推荐的一个库是 Mateusz Łopaciński 开发的 react-native-sortables。
它基于 react-native-reanimated 和 react-native-gesture-handler 构建,拖放交互直接运行在 UI 线程上,流畅且响应迅速,同时提供了灵活的定制选项。
在 Callie 中,我们用这个库实现拖放式待办清单,无需从零编写复杂的手势逻辑,就能交付稳定、高质量的 UX。
技巧 2:学习令人愉悦的动画
想要提升细腻、令人愉悦的动画水平,最好的方法很简单:
把你想象中的交互亲手做出来。
为了持续打磨技能,我曾经每周至少实现一个新的 UI 或交互。没有什么比动手实践更能加速成长,每一次尝试都会拓展你对运动、节奏和视觉细节的直觉。
以下是我强烈推荐的学习资源:
他们的教程很适合用来学习核心概念和模式。打好这些基础之后,不妨用同样的思路动手做自己的动画。久而久之,这些练习会自然而然地积累信心,也让你能实现的交互类型越来越丰富。
把时间留给用户
加入 Callie 团队的第一周,我做出的最重要的技术决策之一,就是把现有的 React Native CLI 项目迁移到 Expo。之前维护 RN CLI 项目的经历让我很清楚:原生配置冲突、构建不稳定、基础设施维护问题有多常见,又会吃掉多少工程时间,而这些投入对用户体验毫无贡献。
在一个小团队里,我是唯一负责整个 App 的工程师,所以快速且可靠的执行速度是最高优先级。要做出高质量的体验,就得在大量迭代中反复微调,我的时间必须花在用户真正能感知到的改进上。Expo 的托管工作流省掉了移动开发中通常绕不开的那些开销,让早期开发阶段快了不少。
Expo 持续原生生成(CNG)
Expo CNG大幅缩短了以往花在管理原生配置和平台特定维护上的时间。因此,我能把大部分工程时间投入到面向用户的改进、功能开发、UX 优化和交互打磨上。
当整个 App 只由一名工程师开发时,时间利用效率直接决定产品的生死。采用 CNG 之后,我可以把精力更多地放在能为用户创造直接价值的开发上,而不是基础设施维护。
Expo 的构建服务
Expo 的构建服务进一步简化了我们的发布流程。一条命令就能生成稳定的 iOS 和 Android 构建,并发布到各自的商店。构建服务提供了一套可靠、一致、也省时得多的部署流程。
战略选择:用 OTA 更新快速迭代
每加一个新功能都要等上几天 App Store 审核,会拖慢产品增长,在需要快速试错的早期阶段尤其如此。为了绕开这个瓶颈,我们上架的首个版本就把近期会用到的库全部打包进去,之后的新功能和改进都通过 Expo 的 OTA 更新 交付,没有任何发布延迟。
用户基数还小的时候,这套做法让我们能迅速吸收用户反馈,产品每周都有肉眼可见的变化。这个短反馈回路对 Callie 的早期开发至关重要,也让它逐渐长成用户真正需要的样子。
早早用上 Expo 的基础设施,我们省掉了大量工程开销,把这些时间直接投回到用户能感知的改进上。结果是,我们在很短的时间里做出了一个考虑周全、质量过硬的产品。换成传统的 React Native 方案,这要难得多。
给小团队的建议
希望这篇文章能帮到那些想更快做出精致产品的小团队和独立开发者,就像 Callie 那样。在小团队里,最重要的是留出足够的空间去试验、迭代,打磨那些决定产品手感的细节。
Expo 让我们做到了这一点。它减少了通常拖慢移动开发的那些开销,让我们能把时间花在真正重要的地方——体验本身。
如果你带着一个小团队,想在不牺牲质量的前提下快速推进,Expo 是我可以放心推荐的平台。