
这个项目听起来可能有点奇怪桌宠不是电脑时代的怀旧玩意吗为什么要做手机版还要接上codex但很多人不知道手机端的桌宠一旦拥有了真实回答问题的能力它就从一个“装饰品”变成了“效率工具”。我自己前后折腾了三个多月踩了不少坑最后做出来的东西让我自己意外地满意——它能解释代码、能帮你改一段文案、能随手记东西还能在你写了一半的问题没来得及问完时主动把上下文接住。这篇文章聊聊我是怎么从零把它搭出来的以及那些文档里没人告诉你的细节。1. 这个项目想解决什么问题——桌宠不该只会卖萌1.1 从一个小场景说起有天我在地铁上手机收到一段报错日志看起来像是JSON解析时的字段名拼错了。我当时手里没有电脑只能先截图但脑子跟着转个不停这段逻辑如果加个空值判断会不会更好这个正则表达式到底匹配不匹配手机上有浏览器、有搜索框、有各种笔记工具但它们之间是割裂的没有一个入口能让我“随手一指”就去回答问题。也是那一刻我突然意识到桌宠这个形态很有意思——它不是聊天软件那样的独立窗口它是永远悬浮在屏幕上的存在你看得到它点击它它就在那里。如果这个桌宠背后接的是codex这样的智能服务它就同时具备“陪伴感”和“干活能力”。这个痛点其实非常普遍我们大多数时候并不需要打开一个复杂的客户端而是希望屏幕上有一个轻量的东西随时能问、能答、能记。手机桌宠app的定位就是这个不在“宠物装扮”上较劲而在“随叫随到的能力”上做文章。1.2 好桌宠的标配陪伴感加干活能力早年电脑上的桌面宠物比如那只会在屏幕角落睡觉的猫已经把“陪伴感”做到了极致。它最大的问题是不能干活时间一长用户就腻了。我做这个codex桌宠app时给自己定了一个原则桌宠的外观动画只是外壳真正留住用户的是它背后接入的能力。目前我把能力收敛成四件事都能通过浮动小球上的交互直接触发代码问答贴一段代码或者说一句问题桌宠调用codex分析逻辑并返回解释。文案生成与改写说白了就是随身带一个擅长各种文风的助手写个朋友圈文案、改个周报几秒钟能出内容。随手速记很多零碎想法其实发生在锁屏前的那几秒有一个常驻桌宠轻点一下就能语音转文字存档。提醒与待办提取跟桌宠说“明天上午提醒我带身份证”它会解析出时间、事项并生成提醒。你可能觉得这些能力跟普通语音助手差不多但区别在于普通语音助手是“指令式的”而codex接入之后可以处理开放式的、需要推理的内容。比如“这段SQL为什么这么慢帮我看看索引该怎么建”这种问题需要上下文理解不是简单定时器能做出来的。1.3 这个项目适合谁我觉得这个项目适合三类人第一移动端开发者想练跨平台技术和原生插件通信第二桌宠爱好者总觉得现在的桌宠只有外表没有灵魂第三对AI应用落地感兴趣的产品型开发者想看看把大模型能力的接口接到一个非常轻量、低打扰的UI形态里会有什么化学反应。如果你是一个完全没做过Android或iOS开发的小白直接照抄这篇可能有点吃力因为悬浮窗、前台服务这些概念需要一些系统知识。但如果你愿意边查边做这个项目难度也没有想象中那么高最难的地方不是代码本身而是“什么时候该让AI回答什么时候不该让AI回答”的产品分寸感。2. 手机桌宠的技术路线为什么我要跨平台又为什么离不开原生2.1 桌宠的三个硬性要求在动手写代码之前技术选型必须先确定下来。一个真正意义上的手机桌宠必须满足三个硬性要求第一系统级悬浮窗。桌宠不是App内的一个小小动画它必须浮动在所有应用界面之上用户正在刷信息流、看视频、打字的时候它依然待在那里。这意味着Android上要用WindowManageriOS上则没有直接对等的方案后面我会细说。第二长时间驻留。桌宠不能像普通应用一样切到后台就被系统回收它需要前台服务或者类似的保活机制否则用户切个应用回来宠物就不见了信任感一下就崩掉。第三全局交互。桌宠要能响应拖拽、点击、长按有时候还要监听用户的复制操作这些事件的捕获边界跟普通App内的手势完全是两回事。这三个硬性要求直接决定了跨平台框架的边界框架负责的是UI和动画而系统级能力必须交由原生代码完成。2.2 为什么不用框架里现成的悬浮窗方案我一开始用的跨平台方案是Flutter理由是它的动画引擎和自绘能力很适合做桌宠这种需要大量不规则动画的界面。Flutter自身确实可以创建“悬浮窗”比如Overlay和OverlayEntry可以在App内部实现画中画、半屏弹窗等效果。但这里面有个关键限制Overlay的生命周期跟随Activity或ViewController一旦用户切到桌面或者别的应用这个Overlay就没了。桌宠要的是“我切走了它也得在”所以这条路直接排除。正确做法是双轨制业务逻辑和动画全部用跨平台框架写但系统级悬浮窗交原生插件来创建。具体到Android上通过MethodChannel让Dart侧与原生Kotlin/Java通信原生侧负责申请权限、创建WindowManager窗口、注入一个承载Flutter视图的面板iOS上因为无法做真正的全局悬浮窗我做的妥协方案是App内悬浮球加桌面小组件至少让人在主屏幕和App内部都能快速触达后面我还会讲这个妥协的思路。这种“跨平台做界面、原生做壳”的组合很多做过视频SDK、直播类App的人应该很熟它最大的好处是代码复用率高同时不牺牲系统能力。2.3 codex接口怎么接轮询、SSE还是WebSocket确定了壳子接下来是核心问题桌宠怎么跟codex通信。codex本身提供的接口能力很丰富但手机端最常见的是流式对话也就是用户输入一句话服务端不断的吐字回来像真实的人在打字一样。这个场景在协议选型上主要有三种方案方案优点缺点我的判断REST轮询实现最简单发送请求后隔一段时间拉取一次结果延迟高前端体验像老式短信轰炸频繁请求也费电不推荐作为主链路WebSocket双向实时通信服务端可主动推送连接管理复杂断线重连、心跳、消息顺序都要自己处理且对话场景并不需要上行实时流可用但杀鸡用牛刀SSEServer-Sent Events基于普通HTTP的长连接服务端单向持续推送数据天然适合流式文字返回单向通信客户端无法通过同一个连接发送多条消息最终选择我最后选了SSE。原因是对话场景本质上是“客户端问一句服务端流式答一段”数据方向是单向的。SSE可以在同一个HTTP连接里源源不断收到事件而且解析起来非常直观——服务端每隔一段数据就发一行data:开头的内容客户端只需要按行读取即可。相比WebSocketSSE不需要维护一个长期的双向通道也不容易遇到代理层对连接的不友好处理。实际用下来SSE的断线重连也更好做因为HTTP本身具备天然的语义状态码、超时时间、响应头都能用来判断连接状态。还有一个隐藏细节codex这类服务在返回过程中可能会在流式输出里夹带特殊事件比如“调用某个工具”的信号。SSE能把普通文本和结构化事件混在一起传输客户端按类型分流即可。这一点对流式渲染至关重要。3. 模块怎么拆悬浮窗、动画状态机、对话链路3.1 整体架构的五个模块我把整个app拆成五个模块每个模块各司其职避免一个God Object把逻辑全塞进去模块职责关键依赖悬浮窗管理模块创建、销毁、移动系统级悬浮窗处理权限生命周期原生WindowManager MethodChannel前台服务模块维持进程优先级在后台继续接收消息Android前台服务通知动画状态机模块控制桌宠的待机、思考、说话、拖动状态Flutter动画控制器 自绘Canvas对话链路模块组装请求、解析SSE、增量渲染、管理上下文codex SSEService 内存滑动窗口本地存储模块保存设置、对话摘要、速记内容SharedPreferences / 轻量数据库模块化设计看起来是会多写一些接口但桌宠这种app有个特点所有功能都围绕一个很小的主界面展开一旦逻辑纠缠在一起后面每加一个能力都要重写一堆代码。我现在把悬浮窗的创建和销毁彻底隔离动画状态机只负责表现不直接感知网络状态整个项目改起来轻松很多。3.2 悬浮窗的生命周期管理悬浮窗听起来很酷但实际上是个非常脆弱的资源。Android系统对悬浮窗的管控一直很严格从Android 6.0开始悬浮窗权限必须用户手动授权到Android 12以后系统对悬浮窗的可见区域、手势冲突都有额外限制。我写的原生代码大概长这样// Kotlin侧的悬浮窗创建核心逻辑 val params WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, PixelFormat.TRANSLUCENT ) params.gravity Gravity.TOP or Gravity.START params.x 24 params.y 260 windowManager.addView(floatingView, params)这段代码里有两个容易被忽略的点。第一是TYPE_APPLICATION_OVERLAY在Android 8.0之后系统强制要求悬浮窗必须使用这个类型否则直接抛异常第二是FLAG_NOT_FOCUSABLE如果不加这个标志悬浮窗会抢占焦点用户点击其他应用时键盘不会正常弹出这是桌宠最容易被骂的细节之一。创建只是第一步真正麻烦的是权限被回收后的处理。很多国产系统在用户重启应用、清理后台之后会重置悬浮窗权限App如果发现权限丢失但没有引导用户重新授权桌宠就会“消失”得莫名其妙。我的处理方案是App启动时先检查权限没有权限就弹出一个自定义引导页不能只丢一句“请在设置中开启悬浮窗权限”——那等于把用户扔在迷宫里。引导页上直接展示系统的授权页面链接用户点一下就能跳转。3.3 动画状态机设计桌宠看起来灵动靠的不是复杂美术资源而是一个严格的状态机。我的桌宠只有五个状态idle待机、thinking思考、speaking说话、dragging拖拽、sleep省电休眠。每个状态对应一套动画状态之间用事件驱动切换而不是在代码里到处setState。状态转换的核心规则如下当前状态触发事件下一个状态idle用户点击桌宠thinkingthinkingcodex返回第一段内容speakingspeaking本次流式输出结束idleidle用户按住桌宠并移动draggingdragging用户松手idleidle / speaking连续一段时间无交互sleepsleep用户点击桌宠idle可能有人会觉得做个动画五六个状态就够了为什么要搞这么正式我踩过坑之后才明白没有状态机动画之间会出现“打架”比如用户刚拖到一半codex的回复回来了桌宠一边显示“说话”状态一边被拖着走看起来就像人格分裂。状态机把每一帧是谁说了算界定清楚动画冲突基本消除。具体实现上Flutter里我用了AnimationController配合自定义Curve但状态转换逻辑完全是普通枚举加事件分发没有引入重量级库。3.4 对话链路从输入到codex流式回复对话链路是整个app里最有技术含量的部分。用户打开桌宠的对话面板输入一句话点击发送后整个流程分为四步第一步请求组装。我会把系统指令、用户输入、历史摘要一起拼进请求系统指令里固定了一段角色设定告诉codex它是一个住在手机里的桌宠助手回答要简洁、口语化不要啰嗦。第二步发起SSE请求。Dart侧我用http包的StreamedRequest发起请求并将响应体交给一个专门的解析器。解析器按行读取遇到以data:开头的内容就尝试解析成JSON如果是普通的增量文本就追加到当前回复如果是特定的事件标识就触发其他动作比如保存速记、设置提醒。第三步增量渲染。这是流式对话体验的关键。很多人以为需要等codex全部返回完再渲染但流式接口的数据是边生成边传的客户端把每段delta即时追加到UI上桌宠的“说话”动画会配合新内容的到来做嘴唇或表情变化体验立刻生动起来。我的做法是用一个ValueNotifierString持有当前回复内容UI层监听变化并滚动到底部这样不会因为频繁setState导致整棵widget树重建。第四步意图路由。同样是发送一句话codex需要根据内容决定是“回答问题”还是“提取待办”。我在请求里要求模型输出一个袖珍的意图标签比如chat、todo、note客户端据此走不同分支。这一步很关键不然用户说“帮我记一下买菜”桌面宠物却回上一大段关于如何买菜的演讲稿那就尴尬了。代码里SSE解析的部分大概是这样的思路late final StreamSubscriptionString _subscription; void _startSseStream(String prompt) { final request http.Request(POST, Uri.parse(https://xxx/codex-chat)); request.headers[Content-Type] application/json; request.headers[Accept] text/event-stream; request.body jsonEncode({prompt: prompt, sessionId: _sessionId}); http.Client().send(request).then((response) async { await for (final line in response.stream.transform(utf8.decoder).transform(const LineSplitter())) { if (!line.startsWith(data:)) continue; final data line.substring(5).trim(); if (data.isEmpty || data [DONE]) continue; final decoded jsonDecode(data); _onDelta(decoded[delta] as String? ?? ); } }); }这个流程写起来不复杂但真正跑通之后使用体验非常的“电视机新闻字幕”化——APP前面一个桌宠屏幕上一行一行字往外蹦配合桌宠的表情变化很有生命力。3.5 上下文和短记忆codex的对话不是无状态的它需要看到历史上下文才能理解“刚才说的它”指代什么。但手机端资源有限也不能无限堆积历史。我采用了“滑动窗口加摘要压缩”的双层方案内存中默认保留最近10轮的完整对话。如果用户继续往下聊在超出第10轮时我会把最早的一批对话交给一个轻量级的摘要接口做压缩替换成两三句摘要再接上新的对话。这样做有两个好处一是token消耗被限制住了二是桌宠对同一件事的记忆不会因为对话太长而断层。还有一个细节不同场景的上下文要隔离。比如用户刚刚在跟桌宠聊代码突然问“明天天气怎么样”不能让代码上下文干扰天气问题的回答。我的实现是在请求里额外加一个scene字段当检测到明显的场景切换时对话链路会自动重置滑动窗口。这个切换判断不靠复杂NLP只靠规则出现“换个话题”“算了”“接下来说”这类词就直接清空窗口。4. 踩过的坑权限、发热、断线、token预算4.1 悬浮窗权限在不同系统上的实际情况先说结论Android的悬浮窗权限是碎片化最严重的一块。原生Android用Settings.canDrawOverlays()判断权限跳转授权页用Settings.ACTION_MANAGE_OVERLAY_PERMISSION但在某安卓定制系统上这个接口可能返回与实际不符的结果——明明已经授权了canDrawOverlays()仍然返回false或者反过来。如果你的应用只在主流设备上测试很难覆盖这些差异。我的建议是把权限检测做成多层验证除了系统API返回的状态再实际尝试addView一次能加就视为有权限抛异常就认为没有权限。这种“实测法”比单纯依赖系统API可靠得多。iOS这边则直接卡死除非越狱否则你无法在其他App之上绘制一个可交互的全局悬浮层。iOS 13之后有个UIWindow的窗口层级概念但普通App创建的Window永远无法超越系统级窗口。所以我最后的方案是iOS端桌宠降级为“App内悬浮球 桌面小组件”。虽然不能全局悬浮但至少用户在主屏幕能看见它在App内也能随时摸到它。产品方案上我会在设置页明确告知iOS用户这个差异避免用户下载后发现“不是真的全局悬浮”觉得被骗了。4.2 电池与发热常驻动画的代价桌宠是一个“默认就在屏幕上”的长驻应用功耗问题比普通聊天应用更尖锐。第一版我踩过一个真实的坑桌宠待机时每秒都跑着60帧呼吸动画屏幕上也挂着悬浮窗结果用户反馈手机半小时后发热明显耗电排行稳居第一。这个问题不是代码报错而是技术指标没有一开始就立好。我后来的处理方式是分层的省电策略场景策略用户正在使用其他应用前台可见动画降到30帧背景色透明度降低悬浮窗在前台但超过2分钟无操作进入sleep状态动画只保留缓慢呼吸帧率降到15锁屏或App切到后台暂停所有动画只保留前台服务和通知持续无交互超过10分钟悬浮窗半透明化颜色变灰但可点击唤醒这样调整之后一晚上8小时只有不到3%的电量消耗基本可以被接受。要让用户愿意把桌宠留在屏幕上功耗控制不是优化项而是生死项。4.3 动画掉帧和内存泄漏Flutter做动画很顺手但悬浮窗这个场景有个特殊问题悬浮窗本身是一个独立的小尺寸视图如果直接加载大图或执行复杂模糊GPU和CPU的消耗都会放大。我的桌宠形象走的是矢量绘制路线就是直接用CustomPainter画脸和五官而不是加载图片序列。这么做的优势是动画只在悬浮窗大小的画布内执行资源占用固定缺点是美术表现力受限没法做出特别华丽的光影效果。权衡之后我选择了表现力偏可爱、但资源占用极低的路子。内存泄漏的坑主要出现在反复进出对话界面的时候。Flutter里不小心把StreamSubscription、ChangeNotifier挂在全局而没有释放很容易把内存吃到几百兆。我现在在页面销毁时统一调用dispose()并且在调试模式里用DevTools的Memory页面盯着对象数量曲线如果曲线走了上坡路而不会回落基本就是泄漏了。4.4 断线重连SSE的超时与恢复SSE虽然实现简单但它有个天生的弱点连接很容易被网络环境切断尤其是手机在移动网络下切换基站的时候。第一个版本我对超时处理只有一个30秒的socket timeout一旦断线桌宠就卡在一个“正在思考”的状态既不说话也不报错看起来像是死机了。后来我加了两个机制。第一心跳检测每15秒客户端要向服务端发送一个空ping普通web请求即可如果连续三次没有收到pong判定连接已断进入重连流程。第二指数退避重连第一次重连等2秒第二次等4秒第三次等8秒最多延迟到60秒。重连成功之后客户端会带着当前的上下文重新发起一次请求等于是“断点续聊”。这个机制上线之后最直接的变化是用户不再反馈“桌宠卡住了”。即使网络不好桌宠会切换成一个“信号弱”的表情重连成功后继续回答整个过程用户能感知到而不是无声无息。4.5 控制codex调用的token成本手机端桌宠一个潜在风险是成本失控。如果你把它当成永不离线的助手用户一天聊几百轮到底要烧多少token心里没数的话很容易翻车。我做了三个层面的控制第一上下文窗口裁剪。前面提到只保留最近10轮且以摘要替代过期对话这样每次请求的token数量被限制在一个范围内。第二用户配额设置。每个账号每天默认有调用次数上限超出后桌宠会以“今天有点累了”这样的口吻拒绝继续对话同时提醒用户第二天再来。第三缓存命中。如果用户问的问题与某个已经问过的问题相似度极高我会把之前的回答直接调出来不重新请求接口。这个成本控制在个人项目里可能被忽视但如果你打算把这个桌宠打包成产品让更多人用这是必须面对的一关。我见过好几个AI应用产品做得不错就是死在API账单上。5. 内测反馈之后我把它改成了更省心的样子5.1 内测用户最关心的不是功能数量做了几轮内测之后有个结果其实挺反直觉用户对桌宠的动画好不好看、功能多不多并不是最在意的他们最在意的是稳定和省心。我收到的反馈里面高频词是“不会崩”“不费电”“别太吵”。“别太吵”指的是桌宠不要动不动就跳出来提示、不要没事就发表情、不要在一堆通知里制造新红点。这跟我一开始脑子里“桌宠越活泼越好”的想法完全不同。桌宠的本质是低打扰陪伴它应当像桌面上的小摆件你看它可爱但不看它时它也不刷存在感。AI能力接入之后桌宠的交互深度变大了但“安静”仍然是它的生存底盘。5.2 三个重要调整基于内测反馈我做了三个关键调整。第一个是把默认动画调成“安静模式”呼吸幅度小、颜色更素只有用户主动点击时才切换成活泼状态。第二个是把常驻通知做成可折叠的不强制让用户看到一条醒目的“桌宠运行中”气泡而是用一个很轻的提醒避免用户反感。第三个是重新设计权限引导流程从“申请悬浮窗权限”改为“解释桌宠为什么要悬浮”让用户在理解后再决定。这个改动带来的授权成功率提升非常明显转化率直接翻了一倍。其中权限引导这个调整我觉得特别值得分享。一开始我在设置页放了一个“开启悬浮窗权限”的按钮点击后直接跳转到系统设置很多用户觉得莫名其妙后来我把按钮文案改成“让桌宠常驻屏幕”并在跳转之前弹出一张简单的示意图说明悬浮窗在哪些场景下能被看到。改动只花了一天但内测群里的提问瞬间少了很多。5.3 我的一点个人体会做到这一步我对“桌宠app”这个品类有了新的理解。很多桌宠项目把重心放在美术资源和动画效果上觉得把形象做得越精致越有吸引力但我的经验是接入codex后桌宠真正的竞争力是“有能力边界地帮助你”——它要足够快、足够懂上下文、足够轻而不是偶尔说一句话逗你乐。技术上的悬浮窗、SSE、状态机这些东西虽然有难度但都有迹可循最难的其实是找到一个让用户觉得“它就像住在手机里的一个小伙伴”的交互尺度。现在这个项目还在持续迭代接下来我准备加入语音交互和更多手势玩法比如摇一摇让桌宠说话、长按拖动时它会有紧张表情。桌宠这东西除了技术它本来就应该是有温度的。希望我的这些经验和踩坑记录能给你一点参考让你做出来的桌宠既好看又好用。