简介:这份基于Android Studio的桌面宠物项目源码,面向希望练手Android应用开发,尤其是桌面悬浮窗、GIF动画与交互设计的初学者和进阶者。项目把动态GIF宠物放到手机桌面,点击宠物即可弹出对话界面,涉及GIF播放、点击事件监听、Activity跳转与Intent传参、对话内容管理等环节,可帮助读者理解悬浮窗权限、动画渲染与组件通信的完整链路。压缩包共585个文件,以json配置、flat数据、bin资源、xml布局、jar依赖、webp与gif素材以及dex、apk产物为主,另有少量Java源码和Gradle脚本,包体约20.55MB,目录结构接近真实工程。已有127人学习下载。读者可据此复现可点击、可对话的桌面宠物,并借鉴触摸分区响应与对话数据组织的实现思路。
1. 一个会说话的桌面小人:这个 Android 桌面宠物项目解决的是什么问题
很多人第一次看到桌面上有个小人站着、会眨眼、戳一下还回你一句话,第一反应是这玩意儿得上游戏引擎。其实用 Android Studio 建一个空工程就够了,核心代码不到五百行,难点全部集中在三个地方:怎么让 View 活在 Activity 之外、怎么在悬浮窗里正确接住触摸事件、怎么把"点击"和"拖动"这两件事分开。这份工程就是一个把这三件事做完整的最小实现,宠物是一只逐帧动画的贴图,长按能拖到屏幕任意位置,松手自动吸到屏幕边缘,点一下弹出气泡说一句话,台词从一个 JSON 里随机抽,不会连着重复。
它适合两类人:一类是刚学完四大组件、想找一个"能立刻看到效果"的练手项目;另一类是想给自己 App 加个常驻悬浮助手,需要一份能直接改的悬浮窗骨架。整个项目没有任何第三方依赖,纯原生 View 体系,代码量小到可以一个下午读完,但踩过的坑一个不少。
2. 工程结构拆解:从 AndroidManifest 到悬浮窗服务的最小骨架
这个项目的分层思路很清楚:Activity 只负责"申请权限"和"开关服务"两件事,真正的活人逻辑全在一个前台 Service 里,宠物本体、气泡窗口、台词管理各自独立成一个类。这样切的好处是,你以后想把宠物从桌面搬进 App 内部的某个页面,只要换掉宿主,PetView 和 DialogueManager 能原样复用。
先看目录,四个核心类的位置决定了你后面改代码时该往哪找:
PetDesktop/ ├── app/src/main/ │ ├── java/com/example/petdesktop/ │ │ ├── MainActivity.java 入口页:权限申请、启动/停止服务 │ │ ├── PetService.java 前台服务:持有 WindowManager,管理宠物与气泡 │ │ ├── PetView.java 宠物本体:帧动画 + 状态机 + 触摸回调 │ │ ├── BubbleView.java 气泡:布局、定位、定时消失 │ │ └── DialogueManager.java 台词加载、随机不重复、冷却控制 │ ├── assets/ │ │ └── dialogues.json idle / touch / drag / night 四类台词 │ ├── res/ │ │ ├── drawable/ pet_idle_01.png 等逐帧贴图 │ │ ├── drawable/pet_idle.xml AnimationDrawable 帧动画定义 │ │ ├── layout/view_bubble.xml 气泡布局,圆角背景 + 一个 TextView │ │ └── values/dimens.xml dp 基准尺寸,多分辨率适配用 │ └── AndroidManifest.xml └── build.gradle2.1 四个类的职责边界
MainActivity是整个工程里唯一有界面的 Activity,它的职责被压到极窄:检查悬浮窗权限、引导用户去系统设置页开权限、拿到权限后startForegroundService。它甚至不持有宠物的任何引用,所以你按返回键退出 Activity,宠物照样在桌面上待着。
PetService是真正的中枢。它在onCreate里拿到WindowManager,把PetView挂上去,在onDestroy里负责把 View 摘下来,同时它还是气泡窗口的宿主——因为宠物和气泡必须共用同一个WindowManager实例和同一套坐标基准,否则气泡永远对不准宠物的头顶。
PetView继承自FrameLayout,内部包一个ImageView做帧动画。把触摸监听放在PetView自己身上而不是ImageView上,是为了让可点击区域比贴图略大一圈——宠物贴图边缘是透明的,手指点在透明像素上如果直接命中ImageView的矩形边界,手感会很怪。DialogueManager则完全无 UI,只吐字符串,方便你换成网络接口而不动其他代码。
2.2 AndroidManifest:悬浮窗权限与前台服务的声明
悬浮窗这件事,权限声明和运行时申请是两套东西,缺一不可。清单里必须声明SYSTEM_ALERT_WINDOW,但这个权限是系统级的特殊权限,不能靠requestPermissions弹框拿到,只能跳系统设置页让用户手动开。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <!-- 悬浮窗权限:特殊权限,只能在设置页由用户手动授予 --> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <!-- 前台服务,保证进程不被随手回收 --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Android 14 起前台服务必须声明具体类型 --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <!-- Android 13 起发通知要运行时权限,前台服务的常驻通知也算 --> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <application android:allowBackup="false" android:label="@string/app_name" android:theme="@style/Theme.AppCompat.Light.NoActionBar"> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <service android:name=".PetService" android:exported="false" android:foregroundServiceType="specialUse"> <property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="desktop_pet_overlay" /> </service> </application> </manifest>这里有个容易翻车的点:foregroundServiceType在 Android 14 上是硬性要求,不写会直接抛MissingForegroundServiceTypeException。桌宠这种没有现成类型可套的场景,官方给的出口是specialUse,并且必须配一段PROPERTY_SPECIAL_USE_FGS_SUBTYPE说明用途——审核时这段说明是要看的,别随手写个 test 糊过去。如果你的targetSdk还停在 33 以下,这段可以先不加,但迟早要补。
2.3 权限申请流程:从系统设置页回来怎么判断
新手最容易在这里翻车:跳转设置页用startActivityForResult等回调,结果用户切回来resultCode永远是RESULT_CANCELED,代码就卡在"没权限"分支里出不来。悬浮窗授权走的是系统设置,压根不会给你回传结果码。
// MainActivity.java private static final int REQ_OVERLAY = 1001; private void ensureOverlayPermission() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { startPetService(); // 6.0 以下装完就有权限 return; } if (Settings.canDrawOverlays(this)) { startPetService(); } else { Intent intent = new Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + getPackageName())); startActivityForResult(intent, REQ_OVERLAY); } } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode == REQ_OVERLAY) { // 别信 resultCode,回来重新查一遍真实状态 if (Settings.canDrawOverlays(this)) { startPetService(); } else { Toast.makeText(this, "需要悬浮窗权限才能把宠物放到桌面", Toast.LENGTH_LONG).show(); } } }Settings.canDrawOverlays是唯一可信的判据,onActivityResult在这里只当"用户回来了"的信号用,resultCode直接忽略。这个习惯值得养成:所有跳系统设置的授权(通知、精确闹钟、电池优化白名单)都是同一套逻辑,回调查状态,别查返回值。
启动服务时也要分版本,8.0 之后必须用startForegroundService,而且要在 5 秒内调用startForeground把通知挂上去,否则系统直接 ANR 加崩溃:
private void startPetService() { Intent i = new Intent(this, PetService.class); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(i); } else { startService(i); } finish(); // 权限拿到就退场,界面留着没意义 }finish()放在这里是刻意的,桌宠的交互全在桌面上,用户没有理由再停留在一个空白的 Activity 里。
3. 悬浮窗核心链路:WindowManager 参数、触摸拖动与点击判定
悬浮窗的完整生命周期其实就是三句话:用WindowManager.LayoutParams描述"长什么样、放哪、吃不吃触摸",用addView挂上去,用updateViewLayout改位置。真正麻烦的是参数组合错了不会报错,只会表现为"看不见""点不动""拖一下就飞了"。
3.1 用 LayoutParams 把宠物摆到桌面上
// PetService.java private WindowManager windowManager; private PetView petView; private WindowManager.LayoutParams petLp; private void attachPet() { windowManager = (WindowManager) getSystemService(WINDOW_SERVICE); petView = new PetView(this); // 8.0 起 TYPE_PHONE 等旧类型全部废弃,必须用 TYPE_APPLICATION_OVERLAY int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY : WindowManager.LayoutParams.TYPE_PHONE; petLp = new WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, type, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, PixelFormat.TRANSLUCENT); petLp.gravity = Gravity.TOP | Gravity.START; // 坐标系原点定在左上角 petLp.x = dp(48); // 横向偏移,单位是 px 不是 dp petLp.y = getResources().getDisplayMetrics().heightPixels / 2; windowManager.addView(petView, petLp); petView.bindWindow(this, windowManager, petLp); // 把拖动需要的东西传下去 }几个参数逐个说清。WRAP_CONTENT让窗口尺寸跟着贴图走,避免透明区域挡住桌面图标的长按。FLAG_NOT_FOCUSABLE是必须的,否则宠物会抢走输入焦点,用户在桌面上打字、滑动都会被它截胡——这个 flag 同时隐含了FLAG_NOT_TOUCH_MODAL,窗口外的触摸事件会正常传给下层。FLAG_LAYOUT_NO_LIMITS允许窗口超出屏幕边界,好处是拖到边缘时宠物不会被硬切掉一半,坏处是你得自己写吸边逻辑把它拉回来。PixelFormat.TRANSLUCENT少了它,PNG 的透明区域会变成黑底,这是最经典的翻车现场之一。
x和y的单位是像素,不是 dp。写死dp(48)之前在工具类里转一次,是为了在不同密度屏幕上起始位置一致;但如果你的工程里直接写了lp.x = 48,在 3x 屏上宠物会紧贴左边,看起来像 bug。
3.2 onTouch 里区分「拖」和「点」
悬浮窗拿到的是一次完整的MotionEvent序列,但用户心里的"点一下"和"按住拖走"在系统看来都是 DOWN → (MOVE...) → UP。判定标准只有一个:总位移有没有超过系统的触摸阈值ViewConfiguration.getScaledTouchSlop(),一般设备上是 8dp 左右。
// PetView.java publiCamera void bindTouch(final WindowManager wm, final WindowManager.LayoutParams lp) { final int slop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); setOnTouchListener(new OnTouchListener() { private float downRawX, downRawY; private int originX, originY; private boolean dragging; @Override public boolean onTouch(View v, MotionEvent e) { switch (e.getActionMasked()) { case MotionEvent.ACTION_DOWN: downRawX = e.getRawX(); // 必须用 rawX,getX 是相对父容器 downRawY = e.getRawY(); originX = lp.x; originY = lp.y; dragging = false; return true; // 不消费就收不到 MOVE / UP case MotionEvent.ACTION_MOVE: int dx = (int) (e.getRawX() - downRawX); int dy = (int) (e.getRawY() - downRawY); if (!dragging && Math.hypot(dx, dy) > slop) { dragging = true; playState(STATE_DRAG); } if (dragging) { lp.x = originX + dx; lp.y = originY + dy; wm.updateViewLayout(PetView.this, lp); } return true; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (!dragging) { playState(STATE_TOUCH); getDialogue().speak("touch"); // 位移没过阈值,算点击 } else { playState(STATE_IDLE); snapToEdge(wm, lp); // 拖完吸边 } return true; } return false; } }); }这段代码里有两个血泪经验。第一,getRawX()和getX()不能混用:getRawX是相对整个屏幕的绝对坐标,getX是相对父容器的。悬浮窗没有父容器,看起来用哪个都行,但一旦你换成getX,手指快速拖动时事件会漂,宠物跟着抖。第二,updateViewLayout的频率要控制,ACTION_MOVE一次事件可能对应多次坐标变化,每帧调一次就够了,没必要做额外节流——系统本身已经把 MOVE 事件按屏幕刷新率合并过了。
吸边逻辑很简单,松手时判断宠物中心点落在屏幕左半还是右半,然后把lp.x补间到贴边位置。这里用ValueAnimator做 200ms 的过渡,比直接赋值舒服得多,也不会让用户觉得"跳"了一下。
3.3 帧动画与状态机:让宠物看起来是活的
贴图是逐帧的 PNG 序列,用AnimationDrawable是最省事的方案,四行 XML 就能定义一组循环帧:
<!-- res/drawable/pet_idle.xml --> <animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="false"> <item android:drawable="@drawable/pet_idle_01" android:duration="180" /> <item android:drawable="@drawable/pet_idle_02" android:duration="180" /> <item android:drawable="@drawable/pet_idle_03" android:duration="180" /> <item android:drawable="@drawable/pet_idle_04" android:duration="180" /> </animation-list>oneshot="false"表示循环播放,duration是每帧停留的毫秒数,180ms 大概是每秒 5.5 帧,这个速度对呼吸、眨眼这类微动作刚好,太快反而像抽搐。
状态机在PetView里只维护一个currentState,切换时做两件事:换 drawable,重新start()动画。关键是要有"同一状态不重复启动"的短路判断,否则一次拖动里几十次 MOVE 事件会反复setImageResource,动画从第一帧重启,视觉上就是卡住不动。
// PetView.java public static final int STATE_IDLE = 0, STATE_TOUCH = 1, STATE_DRAG = 2, STATE_TALK = 3; private int currentState = -1; private final Handler ui = new Handler(Looper.getMainLooper()); public void playState(int state) { if (currentState == state) return; // 同状态直接返回,避免动画被打回第一帧 currentState = state; int resId; switch (state) { case STATE_DRAG: resId = R.drawable.pet_drag; break; case STATE_TOUCH: resId = R.drawable.pet_touch; break; case STATE_TALK: resId = R.drawable.pet_talk; break; default: resId = R.drawable.pet_idle; break; } imageView.setImageResource(resId); ((AnimationDrawable) imageView.getDrawable()).start(); // 触摸 / 说话这类一次性状态,800ms 后自动回 idle if (state == STATE_TOUCH || state == STATE_TALK) { ui.removeCallbacks(idleTask); ui.postDelayed(idleTask, 800); } } private final Runnable idleTask = () -> playState(STATE_IDLE);idleTask提出来做成员变量而不是每次 new 一个匿名 Runnable,是为了让removeCallbacks能精确取消。如果每次都 new,连点五次就会排五个回 idle 的任务,宠物会在你说完话后莫名抽搐几下——这个 bug 我在别的项目里见过一模一样的版本,排查了半小时才发现是 Runnable 没复用。
4. 对话系统实现:气泡窗口、台词表与点击触发
对话这块的设计目标是"加一句台词不需要改代码"。所以台词全部外置到assets/dialogues.json,DialogueManager只负责读表、抽签、控频,气泡是一个独立于宠物的第二个悬浮窗。为什么不把气泡做成宠物窗口内部的一个子 View?因为宠物窗口是WRAP_CONTENT的,气泡一撑开,窗口尺寸变化会触发重新布局,宠物会跟着位移,看起来像被气泡顶了一下。
4.1 台词表放 assets:JSON 结构与随机不重复
{ "idle": ["今天也要加油呀", "你盯我干嘛", "窗外好像要下雨了"], "touch": ["别戳脸,会变形", "再戳我就生气了", "痒……"], "drag": ["放我下来!", "这个位置不好,换一个", "晕了晕了"], "night": ["这么晚还不睡?", "熬夜会变丑的", "我陪你,但我要睡了"] }按"场景"分组而不是平铺一个数组,是为了让触发逻辑能带上语境。点击抽touch,拖拽结束时抽drag,定时心跳按时间段抽idle或night。这个结构后面扩成feed、weather都不用动解析代码。
解析用系统自带的org.json就够了,为三十行代码引入 Gson 不值当:
// DialogueManager.java private final Map<String, List<String>> table = new HashMap<>(); public void load(Context ctx) { try (InputStream is = ctx.getAssets().open("dialogues.json")) { byte[] buf = new byte[is.available()]; int n = is.read(buf); JSONObject root = new JSONObject(new String(buf, 0, n, StandardCharsets.UTF_8)); Iterator<String> keys = root.keys(); while (keys.hasNext()) { String scene = keys.next(); JSONArray arr = root.getJSONArray(scene); List<String> lines = new ArrayList<>(arr.length()); for (int i = 0; i < arr.length(); i++) { lines.add(arr.getString(i)); } table.put(scene, lines); } } catch (Exception e) { Log.e("Dialogue", "台词表解析失败,检查 assets 路径与 JSON 语法", e); } }is.available()在 assets 上能拿到准确长度,换成网络流就不行,别把这个写法抄到别处去。异常这里只打日志不抛,是因为台词表挂了顶多不说话,不应该让整个服务起不来——降级比崩溃重要。
随机不重复的写法是记住每个场景上一次抽中的下标,抽到相同就重抽:
public String pick(String scene) { List<String> lines = table.get(scene); if (lines == null || lines.isEmpty()) return null; if (lines.size() == 1) return lines.get(0); int last = lastIndex.getOrDefault(scene, -1); int idx; do { idx = random.nextInt(lines.size()); } while (idx == last); lastIndex.put(scene, idx); return lines.get(idx); }用do-while重抽而不是"把上次的从列表里剔掉再随机",是因为台词只有三条时,剔除法会让概率分布变得奇怪,重抽法在样本量小的情况下反而更接近真随机。
4.2 气泡窗口的定位、穿透与自动消失
气泡是第二个addView,参数里最关键的是FLAG_NOT_TOUCHABLE:气泡必须完全不吃触摸,否则用户想点气泡下面的图标会被拦住,体验极差。同时它也不能抢焦点,所以FLAG_NOT_FOCUSABLE也得带上。
// PetService.java private View bubbleView; private void showBubble(String text) { if (text == null) return; removeBubble(); // 先清旧的,避免两个气泡叠在一起 bubbleView = LayoutInflater.from(this).inflate(R.layout.view_bubble, null); ((TextView) bubbleView.findViewById(R.id.tv_text)).setText(text); WindowManager.LayoutParams blp = new WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, petLp.type, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, PixelFormat.TRANSLUCENT); blp.gravity = Gravity.TOP | Gravity.START; blp.x = clamp(petLp.x + dp(6) - dp(40), 0, getResources().getDisplayMetrics().widthPixels - dp(200)); blp.y = petLp.y - dp(76); // 优先放在头顶 if (blp.y < getStatusBarHeight()) { // 顶到状态栏就翻到脚下 blp.y = petLp.y + petView.getHeight() + dp(8); } windowManager.addView(bubbleView, blp); ui.removeCallbacks(hideTask); ui.postDelayed(hideTask, 2600); // 2.6 秒足够读完一句短台词 } private final Runnable hideTask = this::removeBubble; private void removeBubble() { if (bubbleView != null && bubbleView.isAttachedToWindow()) { windowManager.removeView(bubbleView); // 未 attach 时 remove 会抛 IllegalArgumentException bubbleView = null; } }isAttachedToWindow()这个判断不是防御性编程的洁癖,是实打实踩过的。服务销毁时如果hideTask还在队列里,removeView会被调用两次,第二次直接抛IllegalArgumentException: View not attached to window manager,日志里一片红。同类的还有WindowLeaked——忘记在onDestroy里removeView,Activity 销毁后窗口还挂着,系统会把整个窗口树的引用记在你头上。
气泡的 x 坐标做了clamp处理,因为宠物被拖到屏幕最右边时,气泡如果还按petLp.x定位,右边一半会跑到屏幕外,文字被裁得只剩几个字。宽度按WRAP_CONTENT撑开,但最大宽度要在view_bubble.xml里给TextView设android:maxWidth="200dp",否则一条长台词能横穿整个屏幕。
4.3 触发时机与冷却:让对话不烦人
语录系统最容易做坏的地方不是技术,是节奏。用户点一下宠物说一句是惊喜,连点五下弹出五句话就是骚扰。冷却放在DialogueManager内部,让所有调用方都受约束:
private static final long COOLDOWN_MS = 1200L; private long lastSpeakAt = 0L; public void speak(String scene) { long now = SystemClock.uptimeMillis(); if (now - lastSpeakAt < COOLDOWN_MS) return; // 冷却期内静默丢弃,不排队 lastSpeakAt = now; String line = pick(scene); if (line != null && listener != null) listener.onSpeak(line); }这里用的是"丢弃"而不是"排队等冷却结束再说"。排队会让用户点五下、然后听五句话连着蹦出来,比不响应还糟。SystemClock.uptimeMillis()而不是System.currentTimeMillis(),是因为前者不受用户改系统时间影响,虽然桌宠场景下这个差别无所谓,但养成习惯没坏处。
除了点击触发,还得有个"活着"的感觉。常见做法是在PetService里挂一个 25 到 40 秒的随机心跳,到点了就抽一条idle台词,同时顺手判断一下当前小时数决定是不是切到night组。这个心跳只在屏幕亮着、宠物可见的时候跑,onDestroy里记得removeCallbacks,不然服务停了 Handler 还在发消息,属于典型的隐形泄漏。
5. 避坑与排查:悬浮窗在真机上跑不起来的 5 个常见问题
模拟器上一切正常、装到真机上就各种玄学,是悬浮窗类项目的常态。下面五条基本覆盖了这个工程从开发到上线会遇到的绝大部分问题,每条都按现象、原因、解决三段来。
5.1 权限显示已授权,宠物就是不出现
现象:Settings.canDrawOverlays返回 true,日志里addView也没抛异常,但屏幕上什么都没有。
原因:LayoutParams的 type 在 Android 8.0 之后用错了。用TYPE_PHONE、TYPE_SYSTEM_ALERT这些旧类型,在 8.0 及以上系统里addView不报错但窗口被静默丢弃,日志里连个警告都没有,是纯粹的"黑匣子"式失败。
解决:统一走版本判断,26 及以上一律用TYPE_APPLICATION_OVERLAY。顺带检查PixelFormat是不是TRANSLUCENT,写成OPAQUE的话透明区域会变成黑块,看起来也像"没显示对"。
5.2 能拖动但完全点不动
现象:手指按上去、拖动、松手,位置会变,但点击永远没反应,不弹气泡。
原因:onTouch里的拖动判定把点击"吃掉"了。要么是ACTION_DOWN没return true,后续事件序列根本没进来;要么是判断条件写成了Math.abs(dx) > 0,手指抖一个像素就被判定成拖动。
解决:ACTION_DOWN必须消费事件;拖动阈值用ViewConfiguration.getScaledTouchSlop()而不是硬编码;另外检查LayoutParams里有没有误加FLAG_NOT_TOUCHABLE,这个 flag 会让整个窗口对触摸完全透明,连 DOWN 都收不到。
5.3 手指一动宠物就"飞"出去老远
现象:轻轻一拖,宠物瞬间跳到屏幕另一头,或者拖到边缘后位置开始乱抖。
原因:坐标系混用。ACTION_DOWN里存了event.getX()(相对父容器),ACTION_MOVE里用的是event.getRawX()(相对屏幕),两者一减,差值就是父容器的偏移量,屏幕越往下偏得越离谱。另一个常见原因是每次 MOVE 都用lp.x = startX + dx里的startX更新成了当前值,导致位移被累加。
解决:全程只用getRawX/getRawY;ACTION_DOWN时把lp.x、lp.y存成originX、originY两个局部变量,MOVE 里永远基于 origin 算,不要回写。
5.4 退出重进后桌面上有两只宠物
现象:反复启动停止服务几次,桌面上出现多个重叠的宠物,点一下弹出好几个气泡。
原因:onStartCommand被调用了多次(Activity 重建、通知栏点击、系统重启服务都会触发),每次attachPet都addView一次,但旧的 View 从来没被移除。WindowManager允许多个同类型窗口共存,它不会帮你查重。
解决:attachPet开头加一个if (petView != null && petView.isAttachedToWindow()) return;的守卫;onDestroy里必须removeView并且把引用置空。更稳妥的做法是把addView挪到onCreate而onStartCommand里只处理业务逻辑,因为onCreate在整个服务生命周期里只跑一次。
5.5 锁屏或切到别的应用久了,宠物自己消失
现象:放着不动十几分钟,回来一看桌面上没了;或者手机重启后宠物就再也没起来过。
原因:服务被系统回收了。只调startService而不startForeground,进程在后台的优先级很低,内存一紧张第一个被干掉。加上部分厂商的 ROM 对后台启动悬浮窗有额外的开关限制,权限管理里另外有一个"后台弹出界面"的选项,不开的话,addView照样静默失败。
解决:老老实实走前台服务,onStartCommand里 5 秒内startForeground挂一个低优先级通知,onStartCommand返回START_STICKY让系统有机会重启它。文档或引导里明确告诉用户去系统设置里把"后台弹出界面""自启动"这两个开关打开——这不是代码能绕过去的,只能引导。
6. 二次开发:把宠物换成自己的皮和脑子
这份工程最好用的地方在于它是"分层"的,换皮和换脑子互不影响。换皮只动res/drawable下的 PNG 和那四个animation-listXML;换脑子只动dialogue.json和DialogueManager,两个方向可以并行推进。
先说换皮。帧图必须尺寸完全一致,逐帧动画不会帮你做对齐,第 3 帧是 200×200、第 4 帧是 220×220,播放时宠物会周期性抖一下。建议一次做四到六帧,帧率高过每秒 10 帧对桌宠来说是浪费——它会持续唤醒 GPU 重绘,耗电很明显。有个小技巧是把所有帧打进一张横向长图,用BitmapDrawable配合clipRect做裁切,能少几十个 drawable 资源,不过对新手没必要,先用animation-list跑通再说。
再说换脑子。台词表可以扩得更细,比如加morning、rain、low_battery,然后在PetService里挂一个条件心跳,每 30 秒判断一次该抽哪一组:
// PetService.java:条件心跳,让宠物"知道"现在是什么时候 private final Runnable heartbeat = new Runnable() { @Override public void run() { int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY); if (hour >= 23 || hour < 6) { dialogue.speak("night"); // 深夜组 } else if (batteryLow()) { dialogue.speak("low_battery"); // 低电量时撒个娇 } else if (SystemClock.uptimeMillis() - lastTouchAt > 90_000) { dialogue.speak("idle"); // 90 秒没人理,主动搭话 } ui.postDelayed(this, 30_000); } };这段的关键是"沉默期"判断:lastTouchAt在每次点击时更新,90 秒没互动才主动说话,比固定间隔刷存在感自然得多。removeCallbacks(heartbeat)要写在onDestroy里,这一点比逻辑本身更重要。
几个常用的扩展点放在一起对比,方便照着改:
| 扩展点 | 改动位置 | 需要注意的参数 |
|---|---|---|
| 换宠物形象 | res/drawable/pet_*.xml与 PNG | 所有帧尺寸一致,duration保持 150~200ms |
| 加台词场景 | assets/dialogues.json | 数组至少两条,pick的重抽逻辑才不会死循环 |
| 加交互手势 | PetView.bindTouch | 双击用GestureDetector,长按走ACTION_MOVE超 500ms 判定 |
| 好感度 / 养成 | 新增PetPrefs封SharedPreferences | 写入放apply(),别在主线程commit() |
| 换气泡样式 | res/layout/view_bubble.xml | maxWidth必须设,否则长句横穿屏幕 |
| 常驻后台 | PetService前台通知 | 通知渠道在 8.0 起必须先createNotificationChannel |
改完之后怎么验证?不靠肉眼盯屏幕。用adb shell dumpsys window windows | grep -i "petdesktop"能看到当前挂了几个窗口、type 是多少、位置在哪,比反复装机快得多。触摸问题用adb shell getevent -l看原始事件有没有下发到你的窗口,坐标系混乱的话一眼就能对出来。这几个命令我现在基本是条件反射,改完悬浮窗参数先 dump 一遍再上手机。
好感度那块我想多说一句。很多人一上来就想做养成系统,结果把SharedPreferences的读写散落在PetView、PetService、DialogueManager三个地方,最后数据对不上,宠物心情时好时坏,排查半小时才发现是某个分支忘了apply()。我一般会在最早的时候就封一个PetPrefs,只暴露addIntimacy(int delta)、getIntimacy()、onDayChanged()这几个方法,内部统一管commit时机和跨天重置,别图省事到处getSharedPreferences。从那以后我每次接这类"有状态的小玩意"的项目,都强制先把状态层封完再动 UI,不然等界面铺开之后再抽,改动量能翻三倍。希望这套拆法能帮到你,少走几段我已经走过的弯路。
本文还有配套的精品资源,点击获取