暗无天日

=============>DarkSun的个人博客

MobileOrg Android:从 API 17 迁移到 API 34 的实战记录

本文迁移全程借助 Claude Code 完成,我本人没有手动编写任何代码。整个过程是:真机测试发现问题 → 将 logcat 或现象描述给 Claude Code → Claude Code 定位原因并生成修复代码 → 推送 GitHub Actions 构建 → 再测试验证。包括本文本身,也是借助 Claude Code 生成的。

由于我不是 Android 开发者,对文中涉及的技术细节无法保证完全准确,请读者自行判断。

MobileOrg Android 是一个 Org-mode 的 Android 客户端,fork 自已经停止维护的 matburt/mobileorg-android。原始代码写于 2013 年左右,targetSdk 为 17(Android 4.2),使用 ActionBarSherlock、Support Library v4 等早已废弃的库。

Google Play 要求 targetSdk 33+,所以必须迁移。本文记录了整个迁移过程中的踩坑和修复经验。

迁移时间线

整个迁移分三个阶段,共 50+ 个 commit:

第一阶段:构建系统升级

原始项目使用 ADT 构建,没有 Gradle 支持。首先需要让项目能编译:

  1. 创建 Gradle 构建配置(AGP 3.0.1 + Gradle 4.1)
  2. 解决 ActionBarSherlock 兼容性(禁用 AAPT2)
  3. 解决 support-v4 版本冲突(降级到 25.4.0)

第二阶段:AndroidX 迁移

  1. 替换 ActionBarSherlock 为 AppCompat
  2. 执行 AndroidX 迁移(Refactor → Migrate to AndroidX)
  3. 升级到现代工具链(AGP 8.2.2 + Gradle 8.5 + JDK 17 + compileSdk 34)

第三阶段:运行时兼容性修复

这是最耗时的阶段。代码能编译,但在现代 Android 上各种崩溃和功能异常。下面详细记录。

踩过的坑

坑 1:ActionBarSherlock → AppCompat 的菜单图标消失

症状:菜单图标在 ActionBar 上不显示

AppCompat 使用自己的 namespace 解析 showAsAction 属性。如果用 =android:showAsAction=,AppCompat 会静默忽略,图标不显示但不报错。

<!-- 错误:AppCompat 会忽略 -->
<item android:showAsAction="ifRoom" />

<!-- 正确:必须用 app namespace -->
<item app:showAsAction="ifRoom"
    xmlns:app="http://schemas.android.com/apk/res-auto" />

**影响范围**:项目中 8 个菜单 XML 文件全部需要修改。只修了一个文件时,其他界面(capture 保存按钮、节点操作按钮等)的图标依然消失。

教训:迁移框架后要全局搜索同类问题,不要只修一个文件就以为搞定了。

坑 2:NotificationChannel 导致前台服务崩溃

症状:CannotPostForegroundServiceNotificationException: Bad notification for startForeground

Android 8.0(API 26)引入了 NotificationChannel。Android 13(API 33)进一步强化——如果 notification 引用的 channel 不存在,直接抛异常崩溃。

原始代码通过反射创建 channel(因为当时 compileSdk 是 23),但反射异常被静默吞掉了。Channel 没创建成功,notification 引用了一个不存在的 channel,崩溃。

// 原来的反射方式——异常被吞掉,channel 创建失败但不知道
try {
    Class<?> channelClass = Class.forName("android.app.NotificationChannel");
    // ...
} catch (Exception e) {
    // 静默失败!
}

// 修复:compileSdk 34 已经有直接 API,不需要反射
NotificationChannel channel = new NotificationChannel(channelId, name,
    NotificationManager.IMPORTANCE_LOW);
nm.createNotificationChannel(channel);

同时,所有 NotificationCompat.Builder 构造函数都必须传 CHANNEL_ID:

// 错误:没有 channel ID
new NotificationCompat.Builder(context)

// 正确
new NotificationCompat.Builder(context, CHANNEL_ID)

**影响范围**:6 处 Builder 调用点,涉及 SyncService、TimeclockService、SynchronizerNotification、SynchronizerNotificationCompat。

坑 3:危险权限的运行时检查

症状:CalendarSyncService 启动时 SecurityException 崩溃

Android 6.0(API 23)引入了运行时权限。=READ_CALENDAR= 和 WRITE_CALENDAR 是危险权限,光在 Manifest 声明不够,必须运行时请求。

// 在 Service.onCreate() 中检查权限
if (!hasCalendarPermission()) {
    stopSelf();  // 无权限则优雅退出
    return;
}

**影响范围**:CalendarSyncService 的 onCreate()onStartCommand() 都需要加权限检查。

坑 4:Service early return 导致 onDestroy NPE

症状:修复权限检查后,CalendarSyncService.onDestroy() 抛 NullPointerException

修复坑 3 加了 early return 后,=sharedPreferences= 没有被初始化,但 Android 仍然会调用 onDestroy()=,里面直接调了 =unregisterOnSharedPreferenceChangeListener(sharedPreferences...) 导致 NPE。

// onDestroy 必须做空检查
@Override
public void onDestroy() {
    if (sharedPreferences != null) {
        sharedPreferences.unregisterOnSharedPreferenceChangeListener(this);
    }
    super.onDestroy();
}

教训:在 onCreate() / onStartCommand() 中加 early return(比如权限检查失败后 stopSelf()=)时,=onDestroy() 仍会被 Android 调用。所有在 onDestroy() 中使用的字段都必须做空检查。

坑 5:主线程网络操作

症状:SSH Connection failed: android.os.NetworkOnMainThreadException

Android 3.0 开始禁止主线程网络操作。但 SyncService.getSynchronizer() 在主线程调用 new SSHSynchronizer()=,而 SSHSynchronizer 的构造函数调了 =connect() 做网络连接。

// 错误:构造函数中发起网络连接
public SSHSynchronizer(Context context) {
    // ... 读取配置 ...
    this.connect();  // 网络操作在主线程!
}

// 修复:移除构造函数中的 connect(),让后台线程处理
public SSHSynchronizer(Context context) {
    // ... 只读取配置,不连接 ...
}

后台线程的 getRemoteFile() 已经有重连逻辑,会自动在正确的线程上建立连接。

坑 6:隐式广播不送达

症状:同步后变更计数不刷新,同步动画不显示,但同步本身是成功的

这是最隐蔽的一个问题。=OrgUtils.announceSyncDone()= 通过 sendBroadcast() 发送隐式广播通知 UI 刷新。但 targetSdk 34 的设备上,隐式广播可能不被 RECEIVER_NOT_EXPORTED 的 receiver 接收。

// 错误:隐式广播,targetSdk 34 可能不送达
Intent intent = new Intent(Synchronizer.SYNC_UPDATE);
context.sendBroadcast(intent);

// 修复:设为显式广播
Intent intent = new Intent(Synchronizer.SYNC_UPDATE);
intent.setPackage(context.getPackageName());  // 关键!
context.sendBroadcast(intent);

这个问题的影响面很大:变更计数不刷新、同步动画不显示、编辑后列表不更新——全部因为广播没送达。但不会报任何错误,就是功能静默失效。

教训:targetSdk 升级后,最危险的不是崩溃,而是功能静默失效。崩溃至少有日志,静默失效只能靠用户反馈。

坑 7:Preference 的隐式 Intent 解析错误

症状:点击 Setup Wizard 直接退回到大纲界面

XML Preference 中用隐式 intent 打开 Activity:

<!-- 错误:隐式 intent 可能解析到错误的 Activity -->
<Preference android:title="Setup Wizard">
    <intent android:action="com.matburt.mobileorg.Settings.SETUP_WIZARD" />
</Preference>

<!-- 修复:显式指定目标 -->
<Preference android:title="Setup Wizard">
    <intent
        android:targetPackage="com.matburt.mobileorg"
        android:targetClass="com.matburt.mobileorg.Gui.Wizard.WizardActivity" />
</Preference>

坑 8:动画在未挂载的 View 上启动

症状:同步图标的旋转动画不显示

startAnimation() 再 =setActionView()=,此时 view 还没挂载到 window,动画被静默忽略。

// 错误顺序
refreshView.startAnimation(rotate);  // view 还没挂载!
synchronizerMenuItem.setActionView(refreshView);

// 修复:先挂载,再用 post 延迟启动动画
synchronizerMenuItem.setActionView(refreshView);
refreshView.post(() -> refreshView.startAnimation(rotate));

坑 9:Intent.getAction() 返回 null 导致 NPE

症状:Setup Wizard 完成后,OutlineActivity 崩溃 NullPointerException

之前修改 Wizard 完成后的导航逻辑,使用 FLAG_ACTIVITY_SINGLE_TOP 回到已有的 OutlineActivity:

Intent outlineIntent = new Intent(context, OutlineActivity.class);
outlineIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
context.startActivity(outlineIntent);

由于 FLAG_ACTIVITY_SINGLE_TOP=,Android 不会创建新实例,而是调用已有实例的 =onNewIntent()=。但这个 Intent 没有设 action,所以 =getAction() 返回 null:

// 崩溃:intent.getAction() 为 null,对 null 调 equals()
if (intent.getAction().equals(SYNC_FAILED)) { ... }

// 修复:常量在左,天然防 null
if (SYNC_FAILED.equals(intent.getAction())) { ... }

教训:=Intent.getAction()= 可以为 null。使用 FLAG_ACTIVITY_SINGLE_TOP 导航回已有 Activity 时,=onNewIntent()= 收到的 Intent 可能没有 action。养成 CONSTANT.equals(variable) 的习惯可以避免此类 NPE。

坑 10:AndroidX Fragment 要求内部类为 public static

症状:点击 TimeclockDialog 的编辑按钮,或 DateTableRow 的日期/时间选择器,抛 IllegalStateException

AndroidX Fragment 1.2+ 强制要求所有 Fragment 子类必须是 =public static=。非 static 内部类持有外部类隐式引用,无法通过无参构造器反射重建。

// 错误:非 static 内部类
private class EditTimePickerFragment extends DialogFragment { ... }

// 修复:改为 public static,数据通过 Bundle 传入
public static class EditTimePickerFragment extends DialogFragment {
    public static EditTimePickerFragment newInstance(int hour, int minute) {
        EditTimePickerFragment f = new EditTimePickerFragment();
        Bundle args = new Bundle();
        args.putInt("hour", hour);
        args.putInt("minute", minute);
        f.setArguments(args);
        return f;
    }
}

**影响范围**:4 个 Fragment 内部类(=TimeclockDialog.EditTimePickerFragment=、=DateTableRow= 的 3 个日期/时间选择器 Fragment)。

坑 11:registerReceiver 必须指定 RECEIVER_EXPORTED/RECEIVER_NOT_EXPORTED

症状:OutlineActivity 启动时崩溃 SecurityException

Android 13(API 33)要求调用 registerReceiver() 时必须指定 RECEIVER_EXPORTEDRECEIVER_NOT_EXPORTED 标志。项目中所有 registerReceiver 调用都需要加 API 级别判断:

if (Build.VERSION.SDK_INT >= 33) {
    registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
    registerReceiver(receiver, filter);
}

**影响范围**:3 处(=OutlineActivity=、=AgendaActivity=、=MobileOrgWidget=)。

坑 12:危险权限的防御性编码——UI 层检查不够

症状:通过 ActionMode 菜单点击"录音"后,RecordingService 崩溃 setAudioSource failed

新增的录音功能有两个 UI 入口:ActionMode 长按菜单和三点选项菜单。选项菜单路径做了 RECORD_AUDIO 权限检查,但 ActionMode 路径直接启动了 Service,跳过了权限检查。更关键的是,Service 本身对 MediaRecorder.setAudioSource() 没有做 try-catch,权限缺失时直接崩溃。

这暴露了危险权限的两个常见疏漏:

  1. *每个 UI 入口都要独立检查权限*——不能假设用户只从特定路径进入
  2. *Service 必须防御性编码*——权限可能在 UI 检查和 Service 执行之间被用户撤销
// Service 中必须 try-catch 包裹硬件 API
mediaRecorder = new MediaRecorder();
try {
    mediaRecorder.setAudioSource(MediaRecorder.AudioSource.DEFAULT);
    mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS);
    // ... prepare, start
} catch (Exception e) {
    releaseRecorder();
    stopSelf();
    return;
}

坑 13:权限检查静默失败——用户点击按钮无反应

症状:长按节点后点击 Record 按钮,ActionMode 关闭了但什么都没发生

ActionMode 路径使用 checkCallingOrSelfPermission() 检查录音权限,失败时只打印一条 Log.w 就 return 了,没有任何用户可见的反馈。用户看到的就是按钮点击无效。

// 错误:静默失败,用户看不到任何反馈
if (PERMISSION_GRANTED != context.checkCallingOrSelfPermission(RECORD_AUDIO)) {
    Log.w("Tag", "permission not granted");
    return;  // 用户:???
}

// 修复:委托给 Activity 的统一权限请求方法
// OutlineActionMode 中:
private void runRecordingService() {
    if (context instanceof OutlineActivity) {
        ((OutlineActivity) context).tryStartRecording(node.id);
    }
}

// OutlineActivity.tryStartRecording() 统一处理:
// 1. 检查 ContextCompat.checkSelfPermission()
// 2. 已授权 → 直接启动 Service
// 3. 未授权 → ActivityCompat.requestPermissions() 弹出系统对话框

教训:(1) checkCallingOrSelfPermission() 不适合 UI 层权限检查——它是给 Binder IPC 用的,不是给按钮点击用的。(2) 多个 UI 入口需要同一权限时,应委托给同一个方法处理,避免重复逻辑和遗漏。

坑 14:通知 Action 图标全部用了同一个图标

症状:录音通知右边显示两个一样的麦克风图标,分不清暂停和停止

新建通知的 NotificationCompat.Action 时,暂停和停止按钮都用了 =R.drawable.ic_menu_record=(麦克风图标),没有为每个操作创建语义化的图标。

// 错误:两个按钮都是麦克风
NotificationCompat.Action stopAction = new NotificationCompat.Action(
    R.drawable.ic_menu_record, ...);  // 麦克风
NotificationCompat.Action pauseAction = new NotificationCompat.Action(
    R.drawable.ic_menu_record, ...);  // 也是麦克风

// 修复:每个操作用自己的语义图标
NotificationCompat.Action stopAction = new NotificationCompat.Action(
    R.drawable.ic_media_stop, ...);   // ■ 方框
NotificationCompat.Action pauseAction = new NotificationCompat.Action(
    R.drawable.ic_media_pause, ...);  // ⏸ 双竖线

教训:通知的每个 Action 按钮都应该有独立、语义清晰的图标。Material Design Icons 提供了标准的 pause、stop、play 等图标,直接复用即可。

番茄钟超时无通知:通知渠道 importance 的"静默覆盖"

**现象**:番茄钟时间到后,没有任何提示——无声音、无振动、无弹出通知。

**根因**:Android 8+ 引入通知渠道(NotificationChannel)机制后,渠道的 importance 级别优先于 notification.defaults=。原代码用 =IMPORTANCE_LOW 创建了 mobileorg_timeclock 渠道,然后尝试用 notification.defaults = Notification.DEFAULT_ALL 触发声音和振动——但这在 IMPORTANCE_LOW 渠道下被静默忽略。更糟的是,复用了前台通知(ongoing)的 ID 来发送提醒,很多设备不会对已显示的 ongoing 通知重新触发声音。

// 旧代码:无效!LOW 渠道覆盖了 defaults
notification.defaults = Notification.DEFAULT_ALL;
mNM.notify(notificationID, notification);  // 复用前台通知 ID

**修复**:

  1. 创建独立的 IMPORTANCE_HIGH 渠道(=mobileorg_timeclock_timeout=)
  2. 发送独立的提醒通知(不同 ID、非 ongoing、=CATEGORY_ALARM=、=autoCancel=)
  3. 在 stop/cancel/destroy 时清理提醒通知
// 新代码:独立 HIGH 渠道 + 独立通知
Compat.createNotificationChannelHigh(this, TIMEOUT_CHANNEL_ID,
    "Pomodoro Timer Alert", "Alerts when pomodoro timer completes");
NotificationCompat.Builder timeoutBuilder = new NotificationCompat.Builder(this, TIMEOUT_CHANNEL_ID)
    .setSmallIcon(R.drawable.timeclock_icon)
    .setContentTitle("🍅 番茄钟时间到!")
    .setPriority(NotificationCompat.PRIORITY_HIGH)
    .setCategory(NotificationCompat.CATEGORY_ALARM)
    .setAutoCancel(true);
mNM.notify(TIMEOUT_NOTIFICATION_ID, timeoutBuilder.build());

教训:Android 8+ 上,=notification.defaults= 和 priority 在通知渠道面前形同虚设。永远不要用 IMPORTANCE_LOW 渠道发送需要声音/振动的提醒。前台通知(ongoing)不适合做提醒——用独立通知。

同步按钮旋转不停:Menu 重建导致动画状态丢失

**现象**:点击同步按钮后,旋转动画开始播放。同步完成后,按钮继续无限旋转。

**根因**:=onResume()= 中调用了 invalidateOptionsMenu()=(为了刷新番茄钟菜单项状态),这会导致 ActionBar/Toolbar 重建 Menu。Menu 重建后,=synchronizerMenuItem 被重新赋值给新的 MenuItem(没有 actionView),而旧的带旋转动画的 ImageView 与新 MenuItem 失去了关联。当 SYNC_DONE 广播到达时,=synchronizerMenuItem.getActionView()= 返回 null,无法停止动画。此外,=synchronizerMenuItem= 本身也可能为 null(OptionsMenu 还没创建),导致 NPE。

// 旧代码:没有 null 安全,动画状态可能丢失
android.view.View actionView = synchronizerMenuItem.getActionView();
if (actionView != null) { actionView.clearAnimation(); }
synchronizerMenuItem.setActionView(null);  // 可能 NPE!

**修复**:

  1. 添加 SyncService.isSyncRunning 静态标志,让 UI 可以检查同步状态
  2. onPrepareOptionsMenu() 中自愈:同步中但动画丢失→恢复动画;同步完成但动画残留→清除动画
  3. SYNC_DONE handler 加 null 安全检查
// 自愈逻辑:每次 menu invalidation 都检查
if (SyncService.isSyncRunning) {
    if (syncItem.getActionView() == null) {
        // 恢复旋转动画
        syncItem.setActionView(refreshView);
        refreshView.startAnimation(rotate);
    }
} else {
    // 清除残留动画
    if (syncItem.getActionView() != null) {
        actionView.clearAnimation();
    }
    syncItem.setActionView(null);
}

教训:=invalidateOptionsMenu()= 会重建 Menu,任何通过 setActionView() 设置的 UI 状态都会丢失。如果 UI 状态依赖后台任务,必须在 onPrepareOptionsMenu() 中根据任务状态自愈,而不是只在广播回调中处理。同时,广播回调中引用 MenuItem 必须做 null 安全检查。

坑 15:SCHEDULE_EXACT_ALARM 是特殊权限,新装默认未授予

DEADLINE/SCHEDULED 提醒是后期加的功能。manifest 里声明了 <uses-permission android:name"android.permission.SCHEDULE_EXACT_ALARM"/>= ,代码里也调用 =AlarmManager.setExactAndAllowWhileIdle()=,初看没问题。

实测时日志里出现:

W MobileOrg: ReminderScheduler failed: java.lang.SecurityException:
    Caller com.matburt.mobileorg needs to hold android.permission.SCHEDULE_EXACT_ALARM
    or android.permission.USE_EXACT_ALARM to set exact alarms.

而且只在 sync 完成后抛,应用启动时不抛。原因有两条:

  1. *=SCHEDULE_EXACT_ALARM= 是特殊权限*(app-special access),不是普通 dangerous permission。manifest 声明只是"有资格申请",实际是否生效要用户在系统设置里手动打开。**新安装的 app 默认未授予**。
  2. *=canScheduleExactAlarms()= 的 fallback 写错了*。原代码:

    if (Build.VERSION.SDK_INT >= 31 && alarmManager.canScheduleExactAlarms()) {
        alarmManager.setExactAndAllowWhileIdle(...);
    } else if (Build.VERSION.SDK_INT >= 23) {  // ← API 31+ 无权限也落到这里
        alarmManager.setExactAndAllowWhileIdle(...);  // ← API 31+ 无权限仍抛 SecurityException
    } else {
        alarmManager.setExact(...);
    }
    

    作者误以为 else if 分支只覆盖 API 23-30,但 API 31+ 无权限时 canScheduleExactAlarms() 返回 false 也会落到这里。在 API 31+ 上,=setExactAndAllowWhileIdle()= 始终需要权限,不管 API 等级。

为什么 onCreate 不抛?因为 onCreate 只调 scheduleDailyOverview()=,那个方法自己写对了(无权限 fallback 到 =setWindow()=)。sync 完成后才调 =scheduleAll()=,里面的 =registerAlarm() 写错了。这种"同一文件两处相同模式,一处对一处错"特别难发现——review 时容易下意识假定两处一致。

**修复**:抽出决策纯函数 chooseAlarmStrategy(apiLevel, canExact)=,让 =scheduleDailyOverviewregisterAlarm 共用,并加单测覆盖三个分支(API 31+ 有/无权限、API 23-30、API < 23)。

教训:Android 12+ 上凡是用到 setExact* 系列的地方都要先检查 =canScheduleExactAlarms()=,无权限时只能 fallback 到 =setWindow()=。manifest 声明 ≠ 权限已授予,对特殊权限永远是两回事。同一个文件里相同模式的代码必须一致,最佳做法是抽公共函数。

坑 16:Handler.postDelayed 在 Doze 下被推迟 25 分钟(前台服务也不例外)

番茄钟计时用 Handler.postDelayed(timeoutRunnable, 20min) 调度一次性超时回调。之前在 CLAUDE.md 里自信地写下"前台服务一直活着,所以 Handler 计时既简单又完美可靠"。这句话错了。

现象:用户选了 20 分钟番茄钟,倒计时到 0:00 之后又过了 30 多分钟,仍没有"番茄钟已结束"的提醒。通知栏倒计时一直显示 =0:00=。

诊断日志(设备 MIUI/xaga)的关键证据:

13:25:05  event=updateTick remaining=-1952     timedOut=false  ← 应该超时
13:32:06  event=updateTick remaining=-422857   timedOut=false  ← 已超 7 分钟
13:50:25  event=timeoutRunnableFired delayMs=1522254            ← 晚了 25 分 22 秒!

delayMs 是代码里预埋的诊断字段(=fireElapsed - timeoutTargetElapsed=),=1522254ms= = 25 分 22 秒。=timeoutRunnable= 本应在 13:25 触发,实际拖到 13:50。

更明显的旁证是 updateTick 的时间间隔——它用 postDelayed(this, 60000) 自我续期,正常应严格 60 秒,但日志里前期变成了 349s、336s、157s、422s 这样的不规则间隔,直到设备被唤醒(用户点亮屏幕)后才恢复稳定 60 秒。这说明 Handler 消息队列被 Doze 整体卡住了。

*根因*:前台服务保证进程不被杀,但不保证 CPU 不睡。设备进入 Doze 后,CPU 休眠,=Handler= 的 MessageQueue 停转,所有待派发的消息被推迟到系统维护窗口。标准 Android 会用维护窗口补发积压消息(所以 updateRunnable 在唤醒后能恢复),但厂商 ROM(MIUI/EMUI/ColorOS)的激进策略会让延迟变得不可控。一次性长延迟定时器(20 分钟)正好落在 Doze 区间,就被推迟到 25 分钟后才补发。

修复方案没有用 =WakeLock=(番茄钟可能跑多个长轮次,全程持锁耗电不可接受),而是让周期性的 =updateTick=(60 秒、自我续期、已证明在唤醒后能恢复)兼作超时保底:

private void checkTimeoutFallback() {
    // WORK 阶段:一次性 timeoutRunnable 可能被 Doze 推迟
    if (pomodoroTimer.isRunning() && !pomodoroTimer.isTimedOut()
            && pomodoroTimer.getRemainingMillis() <= 0) {
        handlePomodoroTimeout();
        return;
    }
    // REST 阶段:一次性 restTimeoutRunnable 有同样的 Doze 暴露
    if (pomodoroTimer.isResting()
            && pomodoroTimer.getRestRemainingMillis() <= 0) {
        handleRestTimeout();
    }
}

updateRunnable.run() 里每次 updateTime() 前调用。最坏情况延迟 = 设备唤醒后一个 tick(≤60 秒),远好于 25 分钟。=timeoutRunnable= 保留作为"快速路径"(Doze 没干扰时即时响应),=updateTick= 作为"保底路径"。

顺带挖出一个*幂等性缺陷*:=handlePomodoroTimeout= 原本只检查 if (!isRunning()) return=,但 =markTimeout() 只设 timedOut=true=,=running 保持 true=。于是保底触发后,延迟的 =timeoutRunnable 补发时会第二次进入 handlePomodoroTimeout=,重新播放闹铃、重新发通知。修复时给守卫加上 =|| isTimedOut() 判断。

教训:不要相信"前台服务的 Handler 计时完美可靠"——进程活着不代表 CPU 醒着。任何一次性的长延迟 Handler 定时器,都必须有一个周期性自我续期的保底检测兜住超时逻辑;而超时处理函数必须做幂等(守卫检查的应该是"已完成"标志,而不是"运行中"标志,因为标记超时通常不改运行状态)。诊断字段(=delayMs=)要在怀疑系统行为时提前预埋,关键时刻一锤定音。

经验总结

1. 静默失效比崩溃更可怕

Android 兼容性的最大敌人不是 FATAL EXCEPTION=,而是功能静默消失。=android:showAsAction 被忽略、隐式广播不送达、动画不播放——这些都不会出现在 logcat 里,只能靠人工测试发现。

2. 全局搜索同类问题

修了一个文件的 showAsAction 后,必须全局搜索所有同类问题。Android 迁移中的问题往往是同一模式在多处重复。我们用 grep -r "android:showAsAction" 一次性找到了 7 个文件。

3. 生命周期配对调用

onCreate() 中加 early return 时,必须检查 onDestroy() 是否使用了会被跳过的字段。Android 保证 onDestroy()stopSelf() 后仍被调用。

4. 编译通过≠运行正确

compileSdk 升级后,很多废弃 API 仍然编译通过但运行时行为改变。需要逐个在目标设备上测试。

5. 用 CI 构建而非本地构建

全程通过 GitHub Actions 构建 APK,在真机上测试。本地环境和 CI 环境的差异可能导致遗漏问题。

Android 迁移 MobileOrg 兼容性