进程级唯一秤体连接修复实施计划

For agentic workers: REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. 当前任务内联执行,不执行 Git 提交。

Goal: 将秤体改为应用进程级唯一连接,消除重复初始化、双串口读取线程、监听竞态和新页面无当前重量的问题。

Architecture: CptApplication 启动唯一 WeigherController,Controller 通过可替换的 WeigherDriver 串行访问厂商 SDK,并维护状态机、最新重量及恢复策略。Activity、Fragment、设置调试页和 Demo 页只订阅 Controller 或发送命令,不再拥有串口。

Tech Stack: Java 8、Android HandlerThread、JUnit 4、WbAiThingsSDK、MMKV。


Task 1:建立可测试的秤体领域模型

Files:

覆盖以下断言:初始化 6 秒超时;ACTIVE 5 秒静默触发软探测;探测后 3 秒触发升级恢复;5 分钟内不允许第二次自动重启;停止状态永不恢复。

核心测试形式:

@Test
public void activeConnectionBecomesSilentAfterConfiguredTimeout() {
    WeigherRecoveryPolicy policy = new WeigherRecoveryPolicy(5_000L, 3_000L, 300_000L);
    policy.onWeightReceived(1_000L);
    assertFalse(policy.shouldProbe(5_999L));
    assertTrue(policy.shouldProbe(6_000L));
}
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) sh ./gradlew :app:testDebugUnitTest --tests 'com.zhct.traybinding.hardware.weigher.WeigherRecoveryPolicyTest'

预期:因目标类尚不存在而失败。

WeigherReading 使用构造器写入不可变字段,WeigherState 定义 IDLE/INITIALIZING/ACTIVE/DEGRADED/RETRY_WAIT/FAILED/SHUTDOWN。所有时间阈值通过构造参数传入,策略不引用 Android 类。

预期:所有恢复策略测试通过。

Task 2:隔离厂商 SDK

Files:

interface WeigherDriver {
    interface Listener {
        void onInit(boolean success);
        void onWeight(int address, int state, int weightGrams);
        void onIdentifyRead(int rate);
        void onError(int errorCode);
    }

    void setListener(Listener listener);
    void init();
    void requestWeight();
    void startContinuousRead();
    void stopContinuousRead();
    void setFastFilter(int value);
    void setIdentify(int value);
    void readIdentify();
    void zero();
    void tare();
    void release();
}

将 SDK 回调转换为 Driver 回调。类注释必须说明 Weigher 是静态单例、初始化线程必须有 Looper、同一进程不得创建第二个拥有者。

Fake 记录每个方法调用次数和调用顺序,并提供主动触发初始化、重量和错误回调的方法。

Task 3:先用测试固化 Controller 幂等与时序

Files:

必须覆盖:

@Test public void repeatedStartInitializesDriverOnlyOnce() { }
@Test public void listenerIsInstalledBeforeInit() { }
@Test public void initSuccessStartsContinuousReadAndAppliesConfigOnce() { }
@Test public void subscriberReceivesLatestReadingImmediately() { }
@Test public void repeatedRecoveryRequestsAreCoalesced() { }
@Test public void hardwareFaultDoesNotEnterTightRetryLoop() { }
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) sh ./gradlew :app:testDebugUnitTest --tests 'com.zhct.traybinding.hardware.weigher.WeigherControllerTest'

Controller 提供 initialize(Context) 创建生产单例,内部创建 Driver 和 HandlerThread("weigher-control");重复初始化返回同一实例。包级测试构造器注入 Driver、控制调度器、主线程分发器、时钟、配置提供者和进程重启器,测试使用同步实现。

依赖接口作为 Controller 的包级嵌套接口定义,避免增加无职责的公共类:

interface TaskScheduler {
    void execute(Runnable task);
    void executeDelayed(Runnable task, long delayMillis);
    void cancel(Runnable task);
}

interface EventDispatcher {
    void dispatch(Runnable task);
}

interface Clock {
    long elapsedRealtime();
    long currentTimeMillis();
}

interface ConfigProvider {
    int fastFilter();
    int identifyRate();
    long lastAutomaticRestartAt();
    void recordAutomaticRestartAt(long wallClockMillis);
}

interface ProcessRestarter {
    void restart(String reason);
}

运行期静默判断使用 elapsedRealtime(),跨进程持久化的 5 分钟重启限流使用 currentTimeMillis(),避免设备重启后 elapsed realtime 清零导致时间域不一致。

公共 API:

public void start();
public static WeigherController initialize(Context context);
public static WeigherController getInstance();
public void addListener(Listener listener, boolean replayLatest);
public void removeListener(Listener listener);
public WeigherState getState();
public WeigherReading getLatestReading();
public boolean hasValidReading();
public void requestWeight();
public void retryFromUser();
public void zero();
public void tare();
public void readIdentify();
public void setIdentify(int value);

回调 UI 前复制监听器快照,不持有 Controller 锁。重量序号由 Controller 单调递增;latestReading 使用 volatile 或同步访问保证可见性。

预期:目标测试全部通过,Fake Driver 的调用序列为 setListener → init → requestWeight → startContinuousRead → setFastFilter → setIdentify

Task 4:实现有边界的健康检查

Files:

验证 5 秒静默只发送一次软探测;探测后收到重量恢复 ACTIVE;探测后 3 秒仍无数据只请求一次受控重启;自动重启被 5 分钟限流。

使用具名常量:

private static final long INITIALIZATION_TIMEOUT_MILLIS = 6_000L;
private static final long SILENCE_TIMEOUT_MILLIS = 5_000L;
private static final long PROBE_GRACE_MILLIS = 3_000L;
private static final long AUTO_RESTART_WINDOW_MILLIS = 300_000L;
private static final int MAX_INITIALIZATION_ATTEMPTS = 2;

运行中静默不无限执行 SDK 重建;软探测失败后调用注入的 ProcessRestarter。重启次数写入 MMKV,超过频率进入 FAILED

连接错误可恢复;零位与传感器错误进入硬件故障;鉴别率错误恢复默认值一次;未知错误保留原始错误码。

预期:无并发恢复、无无限重试、状态转换符合设计。

Task 5:接入 Application

Files:

在 MMKV 和 ResourcesUtils 初始化之后,当 Constants.APP_MODEL_TAG == 2 时依次调用 WeigherController.initialize(this)WeigherController.getInstance().start()

确认 start()只向 weigher-control 投递任务,Application.onCreate()不执行串口 RootCmd 或阻塞等待。

JAVA_HOME=$(/usr/libexec/java_home -v 1.8) sh ./gradlew :app:assembleDebug

Task 6:迁移 WeightMainActivity

Files:

onStart()只注册监听并读取最新快照;onStop()取消监听。onDestroy()不关闭进程级秤体。

删除旧 SensorScale 字段、openWeightSensorScale()、两套初始化实现、重新初始化弹窗和空 SensorScale.closeScale()逻辑。

每包继续更新序号与稳定性会话;重量变化时只调用一次 Fragment 回调。新增 registerWeightValueChangeCallback(listener, replayLatest)unregisterWeightValueChangeCallback(listener),旧 Fragment 不能清除新 Fragment。

进入打餐中前检查 Controller 快照;无有效重量时清空当前扫码状态并提示用户,不记录 Integer.MAX_VALUE

扫码器由 Activity 自身生命周期启动,不再依赖秤体 onInit(true)

Task 7:修复五个 Fragment 的订阅时序

Files:

onStop()使用监听器实例取消注册。

onResume()中先获取起始重量和序号,再注册并请求回放;onStop()取消注册。

重量保持不变时切换页面,打餐中页面应立即显示 0g,不得显示整盘重量或空值。

Task 8:迁移设置调试页与 Demo 页

Files:

连接按钮改为状态检测/用户重试;读取重量、置零、去皮和鉴别率命令转给 Controller;弹窗关闭只取消监听。

不再创建 SensorScale、遍历设备或直接关闭串口。自动读取开关只控制调试 UI 是否刷新。

移除直接 SensorScale 初始化和关闭逻辑;若 Demo 不再使用,则从正式 Manifest 移除入口。

rg -n 'new SensorScale|Weigher\.(init|unInit|startContinuousRead|stopContinuousRead)' app/src/main/java

预期:生产调用只存在于 AithingsWeigherDriver

Task 9:自动化验证

JAVA_HOME=$(/usr/libexec/java_home -v 1.8) sh ./gradlew :app:testDebugUnitTest --tests 'com.zhct.traybinding.hardware.weigher.*'
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) sh ./gradlew :app:testDebugUnitTest
JAVA_HOME=$(/usr/libexec/java_home -v 1.8) sh ./gradlew :app:assembleDebug
git diff --check
git status --short

确认没有过程文档进入源码仓库,没有无说明的魔法时间值和串口路径。

Task 10:真机验收

Task 11:归档变更记录

Files: