29、SystemUI的测试与调试:使用dumpsys systemui命令,SysUI的单元测试与UI测试

SystemUI 这层,说白了就是 Android 系统的“脸面”。

状态栏、通知栏、导航栏、锁屏……用户每天跟它打交道的时间比跟任何 App 都长。但问题也来了——这玩意儿一旦出 Bug,用户感知极强。状态栏不显示、通知拉不下来、导航键没反应,随便一个都能让用户抓狂。

所以,SystemUI 的测试与调试,是每个做框架层开发的工程师必须掌握的硬功夫。今天我就把这块内容掰开了讲,从命令行调试到单元测试,再到 UI 自动化测试,一条龙说清楚。

29.1 调试利器:dumpsys systemui

先说说我最常用的调试命令——dumpsys systemui

你想想看,SystemUI 是一个系统级进程,你不能像普通 App 那样随便加 Log 然后跑一遍。很多时候,Bug 是偶现的,或者发生在用户手上。这时候,dumpsys 就是你的救命稻草。

核心用法:

adb shell dumpsys systemui

这条命令会输出 SystemUI 当前的所有状态信息,包括通知列表、状态栏图标、亮度滑块位置、导航模式等等。

我个人习惯把它重定向到本地文件,然后慢慢分析:

adb shell dumpsys systemui > sysui_dump.txt

29.1.1 常用子命令

dumpsys systemui 其实支持很多子命令,我挑几个最实用的:

子命令 作用 我常用的场景
dumpsys systemui -t 10 设置超时时间(秒) SystemUI 卡死时,防止 dump 命令也卡住
dumpsys systemui visibility 查看各 UI 组件的可见性 排查状态栏图标消失问题
dumpsys systemui notifications 查看通知列表详情 通知不显示时,看是不是被拦截了
dumpsys systemui statusbar 状态栏内部状态 看图标排序、暗色模式状态
dumpsys systemui nav 导航栏状态 手势导航 vs 三键导航切换问题

小技巧: 如果你只想看某个模块,可以用 dumpsys systemui | grep -A 20 "Notifications" 来过滤。我经常这么干,省得在几千行输出里翻来翻去。

29.1.2 实战:用 dumpsys 定位通知不显示问题

有一次,用户反馈说某个 App 的通知收不到。我远程连上设备,先跑了一发:

adb shell dumpsys systemui notifications

输出里有一段关键信息:

NotificationList:
  packageName=com.example.app
  key=0|com.example.app|123|null|10157
  mPriority=0
  mUserLockedFields=0
  mShowBadge=true
  mAlerted=true
  mInterruptionFilter=3
  mSuppressedVisualEffects=0

嗯,这里要注意——mInterruptionFilter=3 表示“重要程度为中等”,按理说应该显示。但再看下去,发现 mSuppressedVisualEffects 虽然为 0,但 mUserLockedFields 也是 0,说明用户没有手动屏蔽。

问题出在哪?我继续往下翻,发现这个通知的 mGroupKey 和另一个通知相同,但 mGroupAlertBehavior 被设成了 GROUP_ALERT_SUMMARY。说白了,这个通知被合并到群组里了,只显示摘要,不显示单独条目。

找到根因后,我让 App 开发改了通知渠道的 setGroupAlertBehavior 设置,问题解决。

避坑指南: 我曾经遇到过 dumpsys systemui 输出为空的情况。后来发现是 SystemUI 进程挂了,需要先 adb shell ps -A | grep systemui 确认进程是否存在。如果进程不在,先 adb shell am start-foreground-service com.android.systemui/.SystemUIService 重启。

29.2 SystemUI 的单元测试

SystemUI 的单元测试,和普通 App 的单元测试有相似之处,但也有不少坑。毕竟它依赖系统服务、Binder 调用、WindowManager 等底层组件。

29.2.1 测试框架选型

在 AOSP 里,SystemUI 的单元测试主要用 Robolectric + JUnit4。为什么不用 Instrumentation 测试?因为 Instrumentation 需要真机或模拟器,跑一次太慢了。Robolectric 可以在 JVM 上跑,速度快得多。

我建议你在 packages/apps/SystemUI/tests 目录下写测试,这是官方推荐的位置。

29.2.2 一个典型的单元测试例子

假设我们要测试 NotificationListener 的一个方法:

@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE, sdk = 30)
public class NotificationListenerTest {

    private NotificationListener mListener;
    private TestableNotificationManager mTestManager;

    @Before
    public void setUp() {
        mTestManager = new TestableNotificationManager();
        mListener = new NotificationListener(mTestManager);
    }

    @Test
    public void testOnNotificationPosted_shouldUpdateBadge() {
        // 准备一个模拟通知
        StatusBarNotification sbn = createTestNotification("test_pkg", 1);

        // 执行
        mListener.onNotificationPosted(sbn, null);

        // 验证
        assertTrue(mTestManager.isBadgeUpdated());
        assertEquals("test_pkg", mTestManager.getLastBadgePackage());
    }

    private StatusBarNotification createTestNotification(String pkg, int id) {
        // 构造一个模拟的 StatusBarNotification
        // 这里省略具体实现
    }
}

你看,这里用了 TestableNotificationManager 来替代真实的 NotificationManager。这就是依赖注入的好处——把依赖抽出来,测试时换成 Mock 或 Test Double。

个人经验: 写 SystemUI 单元测试时,最难搞的是那些 @Inject 的依赖。我建议你用 Dagger 的 TestModule 来替换生产环境的 Module。比如:

@Module
public class TestSysUIModule {
    @Provides
    public NotificationManager provideNotificationManager() {
        return mock(NotificationManager.class);
    }
}

29.2.3 测试系统服务调用

SystemUI 经常调用 WindowManagerStatusBarManager 等系统服务。在 Robolectric 里,你可以用 Shadow 来模拟这些服务:

@Test
public void testExpandNotificationsPanel_callsWindowManager() {
    ShadowWindowManager shadowWm = Shadows.shadowOf(
        (WindowManager) RuntimeEnvironment.application.getSystemService(Context.WINDOW_SERVICE));

    mNotificationPanel.expand();

    // 验证 WindowManager 是否收到了添加窗口的请求
    assertTrue(shadowWm.getAddedWindowTokens().size() > 0);
}

嗯,这里要注意——Robolectric 的 Shadow 对象不是万能的。有些系统服务的 Shadow 实现不完整,这时候你就得自己写 Mock 了。

29.3 UI 测试:Espresso 与 UiAutomator

单元测试只能测逻辑,UI 交互还得靠 UI 测试。SystemUI 的 UI 测试,我主要用两种框架:

  • Espresso:适合测单个 Activity 或 Fragment 内的 UI 交互
  • UiAutomator:适合跨 App 的测试,比如从通知栏点击跳转到设置

29.3.1 用 Espresso 测试通知面板

假设我们要测试“下拉状态栏,点击通知,跳转到对应 App”:

@Test
public void testClickNotification_opensApp() {
    // 先发送一个测试通知
    NotificationManager nm = (NotificationManager) 
        InstrumentationRegistry.getInstrumentation().getTargetContext()
            .getSystemService(Context.NOTIFICATION_SERVICE);
    
    Notification notification = new Notification.Builder(
        InstrumentationRegistry.getTargetContext(), "test_channel")
        .setContentTitle("测试通知")
        .setSmallIcon(android.R.drawable.ic_dialog_info)
        .build();
    
    nm.notify(1, notification);

    // 下拉状态栏
    UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
    device.openQuickSettings();

    // 点击通知
    onView(withText("测试通知")).perform(click());

    // 验证是否跳转到了目标 Activity
    intended(hasComponent(TestActivity.class.getName()));
}

这段代码看起来简单,但实际跑起来坑不少。我遇到过最典型的问题是——通知面板展开后,onView 找不到那个通知。为什么?因为通知列表是 RecyclerView,需要滚动才能看到。

解决办法是加一个 scrollTo()

onView(withText("测试通知"))
    .perform(scrollTo(), click());

29.3.2 用 UiAutomator 测试跨 App 场景

Espresso 只能测自己进程内的 UI。如果要测“从通知栏点击,跳转到浏览器”,就得用 UiAutomator:

@Test
public void testNotificationClick_opensBrowser() throws Exception {
    // 发送通知
    // ... 同上 ...

    // 下拉通知栏
    UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
    device.openNotification();

    // 等待通知出现
    UiObject notification = device.findObject(new UiSelector()
        .textContains("测试通知"));
    assertTrue("通知未显示", notification.waitForExists(3000));

    // 点击通知
    notification.click();

    // 等待浏览器打开
    UiObject browserTitle = device.findObject(new UiSelector()
        .packageName("com.android.chrome")
        .textContains("测试页面"));
    assertTrue("浏览器未打开", browserTitle.waitForExists(5000));
}

避坑指南: 我曾经在 CI 上跑 UiAutomator 测试,发现 device.openNotification() 在某些设备上不生效。后来查了源码,发现这个方法依赖 StatusBarManagerexpandNotificationsPanel(),而有些定制 ROM 把这个接口改了。我的建议是:如果 openNotification() 不行,试试用 device.swipe() 模拟手势下拉。

29.4 知识体系总览

我把 SystemUI 测试与调试的核心内容整理成了一张图,方便你对照着看:

SystemUI 测试与调试知识体系 调试 • dumpsys systemui • 子命令过滤 • 进程状态检查 • 日志分析 • 偶现 Bug 定位 单元测试 • Robolectric + JUnit4 • 依赖注入 (Dagger) • Shadow 系统服务 • Test Double 模式 • 快速反馈 UI 测试 • Espresso • UiAutomator • 通知面板测试 • 跨 App 跳转 • 手势模拟 三者结合,覆盖 SystemUI 全链路质量

这张图把调试、单元测试、UI 测试三个维度串起来了。你想想看,实际工作中,这三个环节是互相补充的:

  • 开发阶段,用单元测试快速验证逻辑
  • 集成阶段,用 UI 测试验证交互流程
  • 线上问题,用 dumpsys 和日志定位根因

缺了任何一个环节,SystemUI 的质量都很难保证。我个人习惯是:每次提交代码前,先跑一遍单元测试,再跑几个关键的 UI 测试用例。虽然 CI 上也会跑,但本地跑一遍能省去来回提交的等待时间。

最后说一句: SystemUI 的测试,难的不是写代码,而是搭建测试环境。尤其是那些依赖系统服务的模块,Mock 起来特别麻烦。我的建议是——先从 dumpsys 入手,把调试工具用熟,再逐步建立单元测试和 UI 测试的覆盖。别想一口吃成胖子。


公众号:蓝海资料掘金营,微信deep3321