设备调试与JUnit 5
这是周五发布帖的第一个后续内容,涵盖本次发布中影响你迭代 Codename One 应用程序工作流程(而非应用程序本身功能)的两项变更:在真实 iPhone 或 Android 设备上进行设备端调试(将 Java 视为 Java),以及针对 JavaSE 模拟器使用标准 JUnit 5。第一项是我们期待已久的功能,也是最需要解释的,因此本文大部分篇幅将围绕它展开。
将 Java 视为 Java 的设备端调试
Codename One 在严格技术意义上一直以来都支持设备端调试。你可以将 Xcode 附加到 .ipa,将 Android Studio 附加到运行的 APK,读取原生调用栈,逐步执行 Objective-C 或 ParparVM 生成的 C 代码。但你无法在 MyForm.java 中设置断点,在真实 iPhone 上命中它,然后将 Java 对象上的 Java 字段作为 Java 对象检查。此外,如果没有 Mac 介入,你无法调试 iOS 应用,因为唯一能理解二进制文件的调试器是 Xcode。你编写的 Java 与 ParparVM 生成的 C 之间的转换步骤,在设备层面留下了无法跨越的鸿沟。
PR #4999(iOS)和 PR #5012(Android)填补了这一鸿沟。从本周开始,任何支持 JDWP 的调试器(IntelliJ IDEA、jdb、VS Code 的 Java 调试器、Eclipse、NetBeans)都可以附加到 Codename One 应用程序,并将运行中的进程视为 JVM。
支持的目标
iOS
- iOS 模拟器(需要 Mac,因为 iOS 模拟器只能在 Mac 上运行)
- 通过 Wi-Fi 与开发者机器位于同一网络的真实 iPhone。你不需要本地 Mac 来调试真实 iPhone。Codename One 构建云为你执行 iOS 构建并生成签名的
.ipa;以通常的方式(TestFlight、ad-hoc 或标准的构建云安装链接)安装到你的 iPhone 上,然后通过 Wi-Fi 的 JDWP 附加功能就可以在 Linux 或 Windows IDE 上工作,就像在 Mac 上一样。Mac 仅在本地 Xcode 构建路径和运行 iOS 模拟器时需要。
Android
- Android 模拟器
- 通过 USB 连接的 Android 真机
- 通过无线
adb连接的 Android 真机
Android 附加使用标准的 adb,因此开发者机器上需要安装 Android SDK 平台工具。这些工具在 macOS、Linux 和 Windows 上均可使用,因此任何系统都适合 Android 调试。
效果展示
在 iOS 模拟器中命中断点,旁边是 IntelliJ IDEA:
[LOADING...]
与任何其他 Java 项目相同的调试工具窗口。左侧的帧面板显示完整的 Java 调用栈。变量面板将 this 和局部变量显示为 Java 值,并支持与常规 JVM 上相同的下钻操作。右侧的模拟器是真实的 iOS 应用,在断点处暂停,等待下一步执行。
各部分如何协同工作
在 iOS 上,IDE 从不直接与设备通信。CN1 调试代理是你开发者机器上运行的一个小型 Java 进程。它绑定两个 TCP 端口:一个用于 iOS 应用通过 CN1 有线协议接入,另一个用于标准 JDWP 供 IDE 连接。IDE 看到的是一个正常的远程 JVM。iOS 应用看到的是一个调试代理。代理在两者之间进行翻译,并遍历 ParparVM 的结构布局,使 Java 字段、方法调用和值在双向传输中保持清晰一致。
[LOADING...]
在 Android 上,不需要代理。Dalvik/ART 自己实现了 JDWP,因此 IntelliJ 通过 adb 内置的 JDWP 转发器直接连接到设备。Maven 插件的新目标 cn1:android-on-device-debugging 为你完成 adb 编排和端口转发。
[LOADING...]
两个平台之间一个值得提前了解的能力差异:在 Android 上,原生接口的 Impl 类是常规 Java,因此 JDWP 附加后可以像调试项目中任何其他类一样逐步执行它。在 iOS 上,Impl 是 Objective-C,JDWP 无法理解,因此你无法从 IDE 中逐步执行。你仍然可以逐步执行 Codename One 框架代码和你自己的 Java 代码,直至并包括原生接口调用,也可以检查调用返回的值;Objective-C 方法的主体是从 JDWP 角度看唯一不透明的部分。如果你需要同时逐步执行 Objective-C,可以并行附加 Xcode。
教程:IntelliJ + iOS
Codename One 原型现在在 IntelliJ 运行配置下拉菜单的“On-Device Debug”文件夹下生成了两个运行配置:CN1 Debug Proxy 和 CN1 Attach iOS。以下教程假设项目是近期从 Initializr 生成的,以便包含这些配置。如果你有较旧的项目,请使用 Initializr 生成一个新项目,并复制 .idea 目录和 Maven pom.xml 文件。
1. 启用构建提示
打开 common/codenameone_settings.properties,并取消注释原型生成的这四行:
ios.onDeviceDebug=true 使 iOS 构建切换为插桩变体。另外三个配置代理连接。第四个提示 ios.onDeviceDebug.waitForAttach=true 是启动时阻塞选项,我们建议保持开启。启用后,iOS 应用在启动时会显示“Waiting for debugger”覆盖层,并且在代理发出首次恢复之前不会执行 Display.init。这个建议主要是为了让设备端调试变体更可见。如果没有覆盖层,很容易启动一个设备端调试构建,期望调试器附加,却没有意识到它正在默默等待一个未运行的代理;也容易将设备端调试构建误认为是常规构建,然后对其性能不如发布变体感到惊讶。覆盖层排除了这两种情况。
对于物理 iPhone,proxyHost 的值应该是笔记本电脑的局域网 IP(运行 ifconfig | grep "inet " 找到),而不是 127.0.0.1。iOS 模拟器始终可以使用 127.0.0.1。
2. 构建 iOS 应用
两种路径均可:
- 本地 Xcode 构建(
mvn cn1:buildIosXcodeProject),然后从 Xcode 运行。 - 为真实设备进行云构建(
mvn cn1:buildIosOnDeviceDebug),然后安装生成的.ipa。
由于设置了构建提示,两者都会生成用于设备端调试的 iOS 二进制文件。
3. 启动代理
在 IntelliJ 中,从运行配置下拉菜单中选择 CN1 Debug Proxy,然后点击绿色的 Run 按钮(不是 bug 图标;在此配置上点击 Debug 会将 IntelliJ 附加到代理本身,这不是你想要的)。运行工具窗口显示:
当出现 [jdwp] 行时,代理已就绪。
4. 附加调试器
将运行配置下拉菜单切换到 CN1 Attach iOS,然后点击 Debug 按钮。IntelliJ 连接到 localhost:8000 并打开其标准调试工具窗口。现在你可以在 Java 代码或框架中的任何位置设置断点。
5. 启动应用
在 iOS 模拟器(从 Xcode)或连接的设备上启动 iOS 应用。如果启用了 waitForAttach=true,应用会在“Waiting for debugger”覆盖层处暂停,直到代理发出首次恢复。在 IntelliJ Debug 工具栏上点击 Resume;应用继续执行,你的断点会在应用执行时触发。
代理的运行窗口同时也是你的设备控制台。应用写入 System.out、Log.p、printf 或原生代码中的 NSLog 的任何内容都会被转发到代理,并在 CN1 Debug Proxy 运行窗口中以 [device] 前缀打印出来。这非常有用,减少了你对 Xcode 的依赖。需要注意的是,转发在代理连接建立时开始,因此在进程启动的最初毫秒内(Display.init 之前)写入的输出不一定被捕获。如果你需要从 t=0 开始的每个字节,请为特定运行附加 Xcode 的控制台。
教程:IntelliJ + Android
Android 更简单,因为不需要代理。原型在同一“On-Device Debug”文件夹下生成了两个运行配置:CN1 Android On-Device Debug(Maven,构建并安装 APK 并转发 JDWP)和 CN1 Attach Android(远程 JVM 调试,位于 localhost:5005)。
1. 启用构建提示
在 common/codenameone_settings.properties 中:
这个单一的提示将清单切换为 debuggable="true",并在此构建中关闭 R8 / Proguard。未设置提示的发布构建不受影响。
2. 运行 CN1 Android On-Device Debug
获取提示,构建 APK,将其安装到连接的设备或模拟器上,设置调试应用以等待附加,启动 Activity,将 JDWP 转发到 localhost:5005,并将 logcat --pid= 流式传输到运行窗口,带有 [device] 前缀。对于无线 adb,传递 -Dcn1.android.onDeviceDebug.wireless=,目标会在安装前执行 adb connect。Android 11+ 的 adb pair 流程和旧的 adb tcpip 流程均可使用。
3. 附加调试器
切换到 CN1 Attach Android,然后点击 Debug。IntelliJ 连接到 localhost:5005。在任何位置设置断点;它们会在执行时触发。源码解析覆盖 codenameone-core 和 codenameone-android 的源码 jar,因此框架内部或 Android 端口内的断点会解析到正确的文件。
在 Android 上,原生接口本身也是 Java,因此在你自己原生接口的 Impl 类中设置的断点就像代码中任何其他断点一样触发;你可以逐步执行实现,检查局部变量,并像其他代码一样计算表达式。
开发者指南包含完整的参考,包括无线配对流程、VS Code 和 Eclipse 的等效操作以及故障排除部分:iOS 设备端调试 和 Android 设备端调试。
何时使用(以及何时不用)
对于大多数 bug,JavaSE 模拟器仍然是快得多的循环。当 bug 是平台特定的时候,才考虑使用设备端调试:ParparVM 特定的线程问题、现代原生主题下的 iOS 专属布局故障、真实的无线电蓝牙交互、Touch ID 门控、Android 专属的清单交互,或者任何只在 iOS 后台内存压力下重现的问题。那种以前让你不得不求助于 Log.p 和重新构建循环的 bug,现在有一个调试器指向它了。
针对模拟器的 JUnit 5
本次发布的另一个变更是 JavaSE 端口中的新 JUnit 5 集成(PR #5032)。需要明确的是:这是标准的 JUnit 5。com.codename1.testing.junit 包中没有 JUnit 的分支。该包包含一小套注解和一个 CodenameOneExtension,它插入到常规 JUnit Jupiter 生命周期中。你使用 org.junit.jupiter.api.Test 编写 @Test 方法,使用 org.junit.jupiter.api.Assertions 进行断言,你的 IDE 原生测试运行器会像在其它任何 Java 项目上一样识别它们。
为什么还需要一个单独的集成?旧的 com.codename1.testing.AbstractTest 框架(由 cn1:test Maven 目标驱动)仍然存在,并且是在真实 iOS 或 Android 设备上运行测试的唯一方式(JUnit Jupiter 在 ParparVM 上不可用)。代价是 AbstractTest 测试必须在 Codename One 设备子集下编译,没有反射、没有 java.net.http、没有 java.nio.file、没有 Mockito、没有 AssertJ、没有 assertThrows。JUnit 风格的测试只在 JavaSE 模拟器 JVM 上运行,但 JVM 是常规 JVM,因此反射、Mockito、AssertJ 和参数化测试都可以使用。两种风格可以在同一个项目中共存,放在 common/src/test/java 下。你可以根据测试类来选择。运行器发现不相交的集合(cn1:test 查找 UnitTest 实现者;Surefire 查找 @Test 方法),因此 mvn install 在同一阶段运行两次且没有重叠。
一个最小测试
测试位于 common/src/test/java 下。大多数应用想要的形状是:通过模拟器使用的相同 init / start 序列启动项目的应用类,然后断言应用实际打开的窗体:
这比直接在测试中构造一个 Form 更有用,因为它执行了模拟器运行的相同启动路径。断言检查的是你的应用打开的窗体,而不是测试编写的窗体。
自然的方式是从 IntelliJ 边栏运行。点击类声明旁边的绿色图标:
[LOADING...]
结果会显示在标准的运行工具窗口中:
[LOADING...]
点击特定 @Test 方法旁边的绿色图标,只运行该方法。同样的流程也适用于 VS Code 的测试资源管理器和 Eclipse 的 JUnit 视图。
如果你喜欢命令行:
@CodenameOneTest 是类级入口点。它将模拟器扩展接入 JUnit Jupiter 生命周期,每个 JVM 启动一次 Display.init(null)(幂等,因此后续类共享同一个 Display),如果 JVM 确实是 headless 的,则跳过该类并抛出 TestAbortedException(这样没有显示器的 CI 运行器不会污染其余的测试运行)。
@RunOnEdt 通过 CN.callSerially 调度测试主体,这在主体触及 UI 状态时是你想要的。它会在 JUnit 线程上重新抛出主体中的异常,以便堆栈跟踪在 IDE 中仍然可点击。放在方法上用于单个测试,放在类上则应用于所有测试。
另外几个常见案例
一个测试仅执行简单的验证器,完全不涉及 UI:
这是“纯模型代码”的形状。没有 @RunOnEdt,没有 UI,在 JUnit 工作线程上运行,速度快。
一个测试在特定视觉配置下测试窗体:
视觉配置注解(@Theme、@DarkMode、@LargerText、@Orientation、@RTL)在 EDT 上一次批量应用,然后进行一次主题刷新,因此测试主体看到的是你所要求的精确配置的模拟器,没有闪烁。
一个测试在单个方法期间注入自定义属性:
类级别的 @SimulatorProperty 应用于类中的每个方法。方法级别覆盖类级别。使用容器 @SimulatorProperties 可以指定多个属性(包源码级别排除了 @Repeatable 的使用)。
完整的参考,包括 common/pom.xml 和 javase/pom.xml 的依赖块 YAML,以及 @Theme / @Orientation / @RTL 的详细信息,参见开发者指南中的使用 JUnit 5 测试。
总结
这就是本次发布的工作流程部分。明天的帖子将介绍本周移至核心的新平台 API:AI 和 OAuth/OIDC 是主要部分,同时还有 wifi/连接性以及一些较小的功能。
回到周刊索引。