
简介Java2OP for XE10 是一款面向 Embarcadero Delphi 开发者的跨语言桥接工具主要用于把 Java 的 jar 包解析并转换为 Delphi 可引用的 PAS 单元文件解决在 Delphi 项目中复用 Java 类库、减少重复改写的问题适合需要调用既有 Java 资源或进行跨平台集成的中高级开发者。压缩包共 8 个文件包含可执行程序 Java2OP.exe、bootclasses.jar 与 doclava.jar、bootclasses.xml、default.inc、README.txt 及日志缓存等文本文件整体约 16.4MB兼顾转换运行、模板配置与使用说明。已有 260 人学习下载。借助其中的工具主程序、启动类清单与配置模板读者可完成 jar 元数据解析、类与方法声明生成并在 XE10.2 环境中按说明调整 PAS 文件、处理类型映射和调用约定为 Java 库接入 Delphi 工程提供可操作的参考。1. 只给 jar 的 Android SDKDelphi 侧怎么接上话上一个跨平台项目里合作方给了一份 Android SDK 压缩包里面只有 jar、几个.so和一份 PDF通篇没提 Delphi 一个字。官方预置的桥接单元只覆盖 Android 标准库别人家的业务类它一个都不认识你在 Object Pascal 里写com.xxx.Foo编译器连名字都没见过。java2op就是补这一步的工具它读 jar 里的 class 文件把类、方法、字段签名翻译成一套 Object Pascal 接口声明产出一个能uses的单元。社区里有人按用途叫它 jar2pas 或 class2pas本质是同一类活。这篇对准的是 RAD Studio 10.xXE10 系列上要接第三方 Android Java 库的人从「这批接口怎么长出来」一直讲到「接进工程怎么验证它真通了」。2. 转换链路拆开看jar 里的 class 是怎么变成可用的 .pas 的2.1 为什么不能直接调用 jarJava 和 Object Pascal 之间隔着一层 JNI所有跨语言的调用最终都落到JNIEnv指针上的一组固定函数FindClass、GetMethodID、CallObjectMethod之类。Delphi 的 Android 目标平台把这个过程包装成了「接口 代理」每个 Java 类在 Pascal 侧对应一个接口方法体是空的实际的调用由TJavaGenericImport在运行时注册的代理完成。java2op干的活只在这一层的一半——它不生成任何调用逻辑只负责把「Java 那边长什么样」翻译成 Pascal 能编译的声明。产物里没有任何算法全是类型、签名、方法名。// java2op 生成单元的典型骨架手写还原版 type TJScannerClass interface(JObjectClass) [{B1E4F0A2-...}] function init: TJScanner; cdecl; function create(act: JActivity): TJScanner; cdecl; end; [JavaSignature(com/example/sdk/device/Scanner)] TJScanner interface(JObject) [{7C3A9D11-...}] function open: Boolean; cdecl; procedure close; cdecl; function readTimeout: Integer; cdecl; end; TJScannerClass class(TJavaGenericImportTJScannerClass, TJScanner) end;三个类型是一组。TJScannerClass是静态侧——构造函数和静态方法挂在这里通过TJScanner.JavaClass拿到。TJScanner是实例侧实例方法挂在这里。最下面那个同名 class 是泛型导入器作用是把这对接口登记进TJavaGenericImport的注册表FindClass(com/example/sdk/device/Scanner)这一步就是它在运行时替你做掉的。理解这一点很关键java2op编出来的.pas本身不会唱歌它只是一张「地图」。地图画错了运行期的FindClass就会返回 nil随后就是那句经典的Java class is not found。2.2 工具位置和运行前提java2op.exe跟着 RAD Studio 一起装落在安装目录的bin下和dcc32.exe、adb.exe它们在同一个池子里。64 位版本另有一个bin64目录都行日常用 32 位那个就够了。# Windows 下先确认这个文件在 dir Studio安装目录\bin\java2op.exe注意版本号目录按你实际装的那个来22.0、23.0这类数字每家机器不一样别照抄路径。运行前提有三条缺一条就白跑第一Android SDK 的platforms目录得在手上。java2op要读android.jar来解析JObject、JString、JActivity这些基础类型的继承链没有它连JObject的父类都找不到。第二第三方 jar 要解压友好。有些商用 SDK 的 jar 经过了混淆或者裁剪类名被压成a.b.c转换出来的接口名也是TJa、TJb这种看着像乱码但能用——这种情况下你要自己对照一份未混淆的 API 文档。第三命令行环境里java可用。java2op内部会拉起 JVM 读 class 文件JDK 版本太新比如 17 以上时偶发读不了老版本 class换 8 或 11 通常就好了这个踩过一次。2.3 先拿标准库类建立基线不要一上来就转自己的 jar。先转一个标准库类确认工具链本身是通的。echo off set JAVA2OPStudio安装目录\bin\java2op.exe set SDK_DIRC:\Android\sdk\platforms\android-33 %JAVA2OP% -D%SDK_DIR% -oD:\proj\android\bridge java.util.ArrayList echo exit code: %ERRORLEVEL% pause这段命令干了三件事set JAVA2OP把带空格的路径包进变量避免每处都写引号-D指定android.jar所在的平台目录工具会去这个目录里找基础类定义-o指定输出目录。最后的java.util.ArrayList是目标类用全限定名点号分隔和你在 Java 源码里import的写法一致。跑之前把输出目录先建好。java2op不会替你创建目录路径不存在时它不报错静默结束%ERRORLEVEL%还是 0——这是最容易让人怀疑人生的一个点。跑完去D:\proj\android\bridge下看应该出现一个.pas和如果加了-H一个.hpp。文件名跟着 Java 包名走java.util会落到Androidapi.JNI.Java.Util.pas。打开它头部会有一长串uses中间是接口声明从下往上翻能看到TJArrayList的add、get、size这些熟面孔。这一步跑通说明 SDK 路径、输出目录、JVM 环境三件都对上了后面换自己的 jar 只是替换最后那个类名参数的事。3. 三组参数定生死-D、-o 和类名清单怎么配3.1 -D 搜索路径少给一个目录就等着报错-D是这个工具里最容易翻车的参数没有之一。它接受一个用分号分隔的目录列表工具会在这些目录里依次找目标类以及目标类引用到的所有类型。最朴素的理解方式你在 Java 里写public void setListener(OnScanListener l)转换器必须能找到com.example.sdk.callback.OnScanListener的定义否则它只能把这个参数降级成JObject生成的 Pascal 方法签名就成了procedure setListener(l: JObject)。编译能过运行也能跑但你在 Pascal 侧就彻底失去了类型提示也不知道该传什么进去。# 典型的多路径写法两个目录用分号隔开 %JAVA2OP% -D%SDK_DIR%;%PROJ%\android\libs -o%OUT% com.example.sdk.device.Scanner我把-D归纳成三份清单每次转换前对着核一遍清单项内容漏掉的后果平台基础包platforms\android-XX目录JObject、JString等父类解析失败整份产物报废目标 jar 目录你要转的那个 jar 所在目录cannot find class工具静默退出依赖 jar 目录目标 jar 的compileOnly依赖参数被降级成JObject类型信息丢失关于第二三项有个野路子值得记一笔工具对目录和 jar 文件的处理有时不一致。我遇到过一次把目录塞给-D它死活找不到类改成直接指向那个 jar 文件的完整路径就通了。所以排查到卡住的时候两种写法都试一遍别死磕一种。路径里的空格是个隐形杀手。C:\Program Files\...这种不包引号cmd会把空格当分隔符工具收到的是一个截断的路径然后安静地什么也不做。所以-D后面的整段参数一定要整体加引号用-D路径的形式而不是-D 路径。3.2 -o 输出目录与单元名规则-o只有一个要求目录必须已存在。前面提过不存在时工具不报错、不创建、直接返回 0你以为转换成功了去目录里一看空的。单元名的生成规则有迹可循。工具把 Java 包名的每一段首字母大写用点连接前面统一加Androidapi.JNI.前缀java.util.ArrayList - Androidapi.JNI.Java.Util.pas android.bluetooth.BluetoothAdapter - Androidapi.JNI.Bluetooth.pas com.example.sdk.device.Scanner - Androidapi.JNI.Com.Example.Sdk.Device.pas第三行这种长名字会带来一个实际问题不同 jar 里如果有同名包但不同内容生成出来的单元会互相覆盖。我一般把输出目录按 jar 分组bridge\sdk_a\、bridge\sdk_b\在工程里按需引入不混在一起。嵌套类的命名要单独说。Java 里的内部类com.example.sdk.Scanner$Config在命令行里必须写成带$的形式转出来的 Pascal 名字是TJScanner_Config用下划线代替$。如果你在命令行里写成com.example.sdk.Scanner.Config工具会当成一个不存在的独立类报找不到。# 嵌套类要带 $Windows cmd 里 $ 不需要转义 %JAVA2OP% -D%SDK_DIR%;%JAR_DIR% -o%OUT% com.example.sdk.device.Scanner$Config3.3 需要桥接的类清单用脚本固化手敲命令行只适合验证阶段。一旦类清单超过五个就会出现「上次转了哪些忘了」「这次多转一个少转一个」的问题而这类问题在编译期完全不报错等到运行期才发现某个回调接口没桥接。我的做法是把它做成一个python脚本清单、路径、参数全部写在文件里改一次代码提交一次import subprocess from pathlib import Path JAVA2OP rStudio安装目录\bin\java2op.exe SDK_DIR rC:\Android\sdk\platforms\android-33 JAR_DIR rD:\proj\android\libs OUT_DIR Path(rD:\proj\android\bridge\sdk_a) # 需要桥接的类全限定名一行一个嵌套类用 $ 连 TARGETS [ com.example.sdk.device.Scanner, com.example.sdk.device.Scanner$Config, com.example.sdk.callback.OnScanListener, ] OUT_DIR.mkdir(parentsTrue, exist_okTrue) cmd [ JAVA2OP, f-D{SDK_DIR};{JAR_DIR}, # 分号分隔别用逗号 f-o{OUT_DIR}, *TARGETS, ] print(RUN:, .join(cmd)) r subprocess.run(cmd, capture_outputTrue, textTrue) print(exit:, r.returncode) print(r.stdout, r.stderr) # 产物数量对不上就说明有问题这里做一次断言 generated list(OUT_DIR.glob(*.pas)) print(generated:, [p.name for p in generated])参数说明JAVA2OP用原始字符串r...是为了让反斜杠原样保留f-D{SDK_DIR};{JAR_DIR}里分号是 Windows 的路径分隔符换成冒号在 Windows 上不生效subprocess.run带capture_outputTrue是为了把工具的 stdout 抓下来——这个工具的错误信息经常只出现在 stdout 而不是 stderr只看stderr会漏mkdir(parentsTrue, exist_okTrue)补上了工具不建目录的短板。最后那段glob断言是我加的后悔药。工具有时候会「部分成功」命令行里写了五个类输出目录里只有三个.pas退出码还是 0。有了这行断言至少能在脚本层面把这种半成品打回去。4. 生成的单元接进工程接口三件套、类型映射和打包验证4.1 接口三件套的正确用法生成物拿进工程后大部分人的疑问集中在「TJxxx和TJxxxClass到底哪个能 new」。答案取决于你要调的东西是静态还是实例。想构造一个对象、或者调一个静态方法走.JavaClassuses Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.JNI.Com.Example.Sdk.Device; // java2op 生成的单元 var Scanner: TJScanner; Cfg: TJScanner_Config; begin // 无参构造 Scanner : TJScanner.JavaClass.init; // 带参构造生成物里会有多个 overload 的 init Cfg : TJScanner_Config.JavaClass.init(5000); end;init是构造函数在生成物里的统一名字重载了几个版本取决于 Java 侧有几个public构造函数。你在 IDE 里对init按CtrlSpace能看到全部重载参数类型按顺序对得上就能编过。调实例方法直接在接口变量上点var ok: Boolean; begin ok : Scanner.open; // 落到 com/example/sdk/device/Scanner.open()Z Scanner.close;这里没有「第一次调用要初始化」这一说接口变量在拿到的那一刻 JNI 对象引用已经在里面了。但有个隐含前提这个对象引用必须是有效的。如果init内部抛了 Java 异常Pascal 侧拿到的可能是个内部状态不完整的接口后续调用会连带炸掉。所以重要对象的构造最好包在try..except里。静态方法同样走JavaClass。验证整套链路通没通用标准库里的java.lang.System是最省事的uses Androidapi.Helpers, // StringToJString / JStringToString 在这里 Androidapi.JNI.JavaTypes; procedure TFormMain.Button1Click(Sender: TObject); var S: JString; begin S : TJSystem.JavaClass.getProperty(StringToJString(java.version)); ShowMessage(JStringToString(S)); end;StringToJString和JStringToString是这对函数Java 侧String在 Pascal 侧是JString接口不能直接当string用必须过桥。这两个函数早期版本在FMX.Helpers.Android里从 RAD Studio 10 开始统一挪到了Androidapi.Helpers老代码搬过来的时候uses要改。4.2 Java 类型到 Pascal 类型的映射对照生成物里的类型不是随便取的有一套稳定映射。看懂这张表你就知道生成的接口为什么长那样也知道该传什么进去Java 类型生成的 Pascal 类型使用要点int/short/byteInteger/SmallInt/ShortInt直接传值无需转换longInt64注意和时间戳、ID 打交道时别用Integer接booleanBoolean一一对应StringJString必须用StringToJString造出来ObjectJObject泛型擦除后的兜底类型byte[]TJavaArrayByte用.Data[0]取首地址Len取长度ListTJList元素取出来是JObject要自己往下转回调接口TJxxx需要用TJavaOwnedCallback之类包装最容易翻车的是byte[]。很多人以为它对应TArrayByte直接SetLength然后往接口里传编译期就会拦下来。正确的是这样var Buf: TJavaArrayByte; I: Integer; begin Buf : TJavaArrayByte.Create(1024); try // 从 Pascal 数组拷贝到 Java 数组 for I : 0 to 1023 do Buf.Data[I] : Byte(I and $FF); // 传给 Java 方法形参是 TJavaArrayByte Scanner.write(Buf); finally Buf.Free; // 它是记录但持有 JNI 全局引用必须显式释放 end; end;TJavaArrayT不是普通的动态数组它内部持有一个 JNI 全局引用。漏掉Free的表现是长时间运行后内存持续上涨每次 GC 都回收不掉——这种问题在测试机上跑十分钟根本发现不了上真机跑一小时才暴露属于典型的「上线才翻车」。JList这类泛型容器在生成物里是擦除后的形态取元素时统一是JObject要往下转得手动强转var L: JList; Item: JObject; begin L : Scanner.getDevices; // 返回 JList if (L nil) and (L.size 0) then begin Item : L.get(0); // JObject // 已知元素实际是 TJScanner_Config直接用接口强转 ShowMessage(IntToStr(TJScanner_Config(Item).getTimeout)); end; end;强转这一步编译期完全不检查转错了运行时就是一个Invalid cast或者更糟——直接 SIGSEGV。稳妥的做法是在 Java 侧补一层 wrapper 方法返回具体类型绕过泛型擦除。4.3 jar 打包进 APK 之后才算真的接上.pas能编译通过只说明接口声明对上了不说明 jar 跟着进包了。第三个环节是打包这一步失败的现象是运行期NoClassDefFoundError或者ClassNotFoundException——编译期一切正常。.so文件的处理相对简单按 ABI 丢进部署目录Project Deployment里配Remote Path为library\lib\abi构建时会自动打包。jar 的情况复杂一些需要确保它被纳入 dex 生成流程。不管走哪种配置方式最后都要做一次实物验证。APK 本质是个 zip把classes.dex抽出来在二进制里搜包名import zipfile with zipfile.ZipFile(rD:\build\MyApp.apk) as z: names [n for n in z.namelist() if n.startswith(classes) and n.endswith(.dex)] print(dex files:, names) found False for n in names: blob z.read(n) # Java 包名在 dex 里用斜杠分隔 if bcom/example/sdk/device/Scanner in blob: found True print(hit in, n) print(SCANNER PRESENT:, found)验证点的选择有讲究搜包路径com/example/sdk/device/Scanner比搜简单的类名靠谱因为 dex 里存的是全限定路径的字符串常量类名单独出现可能和其他库撞。classes2.dex这类分包也要一起扫只扫classes.dex会漏。这一步跑出False就不要往下调试 Delphi 代码了问题百分百在打包环节。5. 排查与避坑五条按现象倒推的记录5.1 双击 exe 后窗口一闪而过什么都没输出现象命令行里执行完控制台窗口立刻关闭输出目录里没有任何新文件%ERRORLEVEL%是 0。原因有两个层面。表层是cmd执行完就关窗口看不到任何输出深层是参数拼错时工具不报错直接退出。最常见的是-o后面忘了跟引号路径里有空格被截断工具收到一个不存在的目录干脆什么都不做。解决所有调用都包一层批处理末尾加pause和echo exit code: %ERRORLEVEL%。另外在命令行里逐段缩短参数——先只给-D和-o和一个最简单的标准库类跑通了再往上加第三方路径。一次加一个变量比一次全塞进去快得多。5.2 生成的 .pas 里方法参数全是 JObject现象TJScanner里setListener、setConfig这些方法的形参全变成了JObjectJava 文档里明明写的是具体类型。原因-D的搜索路径里缺了定义这些类型的 jar工具解析不到降级处理成JObject。注意这里工具不会报错它只是默默降级——这是这个工具最坑的地方没有之一。解决把-D补全。顺序是先加 SDK 平台目录再加目标 jar 所在目录最后加它的所有compileOnly依赖目录。补完之后重新转换新产物和旧产物做一次 diff看JObject有没有变回具体类型。我习惯在脚本里加一条检查产物里JObject出现的次数超过某个阈值就报警。5.3 编译报 Undeclared identifier但类型明明在生成的文件里现象E2003 Undeclared identifier: TJScanner但你打开生成的那个.pasTJScanner就在第 40 行。原因uses顺序或者单元可见性。生成物内部的uses可能引用了你工程里没有的单元比如它uses Androidapi.JNI.JavaTypes而你的搜索路径没包含$(BDSLIB)\$(Platform)\release那么加载这个单元时它的依赖先失败了报错位置却指向你引用的那一行。解决先单独编译生成的那个单元在 IDE 里右键Compile让编译器把它的依赖问题先暴露出来。依赖补齐之后再编你的主单元。工程搜索路径里至少要有$(BDSLIB)\$(Platform)\release这是标准库桥接单元的默认位置。5.4 编译通过、安装成功一调用就 NoClassDefFoundError现象APK 装上了界面也起来了点到某个按钮崩logcat 里是java.lang.NoClassDefFoundError: com/example/sdk/device/Scanner。原因jar 没进 dex。Pascal 侧的接口声明是从 jar 里读出来的跟 jar 在不在包里没关系所以编译期完全正常。运行期 JVM 找类找不到就这一条路。解决按 4.3 的脚本把 APK 当 zip 打开搜包路径。搜不到就往打包配置里查搜到了还报错那就是类加载器的问题通常是 jar 里的类用了反射加载其他类依赖没跟全。5.5 调用返回的字符串是乱码或者空现象某个返回JString的方法JStringToString之后拿到的是空串或者一堆问号。原因可能是 Java 侧返回了null。JStringToString(nil)在不同版本上行为不一致有的返回空串有的直接访问违规。也可能是编码问题Java 侧的byte[]被当成String传出来了。解决先判空再转换写成if S nil then Result : JStringToString(S) else Result : ;。编码问题则要回到 Java 侧的接口定义看它返回的到底是String还是byte[]——这两者生成的 Pascal 类型完全不同byte[]走TJavaArrayByte转换要用TEncoding.UTF8.GetString那一套。6. 回归验证的固定姿势一条命令、一个 Demo、一次 diff升级 RAD Studio 版本是这类工程最容易出事的时刻。11 升 12 那种大版本跳桥接单元的内部结构偶尔会变——TJavaGenericImport的接口形态、Androidapi.Helpers里的辅助函数签名、TJavaArray的释放语义任何一处改动都会让老生成物编不过或者编过了跑不对。等业务代码报错再去追成本高得多。我的做法是把转换过程固化成一条可重复执行的命令每次升版本先跑它再跑一个最小 Demo最后对产物做 diff。三件事加起来十五分钟能挡掉后面几天的排查。echo off REM java2op_regen.cmd —— 固化的转换入口放在工程根目录 set ROOT%~dp0 set JAVA2OPStudio安装目录\bin\java2op.exe set SDK_DIRC:\Android\sdk\platforms\android-33 set JAR_DIR%ROOT%android\libs set OUT%ROOT%android\bridge\sdk_a if not exist %OUT% mkdir %OUT% %JAVA2OP% -D%SDK_DIR%;%JAR_DIR% -o%OUT% ^ com.example.sdk.device.Scanner ^ com.example.sdk.device.Scanner$Config ^ com.example.sdk.callback.OnScanListener echo exit%ERRORLEVEL%^是 cmd 的续行符作用是把一条长命令折成多行便于阅读注意^后面不能有空格。%~dp0取的是脚本自身所在的目录带尾部反斜杠这样整个脚本可以整体搬动而不用改路径。跑完之后做 diff。Windows 上用fc或者丢进 git 看变更fc /N bridge_old\Androidapi.JNI.Com.Example.Sdk.Device.pas bridge_new\Androidapi.JNI.Com.Example.Sdk.Device.pas diff.txtdiff 出来要重点看三类变化方法签名里的类型有没有变JObject变具体类型是好事反过来是坏事、GUID 有没有变变了说明接口布局改了需要重新编译所有引用方、uses列表有没有新增单元新增通常意味着依赖变了要确认你工程里也能找到。Demo 那一环我固定用java.lang.System.getProperty验证就是 4.1 里那段代码。它足够简单只依赖标准库而且同时走通了「静态方法调用」「JString 双向转换」「跨语言返回值」三条路径。升版本后这段能出正确结果说明桥接基础设施是好的后面再出问题就往业务 jar 上找。这套流程是从一次翻车里长出来的。当时升完版本只编了一下就打包发给测试结果测试机上扫码功能全程静默失败logcat 里刷屏的是NoSuchMethodError。回头查才发现新版生成器把某个重载方法的参数顺序调整了原来的open(timeout, retry)变成了open(retry, timeout)Pascal 侧编得过因为两个参数都是Integer运行时按旧顺序传进去Java 侧收到一个 5000 次重试、5 毫秒超时的组合整个扫描线程卡死。这个 bug 在编译期、链接期、安装期都不会报只有真机跑到那个代码路径才现形。从那以后我每次升级 SDK 或者换生成器版本都强制走一遍「跑脚本、跑 Demo、diff 产物」这三步一步不跳。希望帮到你。本文还有配套的精品资源点击获取