☰
Delphi 接入 Android SDK:java2op 生成 JNI 桥接单元
2026/10/28 3:45:26 网站建设 项目流程

简介: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 SDK,Delphi 侧怎么接上话

上一个跨平台项目里,合作方给了一份 Android SDK 压缩包,里面只有 jar、几个.so和一份 PDF,通篇没提 Delphi 一个字。官方预置的桥接单元只覆盖 Android 标准库,别人家的业务类它一个都不认识,你在 Object Pascal 里写com.xxx.Foo编译器连名字都没见过。java2op就是补这一步的工具:它读 jar 里的 class 文件,把类、方法、字段签名翻译成一套 Object Pascal 接口声明,产出一个能uses的单元。社区里有人按用途叫它 jar2pas 或 class2pas,本质是同一类活。这篇对准的是 RAD Studio 10.x(XE10 系列)上要接第三方 Android Java 库的人,从「这批接口怎么长出来」一直讲到「接进工程怎么验证它真通了」。

2. 转换链路拆开看:jar 里的 class 是怎么变成可用的 .pas 的

2.1 为什么不能直接调用 jar

Java 和 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(TJavaGenericImport<TJScannerClass, 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 JAVA2OP="<Studio安装目录>\bin\java2op.exe" set SDK_DIR=C:\Android\sdk\platforms\android-33 %JAVA2OP% -D"%SDK_DIR%" -o"D:\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$Config"

3.3 需要桥接的类清单用脚本固化

手敲命令行只适合验证阶段。一旦类清单超过五个,就会出现「上次转了哪些忘了」「这次多转一个少转一个」的问题,而这类问题在编译期完全不报错,等到运行期才发现某个回调接口没桥接。

我的做法是把它做成一个python脚本,清单、路径、参数全部写在文件里,改一次代码提交一次:

import subprocess from pathlib import Path JAVA2OP = r"<Studio安装目录>\bin\java2op.exe" SDK_DIR = r"C:\Android\sdk\platforms\android-33" JAR_DIR = r"D:\proj\android\libs" OUT_DIR = Path(r"D:\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(parents=True, exist_ok=True) cmd = [ JAVA2OP, f"-D{SDK_DIR};{JAR_DIR}", # 分号分隔,别用逗号 f"-o{OUT_DIR}", *TARGETS, ] print("RUN:", " ".join(cmd)) r = subprocess.run(cmd, capture_output=True, text=True) 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_output=True是为了把工具的 stdout 抓下来——这个工具的错误信息经常只出现在 stdout 而不是 stderr,只看stderr会漏;mkdir(parents=True, exist_ok=True)补上了工具不建目录的短板。

最后那段glob断言是我加的后悔药。工具有时候会「部分成功」:命令行里写了五个类,输出目录里只有三个.pas,退出码还是 0。有了这行断言,至少能在脚本层面把这种半成品打回去。

4. 生成的单元接进工程:接口三件套、类型映射和打包验证

4.1 接口三件套的正确用法

生成物拿进工程后,大部分人的疑问集中在「TJxxx和TJxxxClass到底哪个能 new」。答案取决于你要调的东西是静态还是实例。

想构造一个对象、或者调一个静态方法,走.JavaClass:

uses 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按Ctrl+Space能看到全部重载,参数类型按顺序对得上就能编过。

调实例方法直接在接口变量上点:

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[]TJavaArray<Byte>用.Data[0]取首地址,Len取长度
List<T>JList元素取出来是JObject,要自己往下转
回调接口TJxxx需要用TJavaOwnedCallback之类包装

最容易翻车的是byte[]。很多人以为它对应TArray<Byte>,直接SetLength然后往接口里传,编译期就会拦下来。正确的是这样:

var Buf: TJavaArray<Byte>; I: Integer; begin Buf := TJavaArray<Byte>.Create(1024); try // 从 Pascal 数组拷贝到 Java 数组 for I := 0 to 1023 do Buf.Data[I] := Byte(I and $FF); // 传给 Java 方法,形参是 TJavaArray<Byte> Scanner.write(Buf); finally Buf.Free; // 它是记录,但持有 JNI 全局引用,必须显式释放 end; end;

TJavaArray<T>不是普通的动态数组,它内部持有一个 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(r"D:\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 b"com/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这些方法的形参全变成了JObject,Java 文档里明明写的是具体类型。

原因:-D的搜索路径里缺了定义这些类型的 jar,工具解析不到,降级处理成JObject。注意这里工具不会报错,它只是默默降级——这是这个工具最坑的地方,没有之一。

解决:把-D补全。顺序是先加 SDK 平台目录,再加目标 jar 所在目录,最后加它的所有compileOnly依赖目录。补完之后重新转换,新产物和旧产物做一次 diff,看JObject有没有变回具体类型。我习惯在脚本里加一条检查:产物里JObject出现的次数超过某个阈值就报警。

5.3 编译报 Undeclared identifier,但类型明明在生成的文件里

现象:E2003 Undeclared identifier: 'TJScanner',但你打开生成的那个.pas,TJScanner就在第 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[]走TJavaArray<Byte>,转换要用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 JAVA2OP="<Studio安装目录>\bin\java2op.exe" set SDK_DIR=C:\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.txt

diff 出来要重点看三类变化:方法签名里的类型有没有变(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 产物」这三步,一步不跳。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询