如果你用CocosCreator做过Android原生包,大概率撞上过这么一档子事:游戏里角色被击中、道具落地、或者按钮按下去的那一刻,设计稿上明确写着“这里加一次震动反馈”,结果你翻遍引擎API、搜遍社区帖子,发现引擎压根没给你留一个“让手机震一下”的接口。我第一次遇到这个需求时也懵了一下,后来才意识到,这正是CocosCreator与Java原生交互最典型的一个应用场景:你做了一条桥,拿到了脚本层摸不到的系统能力。
这篇文章就完整拆一遍这件事:为什么必须走原生层、Java侧怎么写、JS侧怎么调、构建Android工程时有什么坑、真机上怎么排查,并把“获取手机震动”从单纯的“能震”升级成“按策略、按设备能力去震”。适合正在做CocosCreator原生打包、需要调系统硬件能力的开发者,也适合对jsb反射机制一知半解、想搞明白原理的初级程序员。
1. 为什么只是“震一下”,却绕不开原生这堵墙
1.1 JS运行时和系统硬件之间的隔离
CocosCreator的脚本逻辑最终跑在V8或JavaScriptCore这类脚本引擎里,而脚本引擎和Android系统进程之间隔着一条明确的边界。手机震动能力在Android SDK里的入口是Context.getSystemService(Context.VIBRATOR_SERVICE),它属于系统级服务。JS运行时没有直接访问SDK的通道,它只能调用引擎暴露出来的绑定对象,再由绑定对象层层下钻。
Cocos的解决方案就是JSB,JavaScript Binding。整条调用链路长这样:
JS -> C++ 层绑定 -> JNI -> Java 层 -> 系统 API你常常听别人说“走一下原生”,指的就是这条链路。jsb.reflection是引擎在脚本层开的一扇窗口,让你能按格式呼叫Java层的静态方法,剩下的路,引擎帮你铺好了。
用个生活化的类比:JS脚本像是住在公寓里的住户,Java原生层是大楼物业,系统API是楼下的水电总闸。住户自己摸不到总闸,只能通过物业的调度室去操作。jsb.reflection就是那个调度电话。
1.2 同一个震动需求,不同平台的“后门”不一样
这件事更有意思的地方在于,震动需求换个发布平台,写法完全不同。如果你在浏览器里跑CocosCreator项目,可以直接调用navigator.vibrate,一行代码搞定;如果你发布到某些小游戏平台,平台也提供了震动相关的API;但一旦你打Android原生包,走的是完全不同的路:引擎没有替你封装,你必须自己在Java层写桥接类。
这不是引擎偷懒,而是原生包的优势本来就在于可以触达系统能力,引擎把选择权交给了开发者。于是就有我开篇说的那一幕:查文档查不到震动接口,但项目确实要震动,唯一出路就是自己写桥。
| 发布目标 | 震动实现方式 | 备注 |
|---|---|---|
| Web端 | navigator.vibrate(ms) | 浏览器权限、页面可见性会影响行为 |
| 小游戏平台 | 平台专用震动API | 各平台不一致,需单独适配 |
| Android原生包 | 自建Java桥 +jsb.reflection调用 | 本文重点 |
这也是“CocosCreator系列——与Java原生交互获取手机震动”这个标题真正的价值所在:你不是拿到了一个现成库,而是亲手开了一扇门,掌握了脚本层调用系统能力的方法论。学会了这一套,后面你想调用Toast、相册、剪贴板,都是一样的思路。
2. Java侧代码:先把“震动能力”封装成可用的类
2.1 Vibrator实例的获取,以及Android 12带来的变化
Java侧的第一步是把Vibrator对象拿到手。常规写法是:
Vibrator vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);但在Android 12,也就是API 31开始,震动系统框架做了调整,新设备建议走VibratorManager。为了兼容老设备,需要在代码里对版本做分支判断:
private static Vibrator vibrator; public static void init(Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { VibratorManager manager = (VibratorManager) context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE); vibrator = manager.getDefaultVibrator(); } else { vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); } }很多老项目从源码抄了一段getSystemService就走,结果在Android 12以上真机上报空或者行为异常,原因就在这里。VibratorManager不是随便加的,它是新版本系统的标准入口。
初始化时机也很关键。Vibrator对象本质上是系统服务句柄,必须在拿到Application或Activity的Context之后才能获得。我的习惯是在入口Activity的onCreate里调用VibrationBridge.init(this),这样后续所有静态方法都能直接使用。
2.2 三种震动调用方式,按场景选型
拿到Vibrator之后,调用震动的API在不同Android版本上有三种形态。
第一种是传统老接口,直接传入震动时长,单位毫秒:
vibrator.vibrate(200);这个接口在Android 8.0以下很好用,但8.0以上系统会建议你改用VibrationEffect,因为新接口支持更多的控制维度,比如振幅。
第二种是VibrationEffect.createOneShot,适合单次固定时长震动:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE)); } else { vibrator.vibrate(duration); }第三种是createWaveform,适合做“连续短暂震动”“节奏型震动”这类反馈,比如受到连击伤害时的两段式震动:
long[] pattern = {0, 80, 60, 120}; vibrator.vibrate(VibrationEffect.createWaveform(pattern, -1));数组里交替表示“等待时间”和“震动时间”,最后的-1表示不循环,改成0就是无限循环。游戏里一般不建议无限循环,会直接劝退玩家。
在动手调用之前,最好先判断设备是否有震动马达:
public static boolean hasVibrator() { return vibrator != null && vibrator.hasVibrator(); }这个判断看起来简单,却是我强烈建议保留的。后面排错和做功能开关都靠它。
2.3 完整可编译的VibrationBridge类
把上面的逻辑整合在一起,我直接给一个实际用过的完整类:
package com.example.gametool; import android.content.Context; import android.os.Build; import android.os.VibrationEffect; import android.os.Vibrator; import android.os.VibratorManager; public class VibrationBridge { private static Vibrator vibrator; public static void init(Context context) { if (vibrator != null) { return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { VibratorManager manager = (VibratorManager) context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE); vibrator = manager.getDefaultVibrator(); } else { vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); } } public static boolean hasVibrator() { return vibrator != null && vibrator.hasVibrator(); } public static void vibrate(long duration) { if (vibrator == null) { return; } duration = Math.max(duration, 10); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE)); } else { vibrator.vibrate(duration); } } public static void vibratePattern(long[] pattern, int repeat) { if (vibrator == null || pattern == null || pattern.length < 2) { return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrator.vibrate(VibrationEffect.createWaveform(pattern, repeat)); } else { vibrator.vibrate(pattern, repeat); } } public static void cancel() { if (vibrator != null) { vibrator.cancel(); } } }cancel()的用途很多人一开始想不到。场景是这样的:玩家按了一次震动,第二次交互又触发了,如果前一次长震还没结束,两段震动会叠加得很难受。提供一个可中断的入口,在每次新震动之前先cancel(),手感会干净很多。
这段Java代码,实际上就完成了“获取手机震动”这件事三分之二的准备工作。它把硬件能力获取、版本差异、调用方式全部封装在了原生层,剩下的就是让JS层能敲开这扇门。
3. JS反射调用:一份参数签名,敲开Java大门
3.1 callStaticMethod的调用格式和签名规则
CocosCreator的JS层调用Java静态方法,核心就一个API:
jsb.reflection.callStaticMethod(className, methodName, methodSignature, ...args).换成/。
参数签名使用的JNI类型签名,把参数类型按顺序连接后放在括号里,然后接返回类型:
| Java类型 | JNI签名 |
|---|---|
| boolean | Z |
| int | I |
| long | J |
| float | F |
| double | D |
| String | Ljava/lang/String; |
| void | V |
举个例子,我要调用VibrationBridge.hasVibrator(),没有参数,返回boolean,签名就是()Z。如果要调用vibrate(long duration),签名就是(J)V。如果方法返回String,签名末尾就写成Ljava/lang/String;。
有一个很容易被忽略的细节:jsb.reflection.callStaticMethod对返回值做了字符串化处理。Java侧返回boolean,JS侧拿到的往往是字符串"true"或"false",而不是布尔值。不做处理直接拿来判断,会有不少隐性问题,我在后面会给处理办法。
数组参数在2.x时代的反射调用里非常痛苦,传数组基本要绕很大一圈,我在这个项目里的做法是尽量避免数组参数。如果确实需要数组,建议在Java侧改方法签名,把数组拆成单个参数传,或者用字符串拼接后进Java侧再解析。
3.2 JS侧完整的震动封装
我在JS侧做了一个工具模块,专门负责养老这一层:
const VibrationHelper = { platform: 'unknown', init() { if (cc.sys.isNative && cc.sys.os === cc.sys.OS.ANDROID) { this.platform = 'android'; } else if (cc.sys.isNative && cc.sys.os === cc.sys.OS.IOS) { this.platform = 'ios'; } else { this.platform = 'web'; } }, hasVibrator() { if (this.platform !== 'android') { return false; } const ret = jsb.reflection.callStaticMethod( 'com/example/gametool/VibrationBridge', 'hasVibrator', '()Z' ); return ret === 'true'; }, vibrate(ms = 30) { if (this.platform !== 'android') { return; } jsb.reflection.callStaticMethod( 'com/example/gametool/VibrationBridge', 'vibrate', '(J)V', ms ); }, vibratePattern(pattern, repeat = -1) { if (this.platform !== 'android') { return; } jsb.reflection.callStaticMethod( 'com/example/gametool/VibrationBridge', 'vibratePattern', '([JI)V', pattern, repeat ); }, cancel() { if (this.platform !== 'android') { return; } jsb.reflection.callStaticMethod( 'com/example/gametool/VibrationBridge', 'cancel', '()V' ); } };调用vibrate时,ms这个数字参数必须和签名里的J对齐,Java侧long类型接收的是64位整数。调用前先加一个Math.max之类的兜底,避免传出负数或0导致Java侧出现异常行为。
值得注意的是jsb.reflection的这个调用是同步阻塞的。它不是一个异步接口,JS线程会一直等JNI返回。Java侧的方法务必保持轻量,只做快速逻辑判断和API调用,绝不能在桥接方法里写耗时逻辑,比如网络请求、文件读写、Thread.sleep,否则会直接把游戏帧率拖垮。
3.3 3.x版本的差异:从jsb到native的过渡
CocosCreator 2.x时代,大家用的都是jsb.reflection。到了3.x版本,引擎做了模块整合,老路径依然兼容,但更推荐使用native.reflection这样的新路径,同时建议用引擎内置的桥接协议JsbBridge来处理更复杂的数据交互。
老实说,如果你的需求只是调用无参数或简单参数的静态方法,直接沿用jsb.reflection的写法,在3.x版本里依然能跑。但如果你想传递复杂对象、频繁回调、或者做双端双向通信,那就应该把JsbBridge学起来,它是引擎官方推荐的方向。
我个人的建议是:项目在2.x就沿用2.x的反射方案,迁移到3.x时顺手把工具模块做一层抽象,内部实现细节对业务层透明。将来不管你是换引擎版本还是换桥接方案,业务代码一行都不用改。
4. 构建Android工程时最容易翻车的三个环节
4.1 先找到正确的Android工程目录
Java类写好之后,问题来了:这个类放哪儿?
用过CocosCreator构建Android包的人都知道,点一次构建,会在项目的build目录下生成一个完整的Android工程。以Android Studio打开这个工程,你会看到熟悉的app/src/main/java目录结构。把VibrationBridge.java放进com/example/gametool包路径下就行。
这里有个操作顺序上的建议:先把CocosCreator这边的构建流程跑通,生成Android工程之后,再用Android Studio打开去做原生侧修改。不要在构建之前就手动改动工程模板,那样每次构建都可能把你的改动覆盖掉。
CocosCreator后来的版本支持构建到指定目录时保留工程,但同一套原生代码,我还是建议统一维护在一处,比如专门建一个“native-ext”目录,把Java源码、AndroidManifest片段、proguard规则都放进去,每次构建完手动同步。脚本化之后,基本就告别重复劳动了。
4.2 AndroidManifest里的震动权限
震动属于系统敏感能力,需要在清单文件里声明权限。找到app/src/main/AndroidManifest.xml,在<manifest>节点下加上:
<uses-permission android:name="android.permission.VIBRATE" />这个权限不是什么危险权限,不会触发运行时弹窗,但你要是忘了声明,系统会默默忽略你的震动请求。这就成了“代码明明写了、真机就是不震”的头号嫌疑犯。
4.3 release包的混淆,专治反射玄学
如果你只打了debug包做测试,一切正常,欢天喜地发了个release包,结果玩家手机上震动完全失效,那你八成碰到了混淆问题。
Cocos构建的Android工程默认开启代码压缩,ProGuard会把Java类的名字搅成无意义的短名。jsb.reflection按完整类路径去反射调用,类名一变,反射自然找不到目标。
解决方法是保留桥接类的原始包路径,在app/proguard-rules.pro里加上:
-keep class com.example.gametool.VibrationBridge { *; }如果你桥接类里还用了别的类,比如自定义的震动策略类,也需要一并保留。检查混淆问题还有一个笨办法:release包出问题后,先用dexdump分析一下APK里的类名,确认是混淆导致的问题再动手改规则,不要上来就把整个包的所有类都keep住,那样混淆形同虚设。
5. 真机排错:为什么模拟器“永远不震”
5.1 模拟器上没有振动马达
你在模拟器上测试震动功能,代码跑得再顺利,手机也不会震。这不是你写错了,而是模拟器默认不带振动马达硬件。我在这个项目里第一次测的时候也傻了,还以为是桥接代码有问题,花了不少时间排查,最后才发现是模拟器的锅。
所以震动功能的验证必须上真机。没有真机的情况下,我一般会在代码里打个日志,确认JS侧调用确实到达了Java层,返回的hasVibrator()是不是false。只要能走到Java层且方法正常返回,功能基本就没问题了。
另外提醒一句:有些模拟器确实提供了振动模拟,但行为真机和模拟器差异很大,尤其是震动节奏和振幅细节,最终手感还是得在真机上校准。
5.2 权限之外的三重“静默开关”
真机联调时,就算权限加了、类名没混淆,你仍然可能遇到“代码明明调了,手机就是纹丝不动”的情况。我踩过几次坑之后,总结出三个非常隐蔽的开关:
第一重是国产ROM的全局震动开关。很多手机在“设置-声音与振动”里有一个总开关,关掉之后所有应用都震不了,你的应用也不例外。
第二重是系统层面的“触摸反馈震动”。个别ROM把“触摸时震动”和“应用震动”做了联动,你关了这个开关,系统会连坐你的应用震动权限。
第三重是应用的通知震动权限。这里比较怪,有些ROM版本对震动权限的管控和通知权限绑在一起,需要进入“通知管理-震动”里把开关打开。
这一套排查顺序基本是我后来遇到“该震不震”问题时的标准流程:先看hasVibrator()返回值,再看权限声明,再查ROM开关,最后拿一个已有的震动应用对照测试。这一套走下来,基本能定位问题出在哪个环节。
5.3 把“获取”变成能力:读取设备震动状态
标题叫“获取手机震动”,这里顺便说得更完整一点。“获取”这个词,一方面指的是调用震动,另一方面强调的是先获取设备的震动能力,再决定要不要震、怎么震。
所以在我的封装里,hasVibrator()是优先级最高的方法。业务层拿到这个结果后可以做三件事:
- 在设置页显示“震动反馈”开关,无马达设备直接隐藏或禁用;
- 初始化时把能力值上报给游戏通用配置,供全局逻辑查询;
- 区分“硬件不支持”和“用户主动关闭”两种状态,避免用户误以为游戏坏了。
代码层面,需要在Java侧额外暴露一个方法处理状态查询。如果想知道当前震动是否处于“可用”状态,可以把硬件能力和系统开关结合起来,通过一个短路逻辑给出最终结果,比如返回一个字符串:“hardware_ok”、“hardware_missing”、“user_disabled”。这样一个接口,比单纯一个布尔值有用得多。
6. 把震动模块做成一门“懂分寸”的游戏功能
6.1 设计一张震动策略表
功能打通之后,真正考验功力的地方在于:什么时候震、震多长、用什么节奏。
我会在JS侧维护一张震动策略表,让策划和程序都在同一套语言下沟通:
| 反馈场景 | 震动时长 | 推荐类型 |
|---|---|---|
| UI按钮点击 | 15ms | 单次短震 |
| 角色受伤 | 50ms | 单次短震 |
| 重击/击杀 | 200ms | 单次中长震 |
| 连续受击 | [0, 40, 30, 80, 30, 120] | 节奏震动 |
| 新关卡解锁 | 300ms | 单次长震 |
这些数值不是凭空写的,是我在不同真机上手动试出来的相对稳妥区间。太短了体感不明显,太长了玩家会烦躁。震动和音效配合尤其关键,震动先触发、音效随后跟上,比两者同时出现更能形成感知上的层次感。
策略表落地到代码里,就是一个配置对象加一个统一的触发入口:
const VibrationConfig = { CLICK: 15, HURT: 50, KILL: 200, COMBO: [0, 40, 30, 80, 30, 120], }; function applyVibration(type) { if (!VibrationHelper.available) { return; } const value = VibrationConfig[type]; if (Array.isArray(value)) { VibrationHelper.vibratePattern(value); } else { VibrationHelper.vibrate(value); } }6.2 震动频率限制与调用节流
震动反馈不能无脑触发。连续攻击时,如果每帧都在调震动,精英怪的每一段攻击都触发一次,玩家的手机就像一台按摩仪,原本很重要的反馈反而彻底失去了意义。
我在项目里加了简单的节流:同一类型震动之间,强制设置一段时间间隔,比如最短100ms内不重复触发同一反馈。
const lastTriggerTime = {}; function throttleVibration(type, interval = 100) { const now = Date.now(); if (lastTriggerTime[type] && now - lastTriggerTime[type] < interval) { return; } lastTriggerTime[type] = now; applyVibration(type); }另一个注意点是,jsb.reflection是同步调用,高频调用会把游戏主线程拖住。即使Java侧方法本身非常快,也扛不住你在导演脚本的update里每一帧去反射一次。把所有震动入口收敛到一个工具模块,同时做好节流,既保护性能,也保住体验。
6.3 关于命名、模块边界和扩展方向
最后聊一点工程上的习惯沉淀。震动代码这种“一次封装、处处调用”的模块,一定要保证调用方只依赖一个入口。不要让业务层直接操作jsb.reflection,那样一旦出现底层变化,所有业务代码都要跟着改。我的做法是:引擎层之上只暴露VibrationHelper,业务层只认识applyVibration('HURT')这类语义化调用,其余细节全部隐藏。
这个模块的边界也可以继续向其他原生能力扩散。你会发现一旦掌握了这套桥接方法,Toast提示、本地相册选图、读取设备型号、调用剪贴板,都是同一个套路。甚至可以把桥接层做成一张能力清单,Java侧暴露哪些能力,JS侧就注册哪些方法,两边各维护一份接口文档。
我在实际项目中踩过几次坑,最大的体会是:桥接代码本身并不难写,难的是让桥接之后的能力真正融入游戏逻辑,成为体验的一部分。震动不是可有可无的装饰,它是触感反馈,是玩家与游戏之间最直接的身体连接。把这一件小事做好,体验的质感会有非常明显的提升。
如果手头正好在做CocosCreator原生包,不妨先把VibrationBridge加上,哪怕这版本只用来做按钮点击反馈,也比完全没震感强很多。后续再慢慢把策略表、节流、设备能力判断这些细节补上,这套方案会越来越顺手。