如何运行时检测折叠屏展开状态
解读
折叠屏在国内安卓机型(华为 Mate X 系列、荣耀 Magic V、OPPO Find N、vivo X Fold、小米 MIX Fold 等)已大规模商用,Unity 项目若要在运行时准确拿到“展开/折叠”状态,必须绕过 Unity 引擎对屏幕尺寸变化的“延迟回调”,直接调用安卓系统级 API。
面试时,考官想确认两点:
- 你是否知道 Unity 的 Screen.width/height 有 1~2 帧延迟,不能作为实时判据;
- 你是否能把 Android 的 WindowMetrics、DisplayFeature 或华为私有 API 桥接到 C#,并保证主线程安全、零 GC、热更新友好。
知识点
-
Android 官方 API 路线
- SDK 30+:WindowMetrics#getBounds()、WindowMetrics#getWindowInsets()
- SDK 24+:DisplayFeature 接口获取折叠区域 postures(FLAT/HALF_OPENED)
- 配置变更:android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" 必须在 AndroidManifest 中声明,防止 Activity 重启。
-
国内厂商私有扩展
- 华为:com.huawei.android.hardware.fold.FoldableManager,权限 com.huawei.hardware.fold.status,回调 onFoldStateChanged(int state)
- 荣耀/华为 2.0+:系统广播 action="huawei.android.intent.action.FOLD_STATE_CHANGED",extra 键值 "state"=0/1/2。
-
Unity 与 Java 通信
- 推荐无反射方案:AndroidJavaObject 只在 Awake 初始化一次,回调用 UnityPlayer.UnitySendMessage 或 C# delegate 转送,避免每帧 New 对象造成 GC。
- 若项目使用 ILRuntime/HybridCLR 热更新,需把状态封装为值类型事件,防止热更层反复 Marshal。
-
线程与刷新时机
- 折叠动画 250~400 ms,检测周期 40 ms(25 Hz)即可;
- 必须在渲染线程之前(End of Frame)把状态写进主线程变量,否则下一帧相机 Aspect 已变,UI 布局会抖动。
-
兼容性兜底
- 对 SDK<24 或无法识别折叠特征的设备,用“屏幕高宽比突变 > 1.8”作为近似策略,但需提示用户“可能不准确”。
答案
- 在 Plugins/Android 新建 FoldableManager.java
public class FoldableManager extends UnityPlayerActivity {
private static FoldableManager INSTANCE;
public enum Posture { FLAT, HALF_OPENED, UNKNOWN }
private Posture curPosture = Posture.UNKNOWN;
@Override protected void onCreate(Bundle b) {
super.onCreate(b);
INSTANCE = this;
if (Build.VERSION.SDK_INT >= 30) {
registerFoldableCallback();
} else if (isHuawei()) {
registerHuaweiBroadcast();
}
}
@TargetApi(30)
private void registerFoldableCallback() {
OnBackInvokedDispatcher dispatcher = getOnBackInvokedDispatcher(); // 占位,仅用于 API 30+ 编译通过
getWindow().getDecorView().addOnLayoutChangeListener((v, l, t, r, b, oldL, oldT, oldR, oldB) -> {
WindowMetrics metrics = getWindowManager().getCurrentWindowMetrics();
List<DisplayFeature> features = getDisplay().getFoldFeatures(); // 官方支持库 androidx.window
for (DisplayFeature f : features) {
if (f instanceof FoldingFeature) {
FoldingFeature fold = (FoldingFeature) f;
curPosture = fold.getState() == FoldingFeature.State.FLAT ? Posture.FLAT : Posture.HALF_OPENED;
UnityPlayer.UnitySendMessage("FoldableListener", "OnPostureChanged", curPosture.name());
return;
}
}
// 无折叠特征,按平板处理
curPosture = Posture.FLAT;
UnityPlayer.UnitySendMessage("FoldableListener", "OnPostureChanged", curPosture.name());
});
}
private boolean isHuawei() {
return Build.MANUFACTURER.toLowerCase(Locale.CHINA).contains("huawei");
}
private void registerHuaweiBroadcast() {
IntentFilter f = new IntentFilter("huawei.android.intent.action.FOLD_STATE_CHANGED");
registerReceiver(new BroadcastReceiver() {
public void onReceive(Context c, Intent i) {
int st = i.getIntExtra("state", -1);
curPosture = (st == 0) ? Posture.FLAT : Posture.HALF_OPENED;
UnityPlayer.UnitySendMessage("FoldableListener", "OnPostureChanged", curPosture.name());
}
}, f);
}
public static boolean IsFolded() {
if (INSTANCE == null) return false;
return INSTANCE.curPosture == Posture.HALF_OPENED;
}
}
- C# 桥接层 FoldableListener.cs
public class FoldableListener : MonoSingleton<FoldableListener> {
public static event Action<bool> OnFoldStateChanged; // true=折叠,false=展开
private bool _folded;
void OnPostureChanged(string posture) {
bool newFold = posture == "HALF_OPENED";
if (newFold != _folded) {
_folded = newFold;
OnFoldStateChanged?.Invoke(_folded);
}
}
public bool IsFolded() {
#if UNITY_ANDROID && !UNITY_EDITOR
using (var jc = new AndroidJavaClass("com.company.product.FoldableManager")) {
return jc.CallStatic<bool>("IsFolded");
}
#else
return false;
#endif
}
}
- 在 AndroidManifest.xml 中加入
<activity android:name="com.company.product.FoldableManager"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|screenSize">
</activity>
- 使用示例
FoldableListener.OnFoldStateChanged += folded => {
Camera.main.orthographic = folded; // 折叠时切换 2D 视图
SafeAreaAdapter.Refresh(); // 重算 SafeArea
};
关键点:
- 全程无反射调用,首次初始化后不再 new AndroidJavaObject;
- 状态变化通过静态事件回抛到主线程,UI 层零 GC;
- 若设备不支持官方 API,则回退到“比例突变”策略,保证低端机兼容。
拓展思考
- 折叠动画期间分辨率渐变,如果游戏使用固定分辨率渲染管线(RenderTexture 缩放),需要在状态变化 0 ms、200 ms、400 ms 三个采样点动态重设 RenderTexture 尺寸,防止像素突然拉伸。
- 多窗口/平行视界场景下,折叠屏可能出现“一半游戏、一半视频”的分屏状态,此时 Unity 的 Screen.width 仅代表应用窗口,而非物理屏幕;需用 WindowMetrics#getBounds() 取得物理像素,再计算 DPI 缩放,确保 UI 布局与物理铰链对齐。
- 云游戏/串流场景(如腾讯 START、网易云游戏)把折叠事件作为信令发到云端,要求状态序列化 < 32 Byte、延迟 < 20 ms;可将 FoldableListener 编译为 .aar 插件,暴露 JNI 接口给外部 SDK,避免重复开发。