如何运行时检测折叠屏展开状态

解读

折叠屏在国内安卓机型(华为 Mate X 系列、荣耀 Magic V、OPPO Find N、vivo X Fold、小米 MIX Fold 等)已大规模商用,Unity 项目若要在运行时准确拿到“展开/折叠”状态,必须绕过 Unity 引擎对屏幕尺寸变化的“延迟回调”,直接调用安卓系统级 API
面试时,考官想确认两点:

  1. 你是否知道 Unity 的 Screen.width/height 有 1~2 帧延迟,不能作为实时判据
  2. 你是否能把 Android 的 WindowMetrics、DisplayFeature 或华为私有 API 桥接到 C#,并保证主线程安全、零 GC、热更新友好

知识点

  1. Android 官方 API 路线

    • SDK 30+:WindowMetrics#getBounds()、WindowMetrics#getWindowInsets()
    • SDK 24+:DisplayFeature 接口获取折叠区域 postures(FLAT/HALF_OPENED)
    • 配置变更:android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" 必须在 AndroidManifest 中声明,防止 Activity 重启。
  2. 国内厂商私有扩展

    • 华为: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。
  3. Unity 与 Java 通信

    • 推荐无反射方案:AndroidJavaObject 只在 Awake 初始化一次,回调用 UnityPlayer.UnitySendMessage 或 C# delegate 转送,避免每帧 New 对象造成 GC。
    • 若项目使用 ILRuntime/HybridCLR 热更新,需把状态封装为值类型事件,防止热更层反复 Marshal。
  4. 线程与刷新时机

    • 折叠动画 250~400 ms,检测周期 40 ms(25 Hz)即可;
    • 必须在渲染线程之前(End of Frame)把状态写进主线程变量,否则下一帧相机 Aspect 已变,UI 布局会抖动。
  5. 兼容性兜底

    • 对 SDK<24 或无法识别折叠特征的设备,用“屏幕高宽比突变 > 1.8”作为近似策略,但需提示用户“可能不准确”。

答案

  1. 在 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;
    }
}
  1. 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
    }
}
  1. 在 AndroidManifest.xml 中加入
<activity android:name="com.company.product.FoldableManager"
          android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|screenSize">
</activity>
  1. 使用示例
FoldableListener.OnFoldStateChanged += folded => {
    Camera.main.orthographic = folded; // 折叠时切换 2D 视图
    SafeAreaAdapter.Refresh();       // 重算 SafeArea
};

关键点

  • 全程无反射调用,首次初始化后不再 new AndroidJavaObject;
  • 状态变化通过静态事件回抛到主线程,UI 层零 GC;
  • 若设备不支持官方 API,则回退到“比例突变”策略,保证低端机兼容

拓展思考

  1. 折叠动画期间分辨率渐变,如果游戏使用固定分辨率渲染管线(RenderTexture 缩放),需要在状态变化 0 ms、200 ms、400 ms 三个采样点动态重设 RenderTexture 尺寸,防止像素突然拉伸。
  2. 多窗口/平行视界场景下,折叠屏可能出现“一半游戏、一半视频”的分屏状态,此时 Unity 的 Screen.width 仅代表应用窗口,而非物理屏幕;需用 WindowMetrics#getBounds() 取得物理像素,再计算 DPI 缩放,确保 UI 布局与物理铰链对齐。
  3. 云游戏/串流场景(如腾讯 START、网易云游戏)把折叠事件作为信令发到云端,要求状态序列化 < 32 Byte、延迟 < 20 ms;可将 FoldableListener 编译为 .aar 插件,暴露 JNI 接口给外部 SDK,避免重复开发。