欢迎光临
我们一直在努力

UI 适配框架从 0 实现(经验总结)

UI 适配框架从 0 实现

这份文档不是项目源码说明书,而是一份“把适配方式变成自己经验”的实现手册。目标是:看完之后,你知道为什么要这样拆模块,也知道如果自己从 0 写一套 Unity UI 适配框架,应该先写什么、后写什么、每个类负责什么、Prefab 怎么接、最后怎么检查。

如果只记一句话:

UI 适配不是把整个界面等比缩放,而是先把内容分类,再给每类内容制定不同的变化规则。

背景可以铺满和裁切,主体要稳定,按钮要避开刘海和底条,列表要能滚动,弹窗要保持形状,世界浮层要防止跑出屏幕。


1. 先建立自己的适配观念

1.1 适配到底在解决什么

同一套 UI 要跑在这些屏幕上:

  • 普通手机:接近设计稿比例。
  • 超长手机:高度更多,底部可能有 Home Indicator。
  • 刘海屏:顶部或底部有不可交互区域。
  • 平板:屏幕更宽,主体如果拉满会很散。
  • PC / 模拟器 / 折叠屏:宽高比例变化更大。

如果只做一个简单缩放,会出现这些问题:

  • 按钮被刘海、圆角、底部条挡住。
  • 宽屏上 UI 被横向拉得很散。
  • 背景露黑边或露底。
  • 弹窗在平板上显得太小。
  • 列表在短屏显示不下。
  • 世界浮层跟随目标后跑出屏幕。

所以适配系统要解决的不是一个问题,而是一组问题。

1.2 我会把 UI 内容分成 6 类

做适配前,不要先想代码。先看界面,把内容分成下面几类。

内容类型它最怕什么适配策略
背景图、氛围图、底纹 露底、黑边、不满屏 铺满真实屏幕,必要时裁切或延展
主体内容 宽屏被拉散、信息距离过远 限制最大宽度,居中显示
按钮、页签、输入框 被刘海、底条、圆角挡住 贴安全区,不贴真实屏幕边缘
列表、滚动区 短屏显示不下,长屏空得难看 固定头尾,中间区域自适应,内容滚动
弹窗主体 宽屏太小,窄屏贴边,内部被拉伸 只缩放主体容器,不手动拉伸每个子节点
世界浮层、气泡 跟随目标后跑出屏幕 先算目标位置,再钳制回安全区域

这张表就是最重要的经验。只要分类对了,后面的实现就很自然。

1.3 一套好用的适配框架要有 4 层

从 0 写框架时,我建议拆成 4 层:

  • 全局数据层:当前屏幕尺寸、宽高比、安全区、主内容区。
  • 全局缩放层:根 Canvas 怎么缩放。
  • 窗口适配层:全屏窗口、弹窗窗口怎么适配。
  • 局部组件层:贴边按钮、背景延展、断点布局、浮层钳制。
  • 这样拆的好处是:

    • 业务 UI 不用到处读 Screen.width。
    • 所有界面共用同一套安全区和主内容区。
    • 全屏和弹窗逻辑不会互相污染。
    • 局部特殊情况有组件处理,不会写一堆散落的业务代码。

    2. 从 0 设计框架目录

    可以先按下面目录建文件:

    UIAdapt/
    ├── Core/
    │ ├── UIAdaptConfig.cs
    │ ├── AdaptMargin.cs
    │ ├── UIAdaptSnapshot.cs
    │ ├── UIAdapt.cs
    │ └── IUIAdaptElement.cs
    ├── Platform/
    │ ├── IUIAdaptPlatform.cs
    │ ├── DefaultUIAdaptPlatform.cs
    │ └── UnitySafeAreaPlatform.cs
    ├── Runtime/
    │ ├── UIAdaptRuntime.cs
    │ └── UIAdaptEventBridge.cs
    ├── Components/
    │ ├── RootCanvasAdapter.cs
    │ ├── FullScreenWindowAdapter.cs
    │ ├── PopupWindowAdapter.cs
    │ ├── SafeEdgeAnchor.cs
    │ ├── EdgeExtendAdapter.cs
    │ └── RectTransformPresetAdapter.cs
    └── Utility/
    ├── UIAdaptRectUtility.cs
    └── UIOverlayClampUtility.cs

    最小可用版本不用一次写完。推荐顺序:

  • UIAdaptConfig
  • AdaptMargin
  • UIAdaptSnapshot
  • UIAdapt
  • RootCanvasAdapter
  • IUIAdaptElement
  • UIAdaptRuntime
  • FullScreenWindowAdapter
  • PopupWindowAdapter
  • SafeEdgeAnchor
  • EdgeExtendAdapter
  • RectTransformPresetAdapter
  • 先把全屏、弹窗、贴边按钮跑通,再补世界浮层和编辑器预览。


    3. 第一步:确定设计基准

    3.1 为什么必须先定设计分辨率

    设计分辨率是整套 UI 的坐标基准。没有这个基准,Prefab 大小、弹窗尺寸、字体大小、背景尺寸都会变成各做各的。

    本项目使用的是:

    public static int DESIGN_WIDTH = 1080;
    public static int DESIGN_HEIGHT = 2339;

    从 0 做框架,可以写成配置类:

    using UnityEngine;

    public static class UIAdaptConfig
    {
    public static readonly Vector2 DesignResolution = new Vector2(1080f, 2339f);

    public const float DesignAspect = 1080f / 2339f;

    // 弹窗在宽屏上的最大安全放大比例。9:17.5 约等于 0.514。
    public const float PopupSafeAspect = 9f / 17.5f;

    // 主内容区最大比例。9:12 = 0.75,平板/PC 两侧留给背景。
    public const float MainBodyAspect = 9f / 12f;

    // 常见短屏比例,9:16 约等于 0.5625。
    public const float ShortAspect = 9f / 16f;
    }

    3.2 怎么理解这些比例

    设计比例:

    1080 / 2339 = 0.462

    屏幕越宽,这个值越大。

    举例:

    分辨率宽高比类型
    1080×2339 0.462 设计基准
    1080×2520 0.428 更长的手机
    1753×2339 0.749 平板附近
    3366×2339 1.439 超宽屏 / PC

    经验:

    • 比设计稿更窄或更长:优先保证宽度,垂直方向多出来。
    • 比设计稿更宽:优先保证高度,横向多出来。
    • 横向多出来的空间不要全部给主体,主体太宽会难看。

    4. 第二步:定义安全区边距

    安全区本质上就是四个边距。

    using System;

    [Serializable]
    public class AdaptMargin
    {
    public float top;
    public float bottom;
    public float left;
    public float right;

    public AdaptMargin()
    {
    }

    public AdaptMargin(float top, float bottom, float left, float right)
    {
    this.top = top;
    this.bottom = bottom;
    this.left = left;
    this.right = right;
    }

    public AdaptMargin Clone()
    {
    return new AdaptMargin(top, bottom, left, right);
    }

    public bool Approximately(AdaptMargin other)
    {
    if (other == null)
    {
    return false;
    }

    return UnityEngine.Mathf.Approximately(top, other.top)
    && UnityEngine.Mathf.Approximately(bottom, other.bottom)
    && UnityEngine.Mathf.Approximately(left, other.left)
    && UnityEngine.Mathf.Approximately(right, other.right);
    }
    }

    4.1 AdaptMargin 和 SafeArea 的关系

    Unity 的 Screen.safeArea 是一个矩形:

    safeArea.xMin
    safeArea.yMin
    safeArea.xMax
    safeArea.yMax

    而 UI 适配更常用的是四边边距:

    left = safeArea.xMin
    bottom = safeArea.yMin
    right = screenWidth – safeArea.xMax
    top = screenHeight – safeArea.yMax

    转成边距后,后面做局部适配会更直接。

    4.2 AdaptMargin 和 RealAdaptMargin 的区别

    如果要做得更细,可以分两种边距:

    • AdaptMargin:给普通 UI 用,保护按钮和主体内容。
    • RealAdaptMargin:给背景延展用,更贴近真实设备边缘。

    为什么要分?

    普通按钮应该避开危险区域,但背景图可以延展进去。比如底部 Home Indicator 那块,按钮不能放过去,底部背景却应该铺过去,否则会露出空白。

    最小版本可以先只做一个 AdaptMargin。后面发现背景延展不够,再加 RealAdaptMargin。


    5. 第三步:定义适配快照

    适配系统每次刷新,都应该产出一份“当前屏幕状态”。这份状态就是 Snapshot。

    using UnityEngine;

    public struct UIAdaptSnapshot
    {
    public int Width;
    public int Height;
    public float Aspect;

    public Rect ScreenRect;
    public Rect SafeRect;
    public Rect MainBodyRect;

    public AdaptMargin AdaptMargin;
    public AdaptMargin RealAdaptMargin;

    public bool IsLandscape;
    public bool IsPortrait;
    }

    经验:

    • 不要让业务到处重新算屏幕信息。
    • 每次屏幕变化,统一生成一份快照。
    • 所有组件都拿同一份快照做布局。

    6. 第四步:写核心 UIAdapt 类

    这个类相当于项目里的 ScreenAdapt。它要做三件事:

  • 提供全局屏幕数据。
  • 提供通用计算方法。
  • 派发屏幕变化事件。
  • 6.1 基础结构

    using System;
    using UnityEngine;
    using UnityEngine.UI;

    public static class UIAdapt
    {
    public static int Width
    {
    get { return Screen.width; }
    }

    public static int Height
    {
    get { return Screen.height; }
    }

    public static float Aspect
    {
    get { return CalcAspect(Width, Height); }
    }

    public static UIAdaptSnapshot Snapshot { get; private set; }

    public static event Action<UIAdaptSnapshot> OnAdaptChanged;
    public static event Action<UIAdaptSnapshot> OnScreenSizeChanged;
    public static event Action<UIAdaptSnapshot> OnSafeAreaChanged;

    private static IUIAdaptPlatform s_Platform;
    private static int s_LastWidth;
    private static int s_LastHeight;
    private static AdaptMargin s_LastAdaptMargin;

    public static void Init(IUIAdaptPlatform platform)
    {
    s_Platform = platform ?? new DefaultUIAdaptPlatform();
    Refresh(true);
    }

    public static void Update()
    {
    if (s_Platform != null && s_Platform.NeedRefresh())
    {
    Refresh(false);
    }
    }
    }

    6.2 刷新逻辑

    public static void Refresh(bool force)
    {
    int width = Mathf.Max(1, Width);
    int height = Mathf.Max(1, Height);
    AdaptMargin margin = s_Platform != null
    ? s_Platform.GetAdaptMargin()
    : new AdaptMargin();

    bool sizeChanged = force || width != s_LastWidth || height != s_LastHeight;
    bool safeChanged = force || s_LastAdaptMargin == null || !s_LastAdaptMargin.Approximately(margin);

    if (!sizeChanged && !safeChanged)
    {
    return;
    }

    Snapshot = BuildSnapshot(width, height, margin);

    s_LastWidth = width;
    s_LastHeight = height;
    s_LastAdaptMargin = margin.Clone();

    OnAdaptChanged?.Invoke(Snapshot);

    if (sizeChanged)
    {
    OnScreenSizeChanged?.Invoke(Snapshot);
    }

    if (safeChanged)
    {
    OnSafeAreaChanged?.Invoke(Snapshot);
    }
    }

    6.3 生成快照

    private static UIAdaptSnapshot BuildSnapshot(int width, int height, AdaptMargin margin)
    {
    Rect screenRect = new Rect(0f, 0f, width, height);
    Rect safeRect = ApplyMargin(screenRect, margin);
    Rect mainBodyRect = ClampRectWidthToAspect(safeRect, UIAdaptConfig.MainBodyAspect);

    return new UIAdaptSnapshot
    {
    Width = width,
    Height = height,
    Aspect = CalcAspect(width, height),
    ScreenRect = screenRect,
    SafeRect = safeRect,
    MainBodyRect = mainBodyRect,
    AdaptMargin = margin,
    RealAdaptMargin = margin,
    IsLandscape = width >= height,
    IsPortrait = width < height,
    };
    }

    6.4 核心算法

    public static float CalcAspect(int width, int height)
    {
    return height <= 0 ? 0f : width / (float)height;
    }

    public static float CalcMatchWidthOrHeight(int width, int height)
    {
    float aspect = CalcAspect(width, height);
    return aspect <= UIAdaptConfig.DesignAspect ? 0f : 1f;
    }

    public static Rect ApplyMargin(Rect rect, AdaptMargin margin)
    {
    if (margin == null)
    {
    return rect;
    }

    return Rect.MinMaxRect(
    rect.xMin + margin.left,
    rect.yMin + margin.bottom,
    rect.xMax – margin.right,
    rect.yMax – margin.top);
    }

    public static Rect ClampRectWidthToAspect(Rect rect, float maxAspect)
    {
    float maxWidth = rect.height * maxAspect;
    if (rect.width <= maxWidth)
    {
    return rect;
    }

    float centerX = rect.center.x;
    rect.xMin = centerX – maxWidth * 0.5f;
    rect.xMax = centerX + maxWidth * 0.5f;
    return rect;
    }

    6.5 这一步的经验

    UIAdapt 不应该关心某个具体按钮怎么摆。它只负责回答这些问题:

    • 当前屏幕多大?
    • 当前安全区是多少?
    • 主体内容应该放在哪个区域?
    • 屏幕变化时谁需要重新适配?

    越底层的类,职责越要稳定。不要把具体业务 UI 写进 UIAdapt。


    7. 第五步:平台安全区提供者

    平台层负责拿设备信息。最小版本可以先用 Unity 自带的 Screen.safeArea。

    7.1 接口

    public interface IUIAdaptPlatform
    {
    AdaptMargin GetAdaptMargin();

    bool NeedRefresh();
    }

    7.2 默认平台

    public class DefaultUIAdaptPlatform : IUIAdaptPlatform
    {
    public AdaptMargin GetAdaptMargin()
    {
    return new AdaptMargin();
    }

    public bool NeedRefresh()
    {
    return false;
    }
    }

    7.3 Unity SafeArea 平台

    using UnityEngine;

    public class UnitySafeAreaPlatform : IUIAdaptPlatform
    {
    private int _lastWidth;
    private int _lastHeight;
    private Rect _lastSafeArea;

    public AdaptMargin GetAdaptMargin()
    {
    int width = Mathf.Max(1, Screen.width);
    int height = Mathf.Max(1, Screen.height);
    Rect safeArea = Screen.safeArea;

    return new AdaptMargin
    {
    left = safeArea.xMin,
    bottom = safeArea.yMin,
    right = width – safeArea.xMax,
    top = height – safeArea.yMax,
    };
    }

    public bool NeedRefresh()
    {
    bool changed = _lastWidth != Screen.width
    || _lastHeight != Screen.height
    || _lastSafeArea != Screen.safeArea;

    _lastWidth = Screen.width;
    _lastHeight = Screen.height;
    _lastSafeArea = Screen.safeArea;

    return changed;
    }
    }

    真实项目里,微信小游戏、Android、iOS 可能会有平台 API,甚至需要修正 Unity 返回的安全区。这个时候不要改 UI 组件,只改平台层。

    经验:

    平台差异应该收敛在 Platform Provider,不能散落到每个 UI 脚本。


    8. 第六步:根 Canvas 适配

    Unity 的 CanvasScaler 是第一层适配。

    8.1 推荐设置

    根 Canvas:

    • UI Scale Mode = Scale With Screen Size
    • Reference Resolution = 1080 x 2339
    • Screen Match Mode = Match Width Or Height
    • Match 运行时动态设置

    8.2 为什么要动态设置 Match

    如果当前屏幕比设计稿更长:

    currentAspect <= designAspect

    使用 matchWidthOrHeight = 0,也就是按宽度适配。这样宽度不会丢,垂直方向多出来。

    如果当前屏幕比设计稿更宽:

    currentAspect > designAspect

    使用 matchWidthOrHeight = 1,也就是按高度适配。这样高度稳定,横向多出来。

    8.3 RootCanvasAdapter

    using UnityEngine;
    using UnityEngine.UI;

    [RequireComponent(typeof(CanvasScaler))]
    public class RootCanvasAdapter : MonoBehaviour
    {
    private CanvasScaler _scaler;

    private void Awake()
    {
    _scaler = GetComponent<CanvasScaler>();
    }

    private void OnEnable()
    {
    UIAdapt.OnAdaptChanged += OnAdaptChanged;
    Apply();
    }

    private void OnDisable()
    {
    UIAdapt.OnAdaptChanged -= OnAdaptChanged;
    }

    private void OnAdaptChanged(UIAdaptSnapshot snapshot)
    {
    Apply();
    }

    private void Apply()
    {
    if (_scaler == null)
    {
    return;
    }

    _scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
    _scaler.referenceResolution = UIAdaptConfig.DesignResolution;
    _scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
    _scaler.matchWidthOrHeight = UIAdapt.CalcMatchWidthOrHeight(UIAdapt.Width, UIAdapt.Height);
    }
    }

    经验:

    根 Canvas 只解决“全局坐标缩放”问题,不解决每个界面内部怎么摆。


    9. 第七步:统一窗口适配时机

    不要让每个适配组件在 Start()、OnEnable()、Update() 里抢着改布局。更稳的做法是:

  • 窗口创建。
  • 业务刷新内容。
  • 标记这个窗口需要适配。
  • 在 Canvas.preWillRenderCanvases 里统一执行。
  • 这样可以避免:

    • 子节点还没创建就开始适配。
    • LayoutGroup 还没刷新就开始读尺寸。
    • 同一帧多个组件互相覆盖。

    9.1 适配组件接口

    public interface IUIAdaptElement
    {
    int AdaptOrder { get; }

    void ApplyCurrentAdapt();
    }

    9.2 执行顺序建议

    组件Order原因
    全屏窗口 / 弹窗根适配 0 先确定大区域
    断点布局 100 再切换不同布局
    安全区锚点 200 再处理局部按钮
    边缘延展 300 最后补背景边缘

    9.3 UIAdaptRuntime

    using System.Collections.Generic;
    using UnityEngine;

    public static class UIAdaptRuntime
    {
    private static readonly List<IUIAdaptElement> s_Elements = new List<IUIAdaptElement>();
    private static readonly List<GameObject> s_DirtyRoots = new List<GameObject>();
    private static readonly List<IUIAdaptElement> s_TempElements = new List<IUIAdaptElement>();

    private static bool s_Registered;

    public static void Register(IUIAdaptElement element)
    {
    if (element == null || s_Elements.Contains(element))
    {
    return;
    }

    s_Elements.Add(element);
    }

    public static void Unregister(IUIAdaptElement element)
    {
    if (element == null)
    {
    return;
    }

    s_Elements.Remove(element);
    }

    public static void MarkDirty(GameObject windowRoot)
    {
    if (windowRoot == null)
    {
    return;
    }

    if (!s_DirtyRoots.Contains(windowRoot))
    {
    s_DirtyRoots.Add(windowRoot);
    }

    RegisterFlush();
    }

    private static void RegisterFlush()
    {
    if (s_Registered)
    {
    return;
    }

    Canvas.preWillRenderCanvases += Flush;
    s_Registered = true;
    }

    private static void UnregisterFlush()
    {
    if (!s_Registered)
    {
    return;
    }

    Canvas.preWillRenderCanvases -= Flush;
    s_Registered = false;
    }

    private static void Flush()
    {
    if (s_DirtyRoots.Count == 0)
    {
    UnregisterFlush();
    return;
    }

    Canvas.ForceUpdateCanvases();

    for (int i = 0; i < s_DirtyRoots.Count; i++)
    {
    RefreshWindow(s_DirtyRoots[i]);
    }

    s_DirtyRoots.Clear();
    UnregisterFlush();
    }

    private static void RefreshWindow(GameObject root)
    {
    if (root == null)
    {
    return;
    }

    Transform rootTransform = root.transform;
    s_TempElements.Clear();

    for (int i = s_Elements.Count – 1; i >= 0; i—)
    {
    IUIAdaptElement element = s_Elements[i];
    Behaviour behaviour = element as Behaviour;
    if (behaviour == null || !behaviour.isActiveAndEnabled)
    {
    s_Elements.RemoveAt(i);
    continue;
    }

    Transform elementTransform = behaviour.transform;
    if (elementTransform == rootTransform || elementTransform.IsChildOf(rootTransform))
    {
    s_TempElements.Add(element);
    }
    }

    s_TempElements.Sort(CompareElement);

    for (int i = 0; i < s_TempElements.Count; i++)
    {
    s_TempElements[i].ApplyCurrentAdapt();
    }

    s_TempElements.Clear();
    }

    private static int CompareElement(IUIAdaptElement left, IUIAdaptElement right)
    {
    if (left == right)
    {
    return 0;
    }

    if (left == null)
    {
    return 1;
    }

    if (right == null)
    {
    return –1;
    }

    return left.AdaptOrder.CompareTo(right.AdaptOrder);
    }
    }

    9.4 窗口打开时怎么接

    你的 UI 框架里,窗口创建并刷新之后调用:

    window.InternalCreate();
    window.InternalRefresh();
    UIAdaptRuntime.MarkDirty(window.gameObject);

    经验:

    适配应该发生在窗口内容刷新之后,渲染之前。


    10. 第八步:全屏窗口适配器

    全屏窗口适配器负责:

    • 根节点落在安全区或主内容区。
    • 背景可以补回真实屏幕。
    • 主体内容不在超宽屏上无限拉宽。

    10.1 推荐 Prefab 层级

    FullScreenWindow
    ├── DFBackground
    ├── DFBackgroundCenter
    ├── vlay_content
    └── BottomBar

    通用命名可以换,但思想不变:

    • Background:铺满真实屏幕。
    • BackgroundCenter:居中的装饰背景。
    • Content:主体内容。
    • BottomBar:底部栏。

    10.2 FullScreenWindowAdapter

    using UnityEngine;

    [ExecuteAlways]
    [RequireComponent(typeof(RectTransform))]
    public class FullScreenWindowAdapter : MonoBehaviour, IUIAdaptElement
    {
    [SerializeField] private bool useSafeArea = true;
    [SerializeField] private bool useMainBodyArea = true;

    public int AdaptOrder
    {
    get { return 0; }
    }

    private RectTransform _rectTransform;

    private RectTransform RectTransform
    {
    get
    {
    if (_rectTransform == null)
    {
    _rectTransform = transform as RectTransform;
    }

    return _rectTransform;
    }
    }

    private void OnEnable()
    {
    UIAdaptRuntime.Register(this);
    UIAdapt.OnAdaptChanged += OnAdaptChanged;
    }

    private void OnDisable()
    {
    UIAdaptRuntime.Unregister(this);
    UIAdapt.OnAdaptChanged -= OnAdaptChanged;
    }

    private void OnAdaptChanged(UIAdaptSnapshot snapshot)
    {
    ApplyCurrentAdapt();
    }

    public void ApplyCurrentAdapt()
    {
    RectTransform rect = RectTransform;
    if (rect == null)
    {
    return;
    }

    RectTransform parent = rect.parent as RectTransform;
    if (parent == null)
    {
    return;
    }

    ResetStretch(rect);

    Rect parentRect = parent.rect;
    Rect targetRect = parentRect;

    if (useSafeArea)
    {
    targetRect = ConvertScreenRectToLocalRect(parent, UIAdapt.Snapshot.SafeRect);
    }

    if (useMainBodyArea)
    {
    targetRect = UIAdapt.ClampRectWidthToAspect(targetRect, UIAdaptConfig.MainBodyAspect);
    }

    ApplyRectAsInsets(rect, parentRect, targetRect);
    ApplyBackgroundCompensation();
    }

    private static void ResetStretch(RectTransform rect)
    {
    rect.anchorMin = Vector2.zero;
    rect.anchorMax = Vector2.one;
    rect.pivot = new Vector2(0.5f, 0.5f);
    rect.localScale = Vector3.one;
    rect.localPosition = Vector3.zero;
    rect.offsetMin = Vector2.zero;
    rect.offsetMax = Vector2.zero;
    }

    private static void ApplyRectAsInsets(RectTransform rect, Rect parentRect, Rect targetRect)
    {
    float left = targetRect.xMin – parentRect.xMin;
    float right = parentRect.xMax – targetRect.xMax;
    float bottom = targetRect.yMin – parentRect.yMin;
    float top = parentRect.yMax – targetRect.yMax;

    rect.offsetMin = new Vector2(left, bottom);
    rect.offsetMax = new Vector2(–right, –top);
    }

    private static Rect ConvertScreenRectToLocalRect(RectTransform parent, Rect screenRect)
    {
    Vector2 leftBottom;
    Vector2 rightTop;

    RectTransformUtility.ScreenPointToLocalPointInRectangle(
    parent,
    new Vector2(screenRect.xMin, screenRect.yMin),
    null,
    out leftBottom);

    RectTransformUtility.ScreenPointToLocalPointInRectangle(
    parent,
    new Vector2(screenRect.xMax, screenRect.yMax),
    null,
    out rightTop);

    return Rect.MinMaxRect(leftBottom.x, leftBottom.y, rightTop.x, rightTop.y);
    }

    private void ApplyBackgroundCompensation()
    {
    // 最小版本可以先不写。
    // 后面需要背景补回真实屏幕时,再让 Background 节点反向抵消根节点 inset。
    }
    }

    10.3 关键经验

    全屏窗口不要把所有东西都塞在根节点拉伸里。要分成:

    • 背景层:可以铺满真实屏幕。
    • 内容层:放在安全区和主内容区。
    • 边缘层:顶部/底部可能需要特殊补偿。

    如果背景和内容不分层,适配就会很难做。


    11. 第九步:弹窗适配器

    弹窗适配和全屏适配不一样。

    全屏窗口是“重新定义窗口可用区域”。
    弹窗是“保持弹窗主体形状,然后按安全比例缩放”。

    11.1 推荐 Prefab 层级

    PopupWindow
    ├── m_btnBgClose
    ├── vlay_content
    │ ├── title
    │ ├── content
    │ └── buttons
    └── closeTips

    要求:

    • vlay_content 必须存在。
    • 弹窗主体内容都放在 vlay_content 下。
    • 根节点不手动拉伸每个子节点。

    11.2 PopupWindowAdapter

    using UnityEngine;

    [ExecuteAlways]
    public class PopupWindowAdapter : MonoBehaviour, IUIAdaptElement
    {
    [SerializeField] private string contentName = "vlay_content";

    public int AdaptOrder
    {
    get { return 0; }
    }

    private void OnEnable()
    {
    UIAdaptRuntime.Register(this);
    UIAdapt.OnAdaptChanged += OnAdaptChanged;
    }

    private void OnDisable()
    {
    UIAdaptRuntime.Unregister(this);
    UIAdapt.OnAdaptChanged -= OnAdaptChanged;
    }

    private void OnAdaptChanged(UIAdaptSnapshot snapshot)
    {
    ApplyCurrentAdapt();
    }

    public void ApplyCurrentAdapt()
    {
    Transform content = transform.Find(contentName);
    if (content == null)
    {
    Debug.LogError("[PopupWindowAdapter] Missing content: " + contentName + " in " + name);
    return;
    }

    float scale = CalculateScale(UIAdapt.Width, UIAdapt.Height);
    Vector3 localScale = content.localScale;
    content.localScale = new Vector3(scale, scale, localScale.z);
    }

    private static float CalculateScale(int width, int height)
    {
    float match = UIAdapt.CalcMatchWidthOrHeight(width, height);
    if (!Mathf.Approximately(match, 1f))
    {
    return 1f;
    }

    float currentAspect = UIAdapt.CalcAspect(width, height);
    if (currentAspect <= UIAdaptConfig.DesignAspect)
    {
    return 1f;
    }

    float safeAspect = Mathf.Max(UIAdaptConfig.DesignAspect, UIAdaptConfig.PopupSafeAspect);
    float targetAspect = Mathf.Min(currentAspect, safeAspect);
    return Mathf.Max(1f, targetAspect / UIAdaptConfig.DesignAspect);
    }
    }

    11.3 为什么弹窗只缩放主体

    因为弹窗内部通常是一个完整设计:

    • 标题和背景框要保持比例。
    • 按钮和文案相对位置要稳定。
    • 列表区域可能有固定尺寸。

    如果每个节点都单独适配,很容易变形。弹窗更适合整体缩放。


    12. 第十步:局部安全区锚点

    有些控件不是整个窗口,而是窗口里的一个局部节点,比如:

    • 顶部返回按钮。
    • 底部操作按钮。
    • 侧边工具栏。
    • 靠右的悬浮入口。

    这类控件应该有一个局部组件来处理。

    12.1 SafeEdgeAnchor 的设计

    它应该支持:

    • 是否考虑安全区。
    • 是否考虑主内容区。
    • 应用左边、右边、顶部、底部。
    • 保存一份设计稿里的基准布局。

    12.2 简化版 SafeEdgeAnchor

    using UnityEngine;

    [ExecuteAlways]
    [RequireComponent(typeof(RectTransform))]
    public class SafeEdgeAnchor : MonoBehaviour, IUIAdaptElement
    {
    [SerializeField] private bool considerSafeArea = true;
    [SerializeField] private bool considerMainBody = false;
    [SerializeField] private bool applyLeft;
    [SerializeField] private bool applyRight;
    [SerializeField] private bool applyTop;
    [SerializeField] private bool applyBottom;

    [SerializeField] private Vector2 baseOffsetMin;
    [SerializeField] private Vector2 baseOffsetMax;
    [SerializeField] private bool hasBaseOffset;

    public int AdaptOrder
    {
    get { return 200; }
    }

    private RectTransform _rectTransform;

    private RectTransform RectTransform
    {
    get
    {
    if (_rectTransform == null)
    {
    _rectTransform = transform as RectTransform;
    }

    return _rectTransform;
    }
    }

    private void OnEnable()
    {
    UIAdaptRuntime.Register(this);
    UIAdapt.OnAdaptChanged += OnAdaptChanged;
    EnsureBaseOffset();
    ApplyCurrentAdapt();
    }

    private void OnDisable()
    {
    UIAdaptRuntime.Unregister(this);
    UIAdapt.OnAdaptChanged -= OnAdaptChanged;
    }

    private void OnAdaptChanged(UIAdaptSnapshot snapshot)
    {
    ApplyCurrentAdapt();
    }

    public void CaptureBase()
    {
    RectTransform rect = RectTransform;
    if (rect == null)
    {
    return;
    }

    baseOffsetMin = rect.offsetMin;
    baseOffsetMax = rect.offsetMax;
    hasBaseOffset = true;
    }

    public void ApplyCurrentAdapt()
    {
    EnsureBaseOffset();

    RectTransform rect = RectTransform;
    RectTransform parent = rect != null ? rect.parent as RectTransform : null;
    if (rect == null || parent == null)
    {
    return;
    }

    Rect targetScreenRect = UIAdapt.Snapshot.ScreenRect;
    if (considerSafeArea)
    {
    targetScreenRect = UIAdapt.Snapshot.SafeRect;
    }

    if (considerMainBody)
    {
    targetScreenRect = UIAdapt.Snapshot.MainBodyRect;
    }

    Rect localTarget = ScreenRectToLocalRect(parent, targetScreenRect);
    Rect parentRect = parent.rect;

    Vector2 nextMin = baseOffsetMin;
    Vector2 nextMax = baseOffsetMax;

    if (applyLeft)
    {
    nextMin.x += localTarget.xMin – parentRect.xMin;
    }

    if (applyRight)
    {
    nextMax.x -= parentRect.xMax – localTarget.xMax;
    }

    if (applyBottom)
    {
    nextMin.y += localTarget.yMin – parentRect.yMin;
    }

    if (applyTop)
    {
    nextMax.y -= parentRect.yMax – localTarget.yMax;
    }

    rect.offsetMin = nextMin;
    rect.offsetMax = nextMax;
    }

    private void EnsureBaseOffset()
    {
    if (hasBaseOffset)
    {
    return;
    }

    CaptureBase();
    }

    private static Rect ScreenRectToLocalRect(RectTransform parent, Rect screenRect)
    {
    Vector2 min;
    Vector2 max;

    RectTransformUtility.ScreenPointToLocalPointInRectangle(
    parent,
    new Vector2(screenRect.xMin, screenRect.yMin),
    null,
    out min);

    RectTransformUtility.ScreenPointToLocalPointInRectangle(
    parent,
    new Vector2(screenRect.xMax, screenRect.yMax),
    null,
    out max);

    return Rect.MinMaxRect(min.x, min.y, max.x, max.y);
    }
    }

    12.3 使用经验

    顶部按钮:

    considerSafeArea = true
    applyTop = true

    底部按钮:

    considerSafeArea = true
    applyBottom = true

    贴主内容区右侧:

    considerMainBody = true
    applyRight = true

    注意:

    • 可点击控件通常贴安全区。
    • 背景装饰通常不贴安全区。
    • 不要同时让多个适配组件抢同一个 RectTransform 的同一条边。

    13. 第十一步:边缘背景延展

    有些节点不是要避开安全区,而是要覆盖安全区。比如:

    • 底部导航背景。
    • 顶部装饰背景。
    • 全屏背景图边缘。

    这时需要反向延展。

    13.1 EdgeExtendAdapter

    using UnityEngine;

    [ExecuteAlways]
    [RequireComponent(typeof(RectTransform))]
    public class EdgeExtendAdapter : MonoBehaviour, IUIAdaptElement
    {
    public enum Direction
    {
    Down,
    Up
    }

    [SerializeField] private Direction direction = Direction.Down;
    [SerializeField] private Vector2 baseOffsetMin;
    [SerializeField] private Vector2 baseOffsetMax;
    [SerializeField] private bool hasBaseOffset;

    public int AdaptOrder
    {
    get { return 300; }
    }

    private RectTransform _rectTransform;

    private RectTransform RectTransform
    {
    get
    {
    if (_rectTransform == null)
    {
    _rectTransform = transform as RectTransform;
    }

    return _rectTransform;
    }
    }

    private void OnEnable()
    {
    UIAdaptRuntime.Register(this);
    UIAdapt.OnAdaptChanged += OnAdaptChanged;
    EnsureBaseOffset();
    ApplyCurrentAdapt();
    }

    private void OnDisable()
    {
    UIAdaptRuntime.Unregister(this);
    UIAdapt.OnAdaptChanged -= OnAdaptChanged;
    }

    private void OnAdaptChanged(UIAdaptSnapshot snapshot)
    {
    ApplyCurrentAdapt();
    }

    public void ApplyCurrentAdapt()
    {
    EnsureBaseOffset();

    RectTransform rect = RectTransform;
    if (rect == null)
    {
    return;
    }

    AdaptMargin margin = UIAdapt.Snapshot.RealAdaptMargin;
    Vector2 nextMin = baseOffsetMin;
    Vector2 nextMax = baseOffsetMax;

    if (direction == Direction.Down)
    {
    nextMin.y -= margin.bottom;
    }
    else
    {
    nextMax.y += margin.top;
    }

    rect.offsetMin = nextMin;
    rect.offsetMax = nextMax;
    }

    public void CaptureBase()
    {
    RectTransform rect = RectTransform;
    if (rect == null)
    {
    return;
    }

    baseOffsetMin = rect.offsetMin;
    baseOffsetMax = rect.offsetMax;
    hasBaseOffset = true;
    }

    private void EnsureBaseOffset()
    {
    if (!hasBaseOffset)
    {
    CaptureBase();
    }
    }
    }

    13.2 什么时候用延展,什么时候用避让

    UI应该避让还是延展
    底部按钮 避让
    底部按钮下面的背景条 延展
    顶部返回按钮 避让
    顶部背景装饰 延展
    全屏背景图 延展
    弹窗主体 不延展,整体缩放

    14. 第十二步:断点布局

    有些界面不是简单缩放能解决的。比如平板上左右结构更合适,手机上上下结构更合适。

    这时要做断点布局。

    14.1 定义屏幕类型

    public enum UIAspectType
    {
    Normal,
    Short,
    MainBody,
    Wide
    }

    public static UIAspectType GetAspectType(float aspect)
    {
    if (aspect <= UIAdaptConfig.DesignAspect)
    {
    return UIAspectType.Normal;
    }

    if (aspect <= UIAdaptConfig.ShortAspect)
    {
    return UIAspectType.Short;
    }

    if (aspect <= UIAdaptConfig.MainBodyAspect)
    {
    return UIAspectType.MainBody;
    }

    return UIAspectType.Wide;
    }

    14.2 断点布局的经验

    断点布局适合:

    • 商店页在宽屏上改成左右两栏。
    • 背包页在平板上增加列数。
    • 主界面按钮在超宽屏上靠主内容区而不是靠屏幕边。
    • 对话界面在宽屏上让人物和文本位置重排。

    断点布局不适合:

    • 只是顶部按钮避开刘海。
    • 只是底部背景延展。
    • 只是弹窗放大。

    也就是说,只有“布局结构变了”,才用断点布局。


    15. 第十三步:世界浮层和气泡

    世界浮层通常有目标点,比如地图建筑、怪物、资源点。它的逻辑是:

  • 世界坐标转屏幕坐标。
  • 屏幕坐标转 UI 本地坐标。
  • 根据浮层大小和 pivot 计算边界。
  • 如果超出安全区,就把它钳制回来。
  • 15.1 简化版钳制函数

    using UnityEngine;

    public static class UIOverlayClampUtility
    {
    public static Vector2 ClampLocalPosition(
    RectTransform panel,
    RectTransform parent,
    Vector2 targetLocalPosition,
    float marginX,
    float marginY)
    {
    if (panel == null || parent == null)
    {
    return targetLocalPosition;
    }

    Rect safeRect = ScreenRectToLocalRect(parent, UIAdapt.Snapshot.SafeRect);
    safeRect.xMin += marginX;
    safeRect.xMax -= marginX;
    safeRect.yMin += marginY;
    safeRect.yMax -= marginY;

    Vector2 size = panel.rect.size;
    Vector2 pivot = panel.pivot;

    float minX = safeRect.xMin + size.x * pivot.x;
    float maxX = safeRect.xMax – size.x * (1f – pivot.x);
    float minY = safeRect.yMin + size.y * pivot.y;
    float maxY = safeRect.yMax – size.y * (1f – pivot.y);

    return new Vector2(
    Mathf.Clamp(targetLocalPosition.x, minX, maxX),
    Mathf.Clamp(targetLocalPosition.y, minY, maxY));
    }

    private static Rect ScreenRectToLocalRect(RectTransform parent, Rect screenRect)
    {
    Vector2 min;
    Vector2 max;

    RectTransformUtility.ScreenPointToLocalPointInRectangle(
    parent,
    new Vector2(screenRect.xMin, screenRect.yMin),
    null,
    out min);

    RectTransformUtility.ScreenPointToLocalPointInRectangle(
    parent,
    new Vector2(screenRect.xMax, screenRect.yMax),
    null,
    out max);

    return Rect.MinMaxRect(min.x, min.y, max.x, max.y);
    }
    }

    经验:

    浮层不是“固定在安全区里”,而是“先跟随目标,再防止越界”。


    16. Prefab 制作规范

    框架再好,Prefab 不规范也会乱。

    16.1 全屏界面规范

    根节点:

    • 挂 Canvas
    • 挂 GraphicRaycaster
    • 挂 FullScreenWindowAdapter

    推荐层级:

    XXXFullScreenUI
    ├── Background
    │ └── bg_full
    ├── BackgroundCenter
    │ └── bg_center
    ├── Content
    │ ├── Title
    │ ├── List
    │ └── Buttons
    └── BottomBar
    ├── bg_bar
    └── buttons

    制作经验:

    • 背景放背景层,不要和内容混在一起。
    • 重要按钮放 Content 或 BottomBar,不要放超宽屏两侧。
    • 列表区域用锚点撑开,不要固定死高度。
    • 底部栏背景可以用 EdgeExtendAdapter 向下补。

    16.2 弹窗规范

    根节点:

    • 挂 Canvas
    • 挂 GraphicRaycaster
    • 挂 PopupWindowAdapter

    推荐层级:

    XXXPopupUI
    ├── m_btnBgClose
    ├── vlay_content
    │ ├── bg
    │ ├── title
    │ ├── body
    │ └── buttons
    └── closeTips

    制作经验:

    • 弹窗主体必须统一放到 vlay_content。
    • 不要在弹窗内部根据屏幕比例写一堆位置逻辑。
    • 弹窗内部列表可以自己滚动,但弹窗整体由 PopupWindowAdapter 控制。
    • 背景关闭按钮铺满根节点。

    16.3 主界面规范

    主界面通常最复杂,因为它同时有:

    • 背景场景。
    • 顶部资源栏。
    • 左右按钮组。
    • 底部功能栏。
    • 世界入口。
    • 活动入口。

    推荐原则:

    • 资源栏贴安全区顶部。
    • 底部功能栏贴安全区底部。
    • 底部背景延展到真实底边。
    • 左右按钮组贴主内容区边缘,不贴超宽屏真实边缘。
    • 场景背景或世界场景可以铺满真实屏幕。

    17. 写业务 UI 时的判断流程

    每次接新 UI,可以按这个流程问自己。

    17.1 第一个问题:这是全屏还是弹窗

    全屏:

    • 有完整背景。
    • 可能占满整个游戏视图。
    • 有标题、返回、底栏、列表。

    用:

    FullScreenWindowAdapter

    弹窗:

    • 在当前界面上弹出。
    • 中间有主体框。
    • 点击空白可能关闭。

    用:

    PopupWindowAdapter

    17.2 第二个问题:背景和内容有没有分层

    如果没有分层,先拆层。

    错误做法:

    Root
    └── 全部东西混在一起

    推荐做法:

    Root
    ├── Background
    ├── Content
    └── EdgeBar

    17.3 第三个问题:哪些东西可点击

    可点击的东西要优先避开安全区:

    • 返回按钮
    • 关闭按钮
    • 底部操作按钮
    • 页签
    • 输入框

    不可点击的装饰可以延展:

    • 背景底图
    • 底部装饰条
    • 顶部光效

    17.4 第四个问题:宽屏时主体应该怎么办

    常见选择:

  • 居中限制宽度。
  • 改成左右布局。
  • 增加列表列数。
  • 两侧只放背景,不放交互。
  • 不要默认拉满。

    17.5 第五个问题:短屏时内容是否能滚

    短屏最容易出问题的是:

    • 奖励列表。
    • 商店商品。
    • 文本说明。
    • 多按钮区域。

    如果短屏显示不下,优先考虑:

    • 中间内容滚动。
    • 缩小上下留白。
    • 次要信息折叠。

    不要把按钮挤出屏幕。


    18. 常见场景怎么写

    18.1 底部按钮被 Home Indicator 挡住

    判断:

    • 按钮是可点击内容。
    • 应该避开安全区。

    做法:

    按钮节点挂 SafeEdgeAnchor
    considerSafeArea = true
    applyBottom = true

    不要做:

    button.anchoredPosition += new Vector2(0, 30);

    因为不同设备底部安全区不同,写死数值不可靠。

    18.2 底部背景没有铺满

    判断:

    • 背景不是可点击内容。
    • 应该延展到真实屏幕边缘。

    做法:

    底部背景节点挂 EdgeExtendAdapter
    direction = Down

    18.3 平板上主体太宽

    判断:

    • 主体内容不应该铺满真实屏幕。
    • 应该限制主内容区。

    做法:

    FullScreenWindowAdapter
    useMainBodyArea = true

    如果只是内容区限制还不够,再做断点布局。

    18.4 弹窗在宽屏上太小

    判断:

    • 弹窗主体应该保持形状并适度放大。

    做法:

    PopupWindowAdapter
    确保 vlay_content 存在

    不要把弹窗背景、按钮、标题分别写屏幕判断。

    18.5 世界气泡跑到屏幕外

    判断:

    • 气泡是跟随目标的浮层。
    • 位置来源是世界目标,不是固定锚点。

    做法:

    Vector2 localPosition = WorldToUILocalPosition(targetWorldPosition);
    localPosition = UIOverlayClampUtility.ClampLocalPosition(
    bubbleRect,
    parentRect,
    localPosition,
    20f,
    20f);
    bubbleRect.anchoredPosition = localPosition;


    19. 测试矩阵

    适配框架必须靠测试矩阵兜底。

    19.1 必测分辨率

    分辨率目的
    1080×2339 设计基准
    1080×2520 超长手机
    1080×1920 短屏手机
    1753×2339 平板比例
    2339×1753 横屏/特殊场景
    3366×2339 超宽屏/PC

    19.2 必测安全区

    至少模拟:

    • 顶部刘海。
    • 底部 Home Indicator。
    • 左右圆角。
    • 上下同时有安全区。

    19.3 每个界面检查项

    全屏界面:

    • 背景是否铺满。
    • 主体是否太宽。
    • 顶部按钮是否避开刘海。
    • 底部按钮是否避开底条。
    • 底部背景是否延展。
    • 列表是否能滚。

    弹窗:

    • vlay_content 是否存在。
    • 宽屏是否适度放大。
    • 窄屏是否贴边。
    • 背景关闭区域是否铺满。
    • 弹窗内容是否变形。

    主界面:

    • 顶栏是否贴安全区。
    • 底栏按钮是否可点。
    • 侧边按钮是否跑到超宽屏边缘。
    • 背景是否铺满。
    • 弹窗打开后是否仍正常。

    世界浮层:

    • 目标在屏幕边缘时是否被钳制。
    • 浮层是否挡住安全区。
    • 浮层 pivot 不同时是否正确。

    20. 排查问题的顺序

    遇到适配问题,不要直接改数值。按这个顺序查。

    20.1 先看内容分类

    问:

    • 这是背景还是内容?
    • 这是可点击还是装饰?
    • 这是固定 UI 还是跟随目标?
    • 这是全屏还是弹窗?

    分类错了,后面怎么调都别扭。

    20.2 再看根节点

    问:

    • 根 Canvas 的 CanvasScaler 是否正确。
    • 全屏窗口是否挂了 FullScreenWindowAdapter。
    • 弹窗是否挂了 PopupWindowAdapter。
    • 弹窗是否有 vlay_content。

    20.3 再看局部组件

    问:

    • 贴边按钮是否挂了 SafeEdgeAnchor。
    • 背景延展是否挂了 EdgeExtendAdapter。
    • 是否有多个组件同时改同一个 RectTransform。

    20.4 再看刷新时机

    问:

    • 窗口打开后有没有 UIAdaptRuntime.MarkDirty(root)。
    • 动态创建节点后有没有重新标脏。
    • 是否在适配之后业务脚本又改了 RectTransform。

    20.5 最后才看算法

    只有确认上面都没问题,再看:

    • 安全区边距是否正确。
    • 屏幕矩形转本地矩形是否正确。
    • 主内容区宽度限制是否正确。
    • 弹窗缩放比例是否正确。

    经验:

    适配 bug 大多数不是数学错,而是层级、分类、时机、组件职责错。


    21. 一周实现计划

    如果真的要从 0 写,可以按这个节奏。

    第 1 天:跑通核心数据

    完成:

    • UIAdaptConfig
    • AdaptMargin
    • UIAdaptSnapshot
    • UIAdapt
    • UnitySafeAreaPlatform

    验收:

    • 能打印当前屏幕宽高。
    • 能打印安全区边距。
    • 能打印主内容区 Rect。
    • 改分辨率能触发刷新。

    第 2 天:跑通根 Canvas 和全屏窗口

    完成:

    • RootCanvasAdapter
    • IUIAdaptElement
    • UIAdaptRuntime
    • FullScreenWindowAdapter

    验收:

    • 标准手机显示正常。
    • 超宽屏主体居中且不拉满。
    • 安全区生效。
    • 背景和内容能分层。

    第 3 天:跑通弹窗

    完成:

    • PopupWindowAdapter
    • 弹窗模板

    验收:

    • 弹窗在标准屏比例正常。
    • 弹窗在宽屏上适度放大。
    • 弹窗在窄屏上不贴边。
    • 缺少 vlay_content 会报错。

    第 4 天:跑通局部按钮和背景延展

    完成:

    • SafeEdgeAnchor
    • EdgeExtendAdapter

    验收:

    • 顶部按钮避开刘海。
    • 底部按钮避开 Home Indicator。
    • 底部背景延展到真实底边。
    • 超宽屏侧边按钮贴主内容区,不贴真实屏幕边。

    第 5 天:跑通断点布局和世界浮层

    完成:

    • RectTransformPresetAdapter
    • UIOverlayClampUtility

    验收:

    • 平板比例能切另一套布局。
    • 世界气泡在屏幕边缘不会出界。

    第 6 天:补编辑器工具

    完成:

    • 分辨率预设。
    • 安全区模拟。
    • 一键刷新当前窗口。

    验收:

    • 不进真机也能在编辑器里测大部分问题。

    第 7 天:整理规范和接入项目

    完成:

    • 全屏模板。
    • 弹窗模板。
    • 主界面示例。
    • 检查清单。

    验收:

    • 新人能复制模板做一个界面。
    • 不需要读框架源码也能知道组件怎么挂。

    22. 最终你应该记住的框架

    可以把整套适配框架记成 8 句话:

  • 先定设计分辨率。
  • 再统一屏幕、安全区、主内容区数据。
  • 根 Canvas 只管全局缩放。
  • 全屏窗口负责把主体放进安全区和主内容区。
  • 弹窗负责保持主体形状并按安全比例缩放。
  • 局部按钮用安全区锚点。
  • 背景边缘用反向延展。
  • 屏幕变化后统一标脏、统一刷新、按顺序执行。
  • 更短一点:

    全局统一算,窗口统一套,局部组件补边界,业务尽量不写屏幕适配。

    这就是能迁移到任何项目里的 UI 适配经验。

    赞(0)
    未经允许不得转载:171主机测评 » UI 适配框架从 0 实现(经验总结)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址