作者最新更新

游戏开发工具

FairyGUI刘海屏、异形屏适配方案

966

4 次收藏2024-10-17 08:12:59发布
上一节:FairyGui简单介绍下一节:

现在主流的手机基本上都是刘海屏、挖孔屏、灵动岛等异形屏, 因此不可避免的我们需要对游戏中的界面进行相关的适配工作。

一、常见的界面类型划分

首先我们引入一个“安全区域”的概念, 这个概念在Android、iOS开发中很常见, Unity也在UnityEngine.Screen中提供了safeArea属性用于获取安全区域的范围, 大家可以在异形屏的手机上分别打印Screen.safeArea和Screen.width、Screen.height来观察它们值的差异. 关于该接口的说明可以参考下图或官方文档.(这个接口在2020和以下版本的Unity中, 对于灵动岛设备支持有问题, 可以自己通过设备信息或Objective-c的代码进行替代, 不在本文中过多描述该问题)

1.jpg

屏幕高度(即屏幕最高点):Screen.height,安全区域最高点:Screen.safeArea.yMax,屏幕顶部异形区域大小:Screen.height - Screen.safeArea.yMax

2.jpg

(安全区域最低点:Screen.safeArea.yMin,可用于屏幕底部适配(苹果Touch Bar))

//计算屏幕顶部异形大小
int safeAreaOffset = (int)(Screen.height - Screen.safeArea.yMax);
//UI偏移
RectTransform rectTransform = transform.Find("SafeArea").GetComponent<RectTransform>();
rectTransform.offsetMax = new Vector2(0,-safeAreaOffset);

TestPanel:UI界面根节点,添加背景图片,可忽略异形屏的影响将图片填充满屏幕

SafeArea:安全区域根节点,在UI被加载后执行以上代码,可以根据异形屏大小动态设置顶部偏移量,避免被异形屏遮挡

未广泛测试机型,有待验证! 还可以根据设备类型(SystemInfo.deviceModel)、设备名称(SystemInfo.deviceName),手动设置每个机型的偏移量


二、常见的界面类型划分

根据游戏的类型不同、需求上的差异, 对于界面的适配通常会有所差异, 一般上可以大致分为以下三种类型:

1、界面尺寸始终保持设计分辨率不变, 但可能存在需要与屏幕保持水平位置或垂直位置方向上居中的需求(例如确认弹窗、限时礼包等弹出式界面)

2、界面尺寸和屏幕尺寸始终保持相同大小(例如背包等全屏界面)

3、界面和安全区域始终保持相同大小、相同位置(例如moba、fps游戏的战斗操作界面)


在上述三种适配规则的基础上, 一些额外的需求

界面中的背景图和屏幕尺寸始终保持相同大小, 但部分可操作的控件希望始终在安全区域范围内。

全屏、安全区域适配的尺寸希望有限制(例如只希望适配720x1280 - 720x1480范围内的屏幕), 超出适配范围的部分用纯色或静态图填充。


希望玩家能自行调整安全区域的尺寸

在下文中将以Unity+FairyGUI为例针对上述提到的几种界面适配需求进行一一说明如何进行制作。


三、屏幕适配服务接口的封装与实现

在上文中提到,Unity为我们提供了现成的Screen.safeArea接口获取安全区域范围,但受限于各项目所用的Unity引擎版本, 可能对部分新设备的支持不够到位(例如上文提到的Unity2020版本获取灵动岛区域存在问题),以及为了方便开发, 我们希望在UnityEditor环境下进行方便的安全区域修改与调试, 来及时看到适配效果, 所以自行封装一个获取安全区域的屏幕适配服务接口是一件很有必要的事情。 以我项目中的代码为例,定义出接口IScreenAdaptorService:

/// <summary>
/// 屏幕适配服务接口。
/// </summary>
public interface IScreenAdaptorService
{
	/// <summary>
	/// 获取安全区域范围
	/// </summary>
	ObservableVariable<Rect> SafeArea { get; }
}

并实现在UnityEditor上专用的实现类:

/// <summary>
/// 编辑器屏幕适配服务。
/// </summary>
internal sealed class EditorScreenAdaptorService : IScreenAdaptorService, IInitialize
{
	public ObservableVariable<Rect> SafeArea { get; } = new ObservableVariable<Rect>();

	public void Initialize()
	{
		_debuggerGameObject = new GameObject("AdaptorDebugger");
		_debugger = _debuggerGameObject.AddComponent<AdaptorDebugger>();
		_debugger.OnDataDirty += () => SafeArea.Value = _debugger.GetSafeArea();

		SafeArea.Value = _debugger.GetSafeArea();
	}

	private GameObject _debuggerGameObject;
	private AdaptorDebugger _debugger;

	private sealed class AdaptorDebugger : MonoBehaviour
	{
		public event Action OnDataDirty;

		public int offsetTop;
		public int offsetBottom;
		public int offsetLeft;
		public int offsetRight;

		private int m_LastOffsetTop;
		private int m_LastOffsetBottom;
		private int m_LastOffsetLeft;
		private int m_LastOffsetRight;

		private int m_LastWidth;
		private int m_LastHeight;

		private void Awake()
		{
			DontDestroyOnLoad(gameObject);

			m_LastWidth = Screen.width;
			m_LastHeight = Screen.height;
		}

		public Rect GetSafeArea()
		{
			var x = offsetLeft;
			var y = offsetTop;
			var w = Screen.width - offsetRight - x;
			var h = Screen.height - offsetBottom - y;
			return new Rect(x, y, w, h);
		}

		private void Update()
		{
			var modify1 = UpdateInputData();
			var modify2 = UpdateScreenSize();

			if (!modify1 && !modify2)
				return;

			OnDataDirty?.Invoke();
		}

		private bool UpdateInputData()
		{
			if (offsetTop == m_LastOffsetTop &&
				offsetBottom == m_LastOffsetBottom &&
				offsetLeft == m_LastOffsetLeft &&
				offsetRight == m_LastOffsetRight)
				return false;

			m_LastOffsetTop = offsetTop = Math.Max(0, offsetTop);
			m_LastOffsetBottom = offsetBottom = Math.Max(0, offsetBottom);
			m_LastOffsetLeft = offsetLeft = Math.Max(0, offsetLeft);
			m_LastOffsetRight = offsetRight = Math.Max(0, offsetRight);
			return true;
		}

		private bool UpdateScreenSize()
		{
			var width = Screen.width;
			var height = Screen.height;

			if (width == m_LastWidth && height == m_LastHeight)
				return false;

			m_LastWidth = width;
			m_LastHeight = height;
			return true;
		}
	}
}

在这个类中构造了一个GameObject实例, 并为其添加上调试组件, 这样我们就可以很方便的在面板上进行设置安全区域的范围。

1.jpg

四、UI界面适配服务接口的封装与实现

在屏幕适配服务接口的基础上, 我们还需要封装一个UI界面适配服务的接口, 因为屏幕尺寸、屏幕安全区域尺寸 和 UI全屏尺寸、UI安全区域尺寸并不是相等的关系, 以FairyGUI为例, 我们在GRoot的代码中可以看到它会根据我们传入的缩放模式、设计分辨率与实时屏幕尺寸进行计算, 得到一个合适的屏幕缩放比例, 然后根据该缩放比例与实时的屏幕尺寸计算出GRoot的尺寸(即UI的全屏尺寸):

/// <summary>
/// Set content scale factor.
/// </summary>
/// <param name="designResolutionX">Design resolution of x axis.</param>
/// <param name="designResolutionY">Design resolution of y axis.</param>
/// <param name="screenMatchMode">Match mode.</param>
public void SetContentScaleFactor(int designResolutionX, int designResolutionY, UIContentScaler.ScreenMatchMode screenMatchMode)
{
	UIContentScaler scaler = Stage.inst.gameObject.GetComponent<UIContentScaler>();
	scaler.designResolutionX = designResolutionX;
	scaler.designResolutionY = designResolutionY;
	scaler.scaleMode = UIContentScaler.ScaleMode.ScaleWithScreenSize;
	scaler.screenMatchMode = screenMatchMode;
	scaler.ApplyChange();
	ApplyContentScaleFactor();
}

/// <summary>
/// This is called after screen size changed.
/// </summary>
public void ApplyContentScaleFactor()
{
	this.SetSize(Mathf.CeilToInt(Stage.inst.width / UIContentScaler.scaleFactor), Mathf.CeilToInt(Stage.inst.height / UIContentScaler.scaleFactor));
	this.SetScale(UIContentScaler.scaleFactor, UIContentScaler.scaleFactor);
}

所以我们需要封装UI界面适配器接口, 提供实际的UI全屏尺寸与UI安全区域尺寸供外部使用:

/// <summary>
/// UI 界面适配服务接口。
/// </summary>
public interface IUIScreenAdaptorService
{
	/// <summary>
	/// UI 屏幕大小。(逻辑尺寸)
	/// </summary>
	ObservableVariable<Vector2Int> UIScreenSize { get; }
	
	/// <summary>
	/// UI 安全区域。(逻辑尺寸)
	/// </summary>
	ObservableVariable<Rect> UISafeArea { get; }
}

基于FairyGUI实现的UI界面适配服务类实现如下:

/// <summary>
/// FairyGUI 界面适配服务。
/// </summary>
internal sealed class FairyGUIScreenAdaptorService : IUIScreenAdaptorService, IInitialize, IDisposable
{
	[Inject]
	public IScreenAdaptorService ScreenAdaptorService { get; set; }
	
	public ObservableVariable<Vector2Int> UIScreenSize { get; } = new ObservableVariable<Vector2Int>();
	public ObservableVariable<Rect> UISafeArea { get; } = new ObservableVariable<Rect>();

	public void Initialize()
	{
		ScreenAdaptorService.SafeArea.Subscribe(OnSafeAreaChanged);
		GRoot.inst.onSizeChanged.Add(OnSizeChanged);
		UpdateSize();
	}

	public void Dispose()
	{
		GRoot.inst.onSizeChanged.Remove(OnSizeChanged);
		ScreenAdaptorService.SafeArea.Unsubscribe(OnSafeAreaChanged);
	}

	private void OnSafeAreaChanged(Rect _, Rect __)
	{
		UpdateSize();
	}

	private void OnSizeChanged()
	{
		UpdateSize();
	}
	
	private void UpdateSize()
	{
		var safeArea = ScreenAdaptorService.SafeArea.Value;
		var factor = 1f / GRoot.contentScaleFactor;
		safeArea.x = Mathf.CeilToInt(safeArea.x * factor);
		safeArea.y = Mathf.CeilToInt(safeArea.y * factor);
		safeArea.width = Mathf.CeilToInt(safeArea.width * factor);
		safeArea.height = Mathf.CeilToInt(safeArea.height * factor);
		
		UIScreenSize.Value = new Vector2Int((int)GRoot.inst.width, (int)GRoot.inst.height);
		UISafeArea.Value = safeArea;
	}
}

可以看到我们在这里根据GRoot计算出的屏幕缩放比例将屏幕安全区域换算成了UI安全区域.

五、界面适配器接口的封装

前文提到, 根据界面类型的不同与需求的不同, 存在多种屏幕适配的方案, 所以我们基于FairyGUI抽象出统一的适配器接口, 供界面的逻辑实现中自行指定适配器实例.

/// <summary>
/// 界面屏幕适配器接口
/// </summary>
public interface IUIFormScreenAdaptor : IDisposable
{
	/// <summary>
	/// 初始化适配器
	/// </summary>
	/// <param name="contentPane">UI界面对象</param>
	/// <param name="uiRoot">UI分组根结点对象</param>
	void Initialize(GComponent contentPane, GComponent uiRoot);
}

/// <summary>
/// FairyGUI界面逻辑基类的一部分
/// </summary>
public partial class AFairyGUIFormLogic
{
	/// <summary>
	/// 屏幕适配器 由界面自行重载指定
	/// </summary>
	protected abstract IUIFormScreenAdaptor ScreenAdaptor { get; }

	private void _OnInitScreenAdapter()
	{
		var uiGroupHelper = (FairyGUIGroupHelper)UIForm.UIGroup.Helper;
		var uiGroupRoot = uiGroupHelper.GroupRoot;
		
		ScreenAdaptor.Initialize(ContentPane, uiGroupRoot);
	}

	private void _OnRecycleScreenAdapter()
	{
		ScreenAdaptor.Dispose();
	}
}


六、固定尺寸界面适配器的实现

固定尺寸界面的适配方案很简单, 只关心位置不关心尺寸, 直接贴上源码:

/// <summary>
/// 固定尺寸界面适配器, 界面尺寸不随屏幕尺寸变化, 提供位置适配功能
/// </summary>
public sealed class ConstantUIFormScreenAdaptor : IUIFormScreenAdaptor
{
	private readonly bool m_IsHorizontalCenter;
	private readonly bool m_IsVerticalCenter;

	private GComponent m_ContentPane;
	private GComponent m_UIGroupRoot;

	public ConstantUIFormScreenAdaptor(bool isHorizontalCenter = true, bool isVerticalCenter = true)
	{
		m_IsHorizontalCenter = isHorizontalCenter;
		m_IsVerticalCenter = isVerticalCenter;
	}

	public void Initialize(GComponent contentPane, GComponent uiRoot)
	{
		m_ContentPane = contentPane;
		m_UIGroupRoot = uiRoot;

		var xy = Vector2.zero;

		if (m_IsHorizontalCenter)
			xy.x = (uiRoot.width - contentPane.width) / 2;

		if (m_IsVerticalCenter)
			xy.y = (uiRoot.height - contentPane.height) / 2;

		contentPane.SetXY(xy.x, xy.y, true);

		if (m_IsHorizontalCenter)
			contentPane.AddRelation(uiRoot, RelationType.Center_Center);

		if (m_IsVerticalCenter)
			contentPane.AddRelation(uiRoot, RelationType.Middle_Middle);
	}

	public void Dispose()
	{
		if (m_IsHorizontalCenter)
			m_ContentPane.RemoveRelation(m_UIGroupRoot, RelationType.Center_Center);

		if (m_IsVerticalCenter)
			m_ContentPane.RemoveRelation(m_UIGroupRoot, RelationType.Middle_Middle);
	}
}

以确认弹窗为例, 可以看到运行时它的大小并不会发生变化, 且始终在屏幕中央:

1.jpg

FairyGUI编辑器中的设置

2.jpg

代码中指定使用固定尺寸适配器, 并保持水平居中、垂直居中

运行时效果图

2-1.jpg


上一节:FairyGui简单介绍下一节: