1. 项目概述:横屏游戏在Safari中的“全屏”执念
做移动端H5游戏,尤其是用Cocos Creator开发的横屏游戏,开发者几乎都会遇到一个共同的“心病”:在iOS的Safari浏览器里,那个顽固的地址栏和底部的工具栏,总会占据宝贵的屏幕空间。对于追求沉浸式体验的游戏来说,这简直是视觉和操作上的双重干扰。玩家一滑动,地址栏就缩回去,再一滑动,它又弹出来,游戏画面跟着上下跳动,体验非常割裂。
这个需求,业内通常称之为“隐藏地址栏”或“全屏模式”,但严格来说,我们无法真正“隐藏”系统级的UI组件。我们的目标,是让游戏页面在Safari中启动后,能够 稳定地占据整个可视区域(Viewport) ,并且 阻止用户手势(如下拉)触发地址栏的显示 ,从而模拟出一个接近原生App的全屏效果。这不仅仅是美观问题,更关乎游戏核心交互的稳定性和专业性。
围绕这个目标,网络上流传着各种代码片段和方案,但很多都语焉不详,或者只在特定iOS版本、特定Cocos Creator版本下有效。今天,我就结合自己多次踩坑和项目实战的经验,为你系统梳理并实现三种经过验证的实用方案。我会附上完整的、可直接嵌入Cocos Creator项目的代码,并详细解释每种方案的原理、适用场景以及那些文档里不会写的“坑”。
2. 核心原理与Safari特性解析
在动手写代码之前,我们必须先理解我们要对抗的是什么。Safari(特别是iOS上的)对网页全屏有着自己的一套规则,这与Chrome等浏览器差异很大。
2.1 视口(Viewport)与Safari的“最小高度”
移动端网页的布局基础是
viewport
meta标签。对于横屏游戏,我们通常会这样设置:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
关键参数是
viewport-fit=cover
,它告诉浏览器,我们希望网页内容覆盖整个屏幕(包括刘海屏、圆角等区域)。然而,这只是一个“请求”,Safari不一定会完全遵守。
Safari有一个内在逻辑:为了确保地址栏可以舒适地显示和隐藏,它会为页面内容区域计算一个“最小高度”。这个高度通常是
window.innerHeight
(当前可视区域高度)加上地址栏本身的高度
。当你下拉页面时,地址栏展开,
window.innerHeight
变小;上推时,地址栏收缩,
window.innerHeight
变大。我们的目标,就是让游戏画布的高度,始终等于地址栏收缩状态下的
window.innerHeight
,并锁死它。
2.2 关键CSS:
100vh
与
100%
的陷阱
这是第一个大坑。你可能习惯性地将画布容器的高度设为
100vh
(视口高度的100%)。但在Safari中,
100vh
这个单位包含了
动态的地址栏和底部工具栏区域
。也就是说,当地址栏展开时,
100vh
是包含它的总高度;当地址栏收缩时,
100vh
还是那个总高度。这会导致你的游戏画布高度永远比可视区域多出一截(地址栏的高度),从而出现垂直滚动条。
核心技巧 :在Safari中实现稳定全屏, 绝对不要依赖
100vh来定义主容器高度 。应该使用height: 100%,并确保从html到body再到你的画布容器,每一层的高度都明确或继承为100%,同时配合JavaScript动态获取并设置window.innerHeight为像素值。
2.3 手势与滚动阻止
即使画面填满了,用户一个下拉手势,地址栏又会优雅地(对我们来说是讨厌地)滑出。因此,我们需要阻止
touchmove
等事件的默认行为。但这里必须非常小心:不能简单地在
document
上全局阻止
touchmove
,否则你的游戏所有触摸交互都会失效。我们需要精准控制,只阻止可能引起页面滚动的边缘手势(如在页面顶部下拉)。
3. 方案一:视口锁定与动态高度设置(基础可靠版)
这是最经典、兼容性相对最好的方案。其核心思想是:在页面加载和窗口尺寸变化时,动态地将
document.body
的高度设置为
window.innerHeight
,并禁用滚动。
完整代码实现(在Cocos Creator的
main.js
或项目加载的初始脚本中):
(function() {
// 立即设置初始高度,防止页面加载时闪烁
function setViewportHeight() {
let vh = window.innerHeight * 0.01;
// 设置CSS自定义属性,方便CSS使用
document.documentElement.style.setProperty('--vh', `${vh}px`);
// 关键:将body高度直接设为当前innerHeight
document.body.style.height = window.innerHeight + 'px';
// 禁用滚动
document.body.style.overflow = 'hidden';
// 对于Cocos画布容器,也需要设置
let canvas = document.getElementById('GameCanvas');
if (canvas && canvas.parentElement) {
canvas.parentElement.style.height = window.innerHeight + 'px';
}
}
// 页面加载时执行
window.addEventListener('load', setViewportHeight);
// Safari在地址栏收缩/展开时会触发resize,但频率需要控制
window.addEventListener('resize', function() {
// 使用防抖,避免频繁重绘导致性能问题
clearTimeout(this._resizeTimer);
this._resizeTimer = setTimeout(setViewportHeight, 150);
});
// 阻止下拉刷新(谨慎使用)
document.body.addEventListener('touchmove', function (e) {
// 只在页面已经滚动到顶部,且继续下拉时阻止
if (window.scrollY <= 0 && e.touches[0].clientY > 0) {
e.preventDefault();
}
}, { passive: false }); // 必须设置 passive: false 才能 preventDefault
// 初始执行一次
setViewportHeight();
})();
对应的CSS建议(在项目的样式文件或
style
标签中):
html, body {
margin: 0;
padding: 0;
width: 100%;
/* 使用JS动态设置的高度,而非100vh */
height: 100%;
overflow: hidden;
background-color: #000; /* 避免屏幕边缘露白 */
}
#GameDiv { /* Cocos Creator画布的外层容器 */
width: 100%;
height: 100%;
position: relative;
}
实操心得与注意事项:
-
执行时机至关重要
:代码必须在Cocos引擎初始化
之前
执行,最好放在
index.html的<head>底部或<body>开头。如果放在Cocos的onLoad里就太晚了,页面已经完成了初始布局。 -
passive: false:在添加touchmove事件监听器时,{ passive: false }这个选项必须加上。现代浏览器为了滚动性能,默认将touchmove事件设为passive: true,这会导致你在事件处理函数中调用e.preventDefault()无效。但请注意,这可能会轻微影响滚动性能,所以我们的判断条件要尽量精准。 -
防抖(Debounce)
:
resize事件在Safari地址栏交互时可能高频触发,不加防抖会导致页面不断重绘,消耗性能。150ms的延迟是一个平衡点。 -
刘海屏适配
:
viewport-fit=cover配合这个方案,可以让内容延伸到刘海区域。但要注意重要UI(如按钮、分数)需要放在“安全区(Safe Area)”内,可以通过CSS的env(safe-area-inset-top)等变量来调整内边距。
4. 方案二:Meta标签动态改写与强制缩放(激进兼容版)
有些更老的iOS版本或特殊场景下,方案一可能不够彻底。方案二采用一种更“强硬”的手段:动态改写
viewport
的
initial-scale
值,强制浏览器以特定缩放比例打开,从而“挤占”掉地址栏的空间。
核心原理
:通过计算
window.outerHeight
(浏览器完整高度)与
window.innerHeight
(可视区域高度)的比例,动态设置一个小于1的
initial-scale
值,让页面初始加载时就呈现缩放状态,促使Safari将地址栏区域也算作“页面内容”的一部分而隐藏。
完整代码实现:
(function() {
function forceHideAddressBar() {
// 检测是否为iOS Safari
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) || /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (!(isIos && isSafari)) {
return; // 非目标浏览器,退出
}
const metaViewport = document.querySelector('meta[name="viewport"]');
if (!metaViewport) {
console.warn('未找到viewport meta标签');
return;
}
// 计算缩放比例
// 理想情况是 outerHeight / innerHeight,但需要限制范围
const outerHeight = window.outerHeight;
const innerHeight = window.innerHeight;
let scaleRatio = 1;
// 仅在内外高度有显著差异时(表明地址栏可见)尝试缩放
if (outerHeight > innerHeight && innerHeight > 0) {
// 比例通常很接近1,我们设置一个略小的值,如0.99
scaleRatio = 0.99;
// 也可以动态计算,但风险较大:scaleRatio = innerHeight / outerHeight;
}
// 构建新的viewport content
const originalContent = metaViewport.getAttribute('content');
let contentAttrs = {};
originalContent.split(',').forEach(item => {
const pair = item.trim().split('=');
if (pair.length === 2) {
contentAttrs[pair[0]] = pair[1];
}
});
// 更新或添加 initial-scale
contentAttrs['initial-scale'] = scaleRatio;
contentAttrs['maximum-scale'] = scaleRatio; // 最大缩放也锁死,防止用户缩放
contentAttrs['user-scalable'] = 'no';
// 重新组合成字符串
const newContent = Object.keys(contentAttrs).map(key => `${key}=${contentAttrs[key]}`).join(', ');
metaViewport.setAttribute('content', newContent);
// 缩放后,仍需设置高度防止滚动
document.body.style.height = window.innerHeight + 'px';
document.body.style.overflow = 'hidden';
console.log(`强制设置viewport缩放比例为: ${scaleRatio}`);
}
// 需要在非常早的阶段执行,比如DOMContentLoaded或更早
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', forceHideAddressBar);
} else {
forceHideAddressBar();
}
// 同样需要监听resize,因为缩放后尺寸可能变化
window.addEventListener('resize', function() {
clearTimeout(this._resizeTimerV2);
this._resizeTimerV2 = setTimeout(() => {
document.body.style.height = window.innerHeight + 'px';
}, 100);
});
})();
注意事项与潜在风险:
- “跳闪”问题 :这种方法可能导致页面在加载瞬间有一个轻微的缩放视觉变化,体验不够平滑。
-
比例计算风险
:动态计算
scaleRatio风险较高,不同设备outerHeight与innerHeight的关系不稳定,可能导致页面被缩放过小。因此代码中采用了固定的0.99这个保守值,这是一个经验值,在大多数设备上能触发隐藏效果而不引起明显缩放感。 -
兼容性
:过度依赖
window.outerHeight,某些浏览器或WebView中此属性可能不准确或为0。 - 最后手段 :此方案应作为备选。建议优先使用方案一,仅在方案一效果不佳时,通过条件判断启用此方案。
5. 方案三:Standalone模式与“添加到主屏幕”(PWA进阶版)
这是效果最接近原生App的方案,但需要用户进行一个操作: 将你的游戏“添加到主屏幕” 。当用户通过主屏幕图标启动时,Safari会以“Standalone”模式(无浏览器UI)打开页面。这不再是“隐藏地址栏”,而是根本没有地址栏。
实现步骤:
-
配置Web App Manifest :在项目根目录(与
index.html同级)创建manifest.json文件。{ "name": "你的游戏名称", "short_name": "游戏短名", "description": "游戏描述", "start_url": "./index.html", "display": "standalone", // 关键! standalone, fullscreen, minimal-ui "background_color": "#000000", "theme_color": "#000000", "orientation": "landscape", // 锁定横屏 "icons": [ { "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png" } ] } -
在
index.html中链接Manifest :<head> <link rel="manifest" href="./manifest.json"> <!-- iOS Safari 特有 meta --> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-title" content="游戏短名"> <link rel="apple-touch-icon" href="./icons/icon-180.png"> <!-- 其他meta... --> </head> -
检测显示模式并做降级处理 :即使配置了,用户仍可能直接从浏览器访问。我们需要检测当前是否处于全屏模式,并可能应用前两种方案作为降级。
// 检测是否在独立模式(全屏)下运行 function isRunningInStandaloneMode() { return (window.matchMedia('(display-mode: standalone)').matches) || (window.navigator.standalone === true) || // iOS Safari 旧方式 (window.matchMedia('(display-mode: fullscreen)').matches); } window.addEventListener('load', function() { if (!isRunningInStandaloneMode()) { console.log('当前在浏览器中打开,应用方案一或二进行地址栏处理。'); // 在这里调用方案一或二的初始化函数 // initSchemeOne(); } else { console.log('当前在独立全屏模式下运行,无需处理地址栏。'); // 独立模式下,可以做一些额外的UI适配,如状态栏区域 } });
推广与引导: 这个方案效果最好,但依赖用户操作。你需要在游戏内添加友好的引导,提示用户“为了获得最佳体验,请点击分享按钮,选择‘添加到主屏幕’”。可以设计一个漂亮的弹窗或横幅,在检测到非独立模式时显示。
实操心得:
-
图标尺寸
:务必提供多种尺寸的高清图标,特别是
180x180(iOS)和192x192、512x512(Android)。 -
start_url:建议使用相对路径./index.html,并确保它能正确处理路由(如果游戏有的话)。 -
状态栏颜色
:
apple-mobile-web-app-status-bar-style可以设置为default、black或black-translucent。black-translucent会让内容延伸到状态栏后面,需要自己用CSSpadding-top: env(safe-area-inset-top)留出安全区。 - 缓存策略 :PWA通常配合Service Worker实现离线缓存。对于Cocos游戏,可以将游戏资源缓存,极大提升加载速度和离线可玩性。这是一个更大的话题,但绝对是提升用户体验的利器。
6. 方案对比与选型指南
| 特性 | 方案一:视口锁定 | 方案二:强制缩放 | 方案三:PWA独立模式 |
|---|---|---|---|
| 实现难度 | 简单 | 中等 | 复杂(需配置Manifest等) |
| 用户体验 | 好,加载后稳定 | 可能有瞬间缩放感 | 最佳,与原生App无异 |
| 用户依赖 | 无,自动生效 | 无,自动生效 | 需要用户手动“添加到主屏幕” |
| 兼容性 | iOS Safari 10+, 主流安卓浏览器 | 对老版本iOS可能有效 | iOS Safari, Chrome for Android |
| 可控性 | 高,纯前端控制 | 中,依赖浏览器对缩放响应 | 高,但依赖系统支持 |
| 推荐度 | 首选 | 备选(方案一无效时尝试) | 强烈推荐长期运营项目使用 |
选型建议:
- 对于大多数项目,优先采用“方案一 + 方案三”的组合策略。 即默认使用方案一保证基础浏览器访问体验,同时积极配置PWA,引导用户添加到主屏幕以获得完美体验。
- 方案二可作为兜底 ,在测试中发现某些特定机型或iOS版本下方案一失效时,通过条件判断(如检测iOS版本号)启用。
- 务必进行真机测试 。在Xcode的Simulator或直接连真机用Safari远程调试,观察不同手势下的表现。
7. 常见问题排查与调试技巧实录
即使按照上述方案实现,在实际测试中你仍可能遇到各种诡异问题。这里记录几个我踩过的“坑”和解决方法。
问题1:画布大小正确,但游戏内容依然上下跳动或偏移。
-
原因
:Cocos Creator引擎本身在
cc.view中也有对画布尺寸的适配逻辑(如fitHeight,fitWidth)。如果JS设置了画布容器大小,但引擎的适配策略(Design Resolution和Fit Policy)与之冲突,就会导致内容缩放或定位异常。 -
解决
:在Cocos游戏脚本的
onLoad或start生命周期中, 同步更新引擎的视图尺寸 。// 在游戏场景的脚本中(如GameManager.ts) onLoad() { // ... 其他初始化 this.adjustViewport(); window.addEventListener('resize', this.adjustViewport.bind(this)); } adjustViewport() { const canvas = cc.find('Canvas').getComponent(cc.Canvas); const designResolution = canvas.designResolution; // 获取当前窗口的实际像素尺寸 const realWidth = window.innerWidth; const realHeight = window.innerHeight; // 根据你的适配策略(如SHOW_ALL, FIXED_HEIGHT等)重新计算 // 这里以FIXED_HEIGHT为例,保持设计高度,宽度自适应 const scale = realHeight / designResolution.height; const adaptedWidth = realWidth / scale; // 更新cc.view的视口和设计分辨率(如果需要) cc.view.setDesignResolutionSize(adaptedWidth, designResolution.height, cc.ResolutionPolicy.FIXED_HEIGHT); // 强制渲染一帧 this.scheduleOnce(() => { cc.director.getScheduler().performFunctionInCocosThread(() => {}) }, 0); }
问题2:在微信内置浏览器、QQ浏览器等第三方浏览器中无效。
-
原因
:这些浏览器有自己独立的渲染内核和全屏策略,对
viewport和JS API的支持与Safari/Chrome不同。 - 解决 :针对这些浏览器做特性检测和降级处理。通常它们对PWA支持弱,但可能有自己的“全屏API”或meta标签。方案一(动态设置高度)在这些浏览器中通常部分有效,但阻止下拉刷新的代码可能需要调整(微信浏览器下拉有自己的一套逻辑)。测试是关键。
问题3:隐藏地址栏后,输入框(如聊天框)聚焦时,键盘弹起导致画面被挤压。
-
原因
:键盘弹起会改变
window.innerHeight,触发resize事件。如果我们的代码将画布高度固定为之前的innerHeight,就会出现画面被压缩或错位。 - 解决 :这是一个棘手的问题。一种思路是,在检测到输入框聚焦时,临时允许页面滚动,让浏览器自然处理键盘弹起时的布局。失焦后,再恢复我们的全屏锁定状态。这需要精细的焦点管理。
调试技巧:
- iOS远程调试 :用USB连接iPhone/Mac,在Mac的Safari“开发”菜单中选中你的设备,可以实时查看Console、检查元素、监控网络和调试JavaScript,这是解决Safari问题的 必备技能 。
- 模拟器调试 :Xcode的iOS Simulator可以模拟不同型号的iPhone和iPad,快速测试不同屏幕尺寸和iOS版本。
-
CSS调试
:在Safari的开发者工具中,给
html和body元素临时加上醒目的背景色(如background: red !important;),可以清晰看到它们实际占据的区域,判断100%高度是否生效。 -
视口尺寸监控
:在页面中输出以下信息,方便观察:
setInterval(() => { console.log(`inner: ${window.innerHeight}, outer: ${window.outerHeight}, screen: ${screen.height}, client: ${document.documentElement.clientHeight}`); }, 1000);
实现一个稳定的、跨浏览器的横屏游戏全屏体验,确实需要一些耐心和细致的调试。没有一劳永逸的银弹,但通过理解原理、组合使用上述方案,并针对你的目标用户群体进行充分测试,完全可以达到令人满意的效果。记住,
方案一是基石,方案三是终极目标
,而方案二则是应对特殊情况的工具箱。在实际项目中,我通常会封装一个
FullscreenHelper
工具类,整合这三种策略,并根据环境检测自动应用最佳方案,这样就能在绝大多数设备上为玩家提供一个沉浸式的游戏环境了。

1万+

被折叠的 条评论
为什么被折叠?



