从我第一次创建 CSS :hover
效果的那一刻起,我就爱上了编程。多年后,最初对网络交互的尝试让我有了一个新的目标:制作游戏。
那些早期玩 :hover
的时光并不特别,甚至毫无用处。我记得我制作了一个由蓝色方块组成的响应式网格(使用 float
制作,如果你能想象出当时的时间线的话),当光标移动到每个方块上时,它们都会变成橙色。我花了似乎是几个小时的时间将鼠标悬停在这些方块上,调整窗口大小以观察它们的大小和对齐方式的变化,然后一遍又一遍地重复这些操作。这感觉就像纯粹的魔法。
多年来,我在网络上构建的内容自然比那个由 <div>
元素组成的网格更加复杂,但赋予事物真正交互性的快感始终伴随着我。随着我对 JavaScript 的了解越来越多,我尤其喜欢制作游戏。
有时它只是一个 CodePen 演示;有时它是一个部署在 Vercel 或 Netlify 上的小副项目。我喜欢在浏览器中重新创建像 颜色泛滥、猜单词 或 四子棋 这样的游戏的挑战。
然而,过了一段时间,目标变得更大了:如果我制作了一个真正的游戏呢? 不仅仅是一个 Web 应用;而是一个真正的、实实在在的、可以从应用商店下载的游戏。去年 8 月,我开始着手我迄今为止最雄心勃勃的项目,四个月后,我向全世界发布了它(意思是:我厌倦了摆弄它):一个我称之为 Quina 的文字游戏应用。

这是什么游戏(以及这个名字的由来)?
最简单的解释 Quina 的方式是:它是 珠玑妙算,但使用的是五个字母的单词。事实上,珠玑妙算实际上是一个经典的纸笔游戏的版本;Quina 只是同一游戏的另一个变体。
Quina 的目标是猜出一个秘密的五个字母的单词。每次猜测后,您都会得到一条线索,告诉您您的猜测与代码词的接近程度。您使用该线索来完善您的下一次猜测,依此类推,但您总共只有十次猜测机会;用完就输了。
“Quina”这个名字的由来是因为它在拉丁语中是“一次五个”的意思(反正 Google 是这么告诉我的)。传统游戏通常使用四个字母的单词,或者有时是四个数字(或者在珠玑妙算的情况下,是四种颜色);Quina 使用没有重复字母的五个字母的单词,因此这款游戏应该有一个符合其自身规则的名字,这感觉很合适。(我不知道最初的拉丁语单词是如何发音的,但我把它读作“QUINN-ah”,这可能是错误的,但是,嘿,这是我的游戏,对吧?)
在大约四个月的时间里,我利用晚上和周末的时间来开发这个应用。我想在这篇文章中谈谈这款游戏背后的技术、所做的决定以及吸取的教训,以防您也有兴趣走上这条道路。
选择 Nuxt
我非常喜欢 Vue,并希望将这个项目作为扩展我对其生态系统了解的一种方式。我考虑过使用其他框架(我还使用 Svelte 和 React 构建过项目),但我认为 Nuxt 在熟悉程度、易用性和成熟度方面达到了最佳平衡点。(顺便说一句,如果您不知道或没有猜到:可以将 Nuxt 相当地描述为 Vue 的 Next.js 等价物。)
我以前没有深入研究过 Nuxt;只做过几个非常小的应用。但我知道 Nuxt 可以编译成静态应用,这正是我想要的——无需担心(Node)服务器。我还知道 Nuxt 可以像将 Vue 组件放入 /pages
文件夹一样轻松地处理路由,这非常吸引人。
另外,虽然 Vuex(Vue 中的官方状态管理)本身并不十分复杂,但我欣赏 Nuxt 添加的一点点语法糖,使其更加简单。(顺便说一句,Nuxt 以各种方式简化了操作,例如不要求您在使用组件之前显式导入它们;您可以将它们放在标记中,Nuxt 会自动识别并根据需要自动导入。)
最后,我提前知道我正在构建一个 渐进式 Web 应用 (PWA),因此已经有一个 Nuxt PWA 模块 来帮助构建所有相关功能(例如用于离线功能的服务工作线程),并且已经打包好并可以使用,这是一个很大的吸引力。事实上,有大量令人印象深刻的 Nuxt 模块 可用于应对任何不可预见的障碍。这使得 Nuxt 成为最简单、最明显的选择,而且我从未后悔过。
我最终在使用过程中使用了更多的模块,包括出色的 Nuxt Content 模块,它允许您使用 Markdown 编写页面内容,甚至可以使用 Markdown 和 Vue 组件的混合编写。我还在“常见问题解答”页面和“如何玩”页面中使用了该功能(因为用 Markdown 编写比硬编码 HTML 页面要好得多)。
使用 Web 技术实现原生应用的感觉
Quina 最终将在 Google Play 商店 上架,但无论如何玩或在哪里玩,我都希望它从一开始就感觉像一个成熟的应用。
首先,这意味着需要像许多原生应用一样,提供可选的暗黑模式和减少动画以获得最佳可用性的设置(对于减少动画,就像任何带有动画的东西都应该有的一样)。

在底层,这两个设置最终都是应用 Vuex 数据存储中的布尔值。当为 true
时,该设置会在应用的默认布局中呈现一个特定的类。 Nuxt 布局 是“包装”所有内容并在应用的所有(或许多)页面上呈现的 Vue 模板(通常用于共享页眉和页脚等内容,但也适用于全局设置)
<!-- layouts/default.vue -->
<template>
<div
:class="[
{
'dark-mode': darkMode,
'reduce-motion': reduceMotion,
},
'dots',
]"
>
<Nuxt />
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
computed: {
...mapGetters(['darkMode', 'reduceMotion']),
},
// Other layout component code here
}
</script>
说到设置:虽然 Web 应用分为几个不同的页面——菜单、设置、关于、游戏等——但共享的全局 Vuex 数据存储有助于保持事物同步并在应用的不同区域之间保持无缝连接(因为用户将在一个页面上调整其设置,并在另一个页面上看到它们应用于游戏)。
应用中的每个设置也同步到 localStorage
和 Vuex 存储,这允许在会话之间保存和加载值,以及在用户导航到不同页面时跟踪设置。
说到导航:页面之间的移动是另一个我认为有很多机会让 Quina 感觉像原生应用的领域,方法是添加整页过渡。
Vue 过渡通常非常简单——您只需为“目标”和“来源”过渡状态编写特定命名的 CSS 类——但 Nuxt 更进了一步,允许您仅使用页面 Vue 文件中的一行代码来设置整页过渡
<!-- A page component, e.g., pages/Options.vue -->
<script>
export default {
transition: 'page-slide'
// ... The rest of the component properties
}
</script>
transition
属性非常强大;它让 Nuxt 知道我们希望在导航到或离开此页面时将 page-slide
过渡应用于此页面。从那里,我们所需要做的就是定义处理动画的类,就像使用任何 Vue 过渡一样。这是我的 page-slide
SCSS
/* assets/css/_animations.scss */
.page-slide {
&-enter-active {
transition: all 0.35s cubic-bezier(0, 0.25, 0, 0.75);
}
&-leave-active {
transition: all 0.35s cubic-bezier(0.75, 0, 1, 0.75);
}
&-enter,
&-leave-to {
opacity: 0;
transform: translateY(1rem);
.reduce-motion & {
transform: none !important;
}
}
&-leave-to {
transform: translateY(-1rem);
}
}
注意 .reduce-motion
类;这就是我们上面在布局文件中讨论的内容。它通过禁用任何 transform
属性(这似乎保证了有争议的 !important
标志的使用)来防止在 用户表示他们更喜欢减少动画 时(通过媒体查询或手动设置)出现视觉移动。但是,仍然允许不透明度淡入淡出,因为这实际上不是移动。
关于转换和处理 404 错误的旁注:当然,转换和路由是由 JavaScript 在后台处理的(确切地说,是 Vue Router),但我遇到了一个令人沮丧的问题,脚本会在空闲页面上停止运行(例如,如果用户将应用程序或选项卡在后台打开一段时间)。当返回到这些空闲页面并单击链接时,Vue Router 将停止运行,因此该链接将被视为相对链接并返回 404 错误。
示例:/faq
页面进入空闲状态;用户返回到该页面并单击链接以访问 /options
页面。该应用程序将尝试转到 /faq/options
,该页面当然不存在。
我对此的解决方案是自定义 error.vue
页面(这是一个自动处理所有错误的 Nuxt 页面),在该页面上,我会对传入路径运行验证并重定向到路径的末尾。
// layouts/error.vue
mounted() {
const lastPage = '/' + this.$route.fullPath.split('/').pop()
// Don't create a redirect loop
if (lastPage !== this.$route.fullPath) {
this.$router.push({
path: lastPage,
})
}
}
这对我的用例有效,因为 a) 我没有任何嵌套路由;并且 b) 最后,如果路径无效,它仍然会遇到 404 错误。
震动和声音
过渡效果很好,但我还知道 Quina 不会感觉像原生应用程序,尤其是在智能手机上,除非它同时具有震动和声音。
由于 Navigator API 的出现,如今在浏览器中实现震动相对容易。大多数现代浏览器都允许您调用 window.navigator.vibrate()
来让用户感觉到轻微的震动或一系列震动,或者使用非常短的持续时间来提供微小的触觉反馈,就像您在智能手机键盘上点击按键时一样。
显然,您希望谨慎使用震动,这有几个原因。首先,因为过度使用很容易造成糟糕的用户体验;其次,因为并非所有设备/浏览器都支持它,因此您需要非常小心如何以及在何处尝试调用 vibrate()
函数,以免导致错误并关闭当前正在运行的脚本。
就我个人而言,我的解决方案是设置一个 Vuex getter 来验证用户是否允许震动(可以从设置页面禁用它);当前上下文是客户端而不是服务器;最后,该函数是否存在于当前浏览器中。(ES2020 可可选链 在最后一部分也适用。)
// store/getters.js
vibration(state) {
if (
process.client &&
state.options.vibration &&
typeof window.navigator.vibrate !== 'undefined'
) {
return true
}
return false
},
旁注:在 Nuxt(以及许多其他可能在 Node 上运行代码的框架)中,检查 process.client
非常重要,因为 window
并不总是存在。即使您在静态模式下使用 Nuxt 也是如此,因为组件在构建时会在 Node 中进行验证。process.client
(及其对立面 process.server
)是 Nuxt 的便捷之处,它们只是在运行时验证代码的当前环境,因此它们非常适合隔离仅限浏览器使用的代码。
声音是应用程序用户体验的另一个关键部分。我没有自己制作音效(这无疑会给项目增加数十个小时的工作量),而是混合了几位更了解该领域知识并且在线提供了一些免费游戏声音的艺术家的样本。(有关完整信息,请参阅应用程序的常见问题解答。)
用户可以设置他们喜欢的音量,或者完全关闭声音。此设置以及震动设置也会保存在用户浏览器的 localStorage
中,并同步到 Vuex store。这种方法允许我们设置保存在浏览器中的“永久”设置,但无需在每次引用它时都从浏览器中检索它。(例如,声音会在每次播放时检查当前音量级别,每次都等待 localStorage
调用的延迟可能会破坏体验。)
关于声音的题外话
事实证明,无论出于何种原因,Safari 在声音方面都非常滞后。所有点击、哔哔声和叮当声在 Safari 中实际播放时,都会在触发它们的事件之后延迟一段时间,尤其是在 iOS 上。这是一个破坏性问题,我花了大量时间绝望地在其中寻找解决方案。
幸运的是,我找到了一个名为 Howler.js 的库,它可以非常轻松地解决跨平台声音问题(并且还有一个有趣的小标识)。只需将 Howler 作为依赖项安装并通过它运行应用程序的所有声音(基本上是一两行代码)就足以解决这个问题。

如果您正在构建具有同步声音的 JavaScript 应用程序,我强烈建议您使用 Howler,因为我不知道 Safari 的问题是什么,也不知道 Howler 如何解决它。我尝试过的任何方法都不起作用,所以我很高兴这个问题能够以非常少的开销或代码修改轻松解决。
游戏玩法、历史和奖励
Quina 可能是一款很难的游戏,尤其是在刚开始的时候,所以有几种方法可以根据您的个人喜好调整游戏的难度。
- 您可以选择想要获得哪种类型的单词作为代码字:基本(常见的英语单词)、棘手(更晦涩或更难拼写的单词)或随机(两者的加权混合)。
- 您可以选择是否在每局游戏开始时接收提示,如果是,该提示会显示多少信息。

这些设置允许不同技能、年龄和/或英语水平的玩家在自己的水平上玩游戏。(具有强烈提示的基本单词集最简单;没有提示的棘手或随机单词集最难。)

虽然简单地玩一系列难度可调的一次性游戏可能已经足够有趣,但这感觉更像是一个标准的网络应用程序或演示,而不是一个真正的、成熟的游戏。因此,为了追求那种原生应用程序的感觉,Quina 会跟踪您的游戏历史记录,以多种不同的方式显示您的游戏统计数据,并为各种成就提供几种“奖励”。

在底层,每个游戏都保存为一个看起来像这样的对象。
{
guessesUsed: 3,
difficulty: 'tricky',
win: true,
hint: 'none',
}
该应用程序以 gameHistory
游戏对象数组的形式对您玩过的游戏进行分类(同样,通过与 localStorage
同步的 Vuex 状态),然后该应用程序使用这些对象来显示您的统计数据(例如您的胜/负率、您玩过的游戏数量和您的平均猜测次数),以及显示您在游戏“奖励”方面的进度。
这一切都可以通过各种 Vuex getter 轻松完成,每个 getter 都利用 JavaScript 数组方法(例如 .filter()
和 .reduce()
)对 gameHistory
数组进行操作。例如,这是一个 getter,用于显示用户在“棘手”设置下玩游戏时赢得了多少局游戏。
// store/getters.js
trickyGamesWon(state) {
return state.gameHistory.filter(
(game) => game.win && game.difficulty === 'tricky'
).length
},
还有许多其他复杂程度不同的 getter。(确定用户最长连胜纪录的那个特别复杂。)
添加奖励就是创建一个奖励对象数组,每个对象都与一个特定的 Vuex getter 相关联,并且每个对象都有一个 requirement.threshold
属性,用于指示何时解锁该奖励(即,当 getter 返回的值足够高时)。这是一个示例。
// assets/js/awards.js
export default [
{
title: 'Onset',
requirement: {
getter: 'totalGamesPlayed',
threshold: 1,
text: 'Play your first game of Quina',
}
},
{
title: 'Sharp',
requirement: {
getter: 'trickyGamesWon',
threshold: 10,
text: 'Win ten total games on Tricky',
},
},
]
从那里开始,在 Vue 模板文件中循环遍历成就以获得最终输出是一个非常简单的事情,使用其 requirement.text
属性(尽管添加了大量的数学和动画来填充仪表,以显示用户的进度)朝向实现奖项。)

共有 25 个奖项(为了与主题保持一致,它是 5 × 5),用于奖励各种成就,例如赢得一定数量的游戏、尝试所有游戏模式,甚至在前三次猜测中赢得游戏。(那个被称为“幸运”——作为一个额外的小彩蛋,每个奖项的名称也是一个潜在的代码字,即五个字母不重复。)
解锁奖励除了给你吹嘘的权利外什么也做不了,但其中一些奖励很难获得。(我花了几个星期才把它们全部拿齐!)
这种方法的优缺点
“一次构建,随处部署”策略有很多优点,但也有一些缺点。
优点
- 您只需要部署一次您的商店应用程序。之后,所有更新都可以是网站部署。(这比等待应用商店发布要快得多。)
- 一次构建。这在某种程度上是正确的,但事实证明并不像我想象的那么简单,因为谷歌的支付政策(稍后会详细介绍)。
- 一切都是浏览器。无论用户是否意识到,您的应用程序始终在您习惯的环境中运行。
缺点
- 事件处理程序可能会非常棘手。由于您的代码在所有平台上同时运行,因此您必须同时预测任何和所有类型的用户输入。应用程序中的某些元素可以点击、单击、长按,并且对各种键盘按键的响应也不同;同时处理所有这些事件而不让任何处理程序相互干扰可能会很棘手。
- 您可能需要拆分体验。这将取决于您的应用程序在做什么,但我有一些东西需要仅向 Android 应用程序的用户显示,而另一些东西仅供网络使用。(我将在下面的另一节中详细介绍我是如何解决这个问题的。)
- 一切皆浏览器。 你无需担心用户的 Android 版本,但你需要担心他们的默认浏览器是什么(因为应用程序会在后台使用他们的默认浏览器)。 通常在 Android 上,这意味着 Chrome,但你*确实*需要考虑到所有可能性。
物流:将 Web 应用转换为原生应用
有*很多*技术可以实现“为 Web 构建,随处发布”的承诺 — React Native、Cordova、Ionic、Meteor 和 NativeScript,仅举几例。
一般来说,这些可以归结为两类
- 你按照框架要求的方式编写代码(并非完全按照你通常的方式编写),然后框架将其转换为合法的原生应用;
- 你以通常的方式编写代码,然后该技术只是在你的 Web 技术周围包装一个原生的“外壳”,并将其*伪装*成原生应用。
第一种方法似乎是两者中更理想的一种(因为最终你理论上会得到一个“真正的”原生应用),但我发现它也伴随着最大的障碍。 每个平台或产品都要求你学习它的做事方式,而这种方式本身就是一个完整的生态系统和框架。 根据我的经验,“只写你所知道的”的承诺是一个相当夸张的说法。 我猜在一两年内,这些问题中的很多都会得到解决,但现在,你仍然会感觉到编写 Web 代码和发布原生应用之间存在相当大的差距。
另一方面,第二种方法是可行的,因为它叫做“TWA”,这使得将网站变成应用程序成为可能。
什么是 TWA 应用?
TWA 代表 Trusted Web Activity(可信 Web 活动) — 由于这个答案可能毫无帮助,让我们更详细地分解一下,好吗?
TWA 应用基本上将网站(或 Web 应用,如果你想区分的话)转变为原生应用,并借助了一些 UI 小技巧。
你可以将 TWA 应用视为伪装的浏览器。 它是一个没有任何内部结构的 Android 应用,除了 Web 浏览器。 TWA 应用指向一个特定的 Web URL,每当应用启动时,它不会执行正常的原生应用操作,而只会加载该网站 — 全屏显示,没有浏览器控件,有效地使网站看起来和表现得像一个完整的原生应用。
TWA 要求
将网站包装在原生应用中很容易看出其吸引力。 但是,并非任何旧站点或 URL 都符合条件; 为了将你的网站/应用作为 TWA 原生应用启动,你需要选中以下框
- 你的网站/应用*必须*是 PWA。 Google 在 Lighthouse 中提供了一个验证检查,或者你可以使用 Bubblewrap 进行检查(稍后会详细介绍)。
- 你必须自己生成应用包/APK; 它不像仅仅提交你的渐进式 Web 应用的 URL 那样简单,然后所有工作都为你完成。 (别担心; 即使你对原生应用开发一无所知,我们也会介绍一种方法。)
- 你必须有一个匹配的安全密钥,它同时存在于 Android 应用*和*上传到你的 Web 应用的特定 URL 中。
最后一点是“可信”部分的来源; TWA 应用会检查自己的密钥,然后验证你的 Web 应用上的密钥是否与其匹配,以确保它加载的是正确的站点(据推测,是为了防止恶意劫持应用 URL)。 如果密钥不匹配或未找到,应用仍将正常工作,但 TWA 功能将消失; 它只会将网站加载在一个普通的浏览器中,包括 Chrome 和所有内容。 因此,密钥对应用的体验*极其*重要。 (你可以说它是*关键*部分。抱歉,不抱歉。)
构建 TWA 应用的优点和缺点
TWA 应用的主要优点是它不需要你更改任何代码 — 无需学习框架或平台; 你只需像平常一样构建网站/Web 应用,一旦你完成了这些,你基本上也完成了应用代码。
然而,主要的*缺点*是(尽管帮助引领了 Web 和 JavaScript 的现代时代),但苹果*不支持* TWA 应用; 你无法在 Apple App Store 中列出它们。 只有 Google Play。
这听起来像是一个致命的问题,但请记住以下几点
- 请记住,要首先列出你的应用,它必须是一个 PWA — 这意味着它默认情况下是可安装的。 *任何*平台上的用户仍然可以从浏览器将其添加到他们设备的主屏幕。 它*不需要*在 Apple App Store 中就可以安装在 Apple 设备上(尽管它肯定会错过可发现性)。 因此,你仍然可以在你的应用中构建营销登录页面,并提示用户从那里安装它。
- 也没有什么可以阻止你使用完全不同的策略开发原生 iOS 应用。 即使你想要 iOS *和* Android 应用,只要 Web 应用也是计划的一部分,拥有 TWA 实际上就减少了一半的工作量。
- 最后,虽然 iOS 在主要使用英语的国家和日本拥有约 50% 的市场份额,但 Android 在世界其他地区拥有超过 90% 的份额。 因此,根据你的受众,错过 App Store 可能不像你想的那么有影响力。
如何生成 Android 应用 APK
此时你可能会说,“这个 TWA 的事情听起来不错,但我如何才能真正地将我的网站/应用放到 Android 应用中?”
答案是一个名为 Bubblewrap 的可爱的小型 CLI 工具。
你可以将 Bubblewrap 视为一个工具,它会接收你的一些输入和选项,并根据输入生成一个 Android 应用(具体来说,是 APK,Google Play 商店允许的文件格式之一)。
安装 Bubblewrap 有点棘手,虽然使用它并不完全是即插即用的,但对于一般的 JavaScript 开发人员来说,它肯定比我找到的任何其他类似选项都要容易得多。 Bubblewrap 的 NPM 页面 上的自述文件详细介绍了这一点,但作为简要概述
通过运行 npm i -g @bubblewrap/cli
安装 Bubblewrap(我在这里假设你熟悉 NPM 并通过命令行从中安装软件包)。 这将允许你在任何地方使用 Bubblewrap。
安装完成后,你将运行
bubblewrap init --manifest https://your-webapp-domain/manifest.json
注意:所有 PWA 都需要 manifest.json
文件,并且 Bubblewrap 需要该文件的 URL,而不仅仅是你的应用。 另请注意:根据你的清单文件是如何生成的,其名称对于每个构建可能是唯一的。 (例如,Nuxt 的 PWA 模块会将唯一的 UUID 附加到文件名中。)
另请注意,默认情况下,Bubblewrap 会在此过程中验证你的 Web 应用是否是有效的 PWA。 出于某种原因,当我经历这个过程时,检查结果一直是负面的,尽管 Lighthouse 确认它实际上是一个功能齐全的渐进式 Web 应用。 幸运的是,Bubblewrap 允许你使用 --skipPwaValidation
标志跳过此检查。
如果这是你第一次使用 Bubblewrap,它会询问你是否希望它为你安装 Java 开发工具包 (JDK) 和 Android 软件开发工具包 (SDK)。 这两个是在后台生成 Android 应用所需的实用程序。 如果你不确定,请按“Y”表示是。
注意:Bubblewrap 希望这两个开发工具包存在于*非常特定的位置*,如果它们不在那里,它将无法正常工作。 你可以运行 bubblewrap doctor
来验证,或查看完整的 Bubblewrap CLI 自述文件。
安装完所有内容后 — 假设它在提供的 URL 处找到了你的 manifest.json
文件 — Bubblewrap 会询问你有关你应用的一些问题。

许多问题要么是偏好设置(比如你应用的主要颜色),要么只是确认基本细节(比如应用的域名和入口点),并且大多数问题都会根据你网站的清单文件预先填写。

可能已经由你的清单预先填写的其他问题包括在哪里可以找到你应用的各种图标(用作主屏幕图标、状态栏图标等)、应用打开时启动画面应该是什么颜色,以及应用的屏幕方向,以防你想强制纵向或横向。
但是,有一些重要的问题可能会让人有点困惑,所以让我们在这里介绍一下
- 应用程序 ID:这似乎是一个 Java 约定,但每个应用都需要一个唯一的 ID 字符串,通常是 2-3 个点分隔的部分(例如,
collinsworth.quina.app
)。 *这实际上并不重要*;它不是功能性的,只是一个约定。 唯一重要的是你记住它,并且它是唯一的。 但*请注意*,这将成为你的应用唯一的 Google Play 商店 URL 的一部分。 (因此,你*不能*使用以前使用过的应用 ID 上传新的软件包,所以*请确保*你对自己的 ID 感到满意。) - 起始版本:目前这并不重要,但 Play 商店会要求你在上传新软件包时增加版本号,并且*你不能两次上传相同的版本*。 所以我建议从 0 或 1 开始。
- 显示模式:实际上, TWA 应用可以通过几种方式显示您的网站。在这里,您最可能想要选择
standalone
(全屏,但顶部带有原生状态栏)或fullscreen
(无状态栏)。我个人选择了默认的standalone
选项,因为我没有看到任何理由要在应用内隐藏用户的状态栏,但您可以根据应用的功能选择不同的选项。
签名密钥
最后一块拼图是签名密钥。这是最重要的部分。此密钥将您的渐进式 Web 应用连接到此 Android 应用。如果应用预期的密钥与 PWA 中的密钥不匹配,则您的应用仍然可以工作,但在用户打开它时,它不会看起来像原生应用;它将只是一个普通的浏览器窗口。
这里有两种方法有点太复杂,无法详细介绍,但我将尝试给出一些指示
- 生成您自己的密钥库。您可以让 Bubblewrap 执行此操作,或使用名为
keytool
的 CLI 工具(名称恰如其分),但无论哪种方式:都要非常小心。您需要明确跟踪密钥库的确切名称和密码,并且由于您是在命令行上创建的,因此您需要非常小心特殊字符,因为它们可能会扰乱整个过程。(即使在作为密码提示的一部分输入时,特殊字符在命令行上的解释也可能不同。) - 允许 Google 处理您的密钥。根据我的经验,这实际上并没有简单多少,但它可以通过允许您进入 Google Play 开发者控制台并下载为您的应用预先生成的密钥来省去一些处理您自己的签名密钥的麻烦。

无论您选择哪种选项,都可以在此处找到有关应用签名的深入文档(为 Android 应用编写,但其中大部分内容仍然相关)。
本 Android 应用链接验证指南介绍了如何将密钥放到您的个人网站上。简单地说:Google 会在您网站上的确切路径 /.well-known/assetlinks.json
中查找文件。该文件需要包含您的唯一密钥哈希以及其他一些详细信息
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target" : { "namespace": "android_app", "package_name": "your.app.id",
"sha256_cert_fingerprints": ["your:unique:hash:here"] }
}]
关于上架应用您应该了解的内容
在您开始之前,还需要注意应用商店方面的一些障碍
- 首先,您需要注册才能发布到 Google Play 商店。此资格需要支付 25 美元的一次性费用。
- 获得批准后,请注意上架应用既不快速也不容易。这比困难或技术性更繁琐,但 Google 会审核商店中的每个应用和更新,并且要求您在甚至开始审核过程之前填写大量关于您自己和您的应用的表格和信息,而审核过程本身可能需要很多天,即使您的应用尚未公开。(友情提醒:Play 控制台仪表板中至少六个月以来一直显示“我们正在经历比平时更长的审核时间”警告横幅。)
- 其中比较繁琐的部分是:您必须上传几张应用运行中的截图,然后才能开始审核。这些最终将成为商店列表中显示的图片 - 请记住,更改它们也会启动新的审核,因此如果您想最大程度地缩短周转时间,请做好准备。
- 您还需要提供指向您应用的服务条款和隐私政策的链接(这也是我的应用拥有这些链接的唯一原因,因为它们几乎毫无意义)。
- 有很多事情是您无法撤消的。例如,您永远不能将免费应用更改为付费应用,即使它尚未公开发布和/或下载量为零。您还必须严格控制上传内容的版本和命名,因为 Google 不允许您覆盖或删除您的应用或上传的软件包,并且也不总是允许您恢复仪表板中的其他设置。如果您采用“先完成,稍后再解决问题”的方法(像我一样),您可能会发现自己至少需要从头开始一两次。
- 除少数例外,Google 对在应用内收款有极其严格的政策。当我构建时,它对所有交易收取 30% 的费用(此后他们有条件地将费用降低到 15% - 更好,但仍然是大多数其他支付提供商收费的五倍)。Google 还强制开发者(除少数例外)使用其自己的原生支付平台;不允许在应用内选择 Square、Stripe、PayPal 等。
- 有趣的事实:在我试图发布 Quina 时,这项政策已经宣布但尚未生效,它仍然被审核人员标记为违规。所以他们绝对非常重视这项政策。
获利、可解锁内容以及绕过 Google 的方法
虽然我使用 Quina 的目标主要是个人目标 - 挑战自我,证明我能行,并在复杂的现实世界应用中更多地了解 Vue 生态系统 - 但我也希望我的作品能够为我和我的家人赚点外快。
不多。我从来没有想过要打造下一个“糖果粉碎传奇”(也没有设计一个以成瘾为燃料的微交易机器所需的道德真空)。但由于我已经为这款游戏投入了数百个小时的时间和精力,我希望也许我可以得到一些回报,即使只是一点啤酒钱。
起初,我并不喜欢尝试出售该应用或锁定其内容的想法,所以我决定在每隔几局游戏后添加一个简单的“如果您喜欢 Quina,是否愿意支持它?”提示,并使部分内容专门为支持者解锁。(默认情况下,单词集的大小有限,并且某些游戏设置最初也被锁定。)支持 Quina 的提示可以永久关闭(我不是怪物),任何捐赠都可以解锁所有内容;没有分层访问或福利。
由于 Stripe 的存在,这一切都相当容易实现,即使没有服务器也是如此;它完全在客户端完成。我只是在 /support
页面上导入了一些 JavaScript,使用了 Nuxt 方便的 head
函数(它专门在给定页面上向 <head>
元素添加项目)
// pages/support.vue
head() {
return {
script: [
{
hid: 'stripe',
src: 'https://js.stripe.com/v3',
defer: true,
callback: () => {
// Adds all Stripe methods like redirectToCheckout to page component
this.stripe = Stripe('your_stripe_id')
},
},
],
}
},
完成这一步(以及少量模板和逻辑)后,用户可以选择他们的捐赠金额 - 在 Stripe 端设置为产品 - 并被重定向到 Stripe 以完成付款,然后在完成后返回。对于每个层级,返回重定向 URL 通过查询参数略有不同。Vue Router 解析 URL 以调整用户的存储捐赠历史记录,并相应地解锁功能。

您可能想知道为什么我要透露所有这些,因为它暴露了系统,使其很容易被逆向工程。答案是:我不在乎。事实上,我自己添加了一个免费层级,所以您甚至不必费心了。我决定,如果有人真的想要可解锁内容,但由于某种原因不能或不愿意付费,那也没关系。也许他们生活在 3 美元就是一大笔钱的情况下。也许他们已经在一部设备上捐赠过了。也许他们会做其他好事来代替。但说实话,即使他们的意图不好:那又怎样?
我很感激您的支持,但这并不是我的生活,我不想建立一个多巴胺收费站。此外,我个人对使用一堆完全开源和/或免费软件(更不用说随之而来的关于所有这些软件的大量文档、博客文章和 Stack Overflow 答案)来建立一个封闭的花园以获取个人利益的道德含义感到不安。
所以,如果您喜欢 Quina 并能支持它:真诚地感谢您。这对我来说意义重大。我喜欢看到我的作品受到大家的喜爱。但如果没有:那也很酷。如果您想要“免费”选项,它就在那里等着您。
无论如何,当我了解到 Google Play 今年生效的新获利政策时,整个计划都遇到了障碍。您可以自己阅读,但总而言之:如果您通过 Google Play 应用赚钱并且您不是非营利组织,您必须通过 Google Pay 并支付高昂的费用 - 您不允许使用任何其他支付提供商。
这意味着我什至无法上架该应用;它会被阻止,仅仅是因为它有一个“支持”页面,其中的付款不通过 Google 进行。(我想我本来可以通过注册一个非营利组织来解决这个问题,但这在很多层面上似乎都是错误的做法。)
我最终的解决方案是在 Google Play 上对应用本身收费,将其定价为 2.99 美元(而不是我之前计划的“免费”价格),并相应地改变 Android 用户的应用体验。
为 Google Play 定制应用体验
幸运的是,Android 应用在请求网站时会发送一个包含应用唯一 ID 的自定义标头。使用此标头,可以很容易地区分应用在网络和实际 Android 应用中的体验。
对于每个请求,应用都会检查 Android ID;如果存在,应用会将名为 isAndroid
的 Vuex 状态布尔值设置为 true
。此状态会级联到整个应用,用于触发各种条件语句来执行诸如隐藏和显示各种常见问题解答以及(最重要的是)隐藏导航菜单中的支持页面之类的操作。它还会默认解锁所有内容(因为用户已经在 Android 上通过购买“捐赠”了)。我什至更进一步,创建了简单的 <WebOnly>
和 <AndroidOnly>
Vue 包装器组件来包装仅用于两者之一的内容。(例如,显然,无法访问支持页面的 Android 用户不应看到有关该主题的常见问题解答。)
<!-- /src/components/AndroidOnly.vue -->
<template>
<div v-if="isAndroid">
<slot />
</div>
</template>
<script>
export default {
computed: {
isAndroid() {
return this.$store.state.isAndroid
},
},
}
</script>
账户管理
在开发 Quina 的一段时间里,我使用了 Firebase 来进行登录和存储用户数据。我非常喜欢允许用户在他们所有设备上玩游戏并在任何地方跟踪他们的统计数据,而不是在每个设备/浏览器上都有单独的历史记录的想法。
然而,最终我放弃了这个想法,原因有几个。一个是复杂性;维护一个安全的帐户系统和数据库并不容易,即使使用像 Firebase 这样好的系统也是如此,而且这种开销是我不能掉以轻心的。但主要原因是:这个决定归结为安全性和简单性。
说到底,我不想对用户的数据负责。通过使用 localStorage
,他们的隐私和安全得到了保证,但代价是牺牲了可移植性。我希望玩家们不介意偶尔丢失他们的统计数据,如果这意味着他们没有任何登录或数据需要担心的话。(嘿,这也给了他们一个重新赢得所有奖励的机会。)
另外,这感觉很好。我可以诚实地说,我的应用程序不可能泄露您的安全或数据,因为它对您一无所知。而且,我也不必担心合规性、Cookie 警告或类似的事情。
总结
开发 Quina 是我迄今为止最雄心勃勃的项目,我从设计和开发它的过程中获得了和看到玩家们享受它一样多的乐趣。
希望这段旅程对您有所帮助!虽然将网络应用程序上架到 Google Play 商店需要很多步骤,并且存在潜在的陷阱,但这对前端开发人员来说绝对是可以实现的。我希望您将这个故事作为灵感,如果您这样做了,我很高兴看到您用新学到的知识构建了什么。
这篇文章太棒了!
谢谢!信息量很大,我很想用 React 试试。
很高兴听到你是如何用 Vue 实现的!我最近用我的 React 应用程序 Crab Fit 做了同样的事情,尽管为了解决捐赠 iAP 的问题,我使用的是数字商品 API 来从 PWA 中进行应用程序内购买。如果你想让你的应用程序保持免费,这可能是你需要考虑的事情,但它目前也需要 Chrome,并且你需要注册起源试用版。
这样的应用程序一个月或一年能赚多少钱?
感谢你这篇很棒的文章。
嘿,克里斯,这个问题很难回答,因为它完全取决于应用程序的受欢迎程度。我相信 Google Play 上的一些应用程序可以让它们的开发者谋生,而另一些应用程序则几乎赚不到钱。
我相信,一个有足够才能和动力的个人,绝对可以通过一个好的应用程序,向合适的受众进行良好的营销,赚取可观的收入。
然而,就我个人而言,我并没有真正为此进行优化。我从 Quina 中赚到的钱与其说是“房租”,不如说是“啤酒钱”。如果目标是赚钱,那么我最好利用这些时间去自由职业。但每个人和每个应用程序都是不同的,对我来说,仅仅是为了体验就值得了。
为什么它不能在所有国家/地区使用?我想测试一下应用程序体验与原生应用程序相比如何,但它在我的国家/地区不可用。
嗨,多米尼克!如果我没记错的话,谷歌对向所有国家/地区发布应用程序有一些限制,所以我只在英语人口比例超过一定百分比的国家/地区上架了这款应用程序。
现在回想起来,这可能有点……有没有一个词是“能力歧视”的意思,但在语言方面?至少可以说,我太自以为是了。
不过,如果您告诉我您希望添加哪些新的国家/地区,我很乐意研究一下!:) 与此同时,也许可以使用 VPN?
我只是想说你的发音是正确的。在巴西,Quina 也是一种彩票游戏的名称,你需要匹配所有 5 个号码才能中奖,所以你的意思非常接近。它也指 5 个东西组合在一起。你能想出这么远的名字给你的游戏命名真是太好了,这表明了你对游戏的关心和热情。我可能会去看看,就因为这个名字。谢谢分享!
说实话,干得好!虽然我的兴趣在于不同的技术,但我还是把你的文章读到了最后。它写得很好,很好地向外行人介绍了构建应用程序的主题。所有这些都包含了现实的期望、陷阱和需要做出的艰难决定。
我要去 Play 商店试试你的应用程序了。
读起来很不错,布局也很好 ^_^
您还可以通过启用对 Solid 的支持来确保隐私和安全。这样,用户就可以跨实现访问他们的数据。
这是我很久以来读过的最好的文章。真的很棒的文章。实用、清晰、引人入胜。感谢分享您的经验。
我在谷歌上搜索了“拉丁语中的一次五个”,结果似乎是“quini”?
维基词典上的 quini
老实说,你做得很好!然而,尽管我的兴趣在于其他技术,但我还是通读了整篇文章。这篇文章写得很好,并为那些不熟悉应用程序开发的人提供了见解。在每种情况下,都会面临挑战、陷阱和需要做出的决定。
让我们去 Play 商店,这样我就可以试试你的应用程序了。
嗨,乔什,
您的应用程序 Quina 在印度的 Google Play 中不可用。为什么?
请提供下载。
我想测试一下 vue/nuxt 作为 webview 的工作原理,以及它的性能如何。我也用 vue 做了很多东西。
谢谢
嗨,库马尔。我刚刚将印度添加到应用程序的国家/地区列表中。
不过,值得一提的是:最近,Google Play 已经多次错误地将该应用程序下架,如果这种情况继续发生,我可能会将其完全删除。它将始终在 quina.app 上提供,并且根据我的个人经验,将其作为 PWA 安装似乎与将其作为 Android 应用程序安装的效果完全相同。