上一篇博客我们分析基于 APNs 实现 iOS Push Notification 的代码,讲解关键环节的关键代码。相信大部份同学看完上一篇就可以复制出自己的消息推送功能。这一篇则是结合我们产品的特殊需求,介绍时区处理,以便给不同时区的用户定时推送消息。有出海和全球化需求的同学应该会需要相关知识。
(更多…)标签: ios
-

【系列教程】使用 Vercel Serverless function 连接 APNs 实现 iOS 推送通知(2)代码解析
上一篇文章我们分享了 Push Notification 的基础原理和项目配置,这一篇我们开始看具体的代码。
(更多…) -

2024 中国大陆搭建 React Native 开发环境
我从 Web 前端做起,后来发展到全栈,至今十几年。我觉得大陆的网络环境对 Web 开发还算比较友好,除去 Google 之外,大部分网站都能轻易访问,大部分网络产品都能自由使用。比如 GitHub,NPM,直接访问都没什么问题。只有少数几个软件包比较麻烦,也多半跟 Google 有关,比如关联到 chromium 的 Electron、Puppeteer 等。
上周开始准备做 React Native 开发,真的踩了不少坑,感觉大陆网络对移动开发相当不友好……Vincent 问我:搭环境需要这么久么?问一下 GPT 不是一两个小时就搞定了?我微笑着告诉他,他把这件事情想简单了。于是他也的确花了一天时间才把 demo 跑起来。这里我就分享一下最新的知识吧。
Expo
Expor 之于 React Native,就像 Next.js 之于 React。它是一个上层的框架,提供很多常用的组件和工具函数。比如基于文件目录结构的路由管理系统,以及从之衍生出的全局 URL、页面间跳转功能等。还有一些页面组件,比如 Stack 等。
我建议使用 Expo,我相信能给我们节省很多自己码代码的时间。学别人规划好的框架,很多时候比自己慢慢手搓要快很多。
不需要 Expo Go
Expo 的网站会推荐我们安装 Expo Go 作为开发调试工具。不过实测之后我发现,App Store 的 iOS 版本无法手动输入 URL,而且我们构建项目的时候,就会包含 Expo Go 的功能,所以这个东西在不需要 EAS(即 Expo 提供的云编译服务)时完全没用,可以不用考虑。
iOS
Ruby & Gem
使用 React Native 需要用到 CocosPods,这是基于 Ruby 写的一些东西,所以必须通过 Gem 下载。国内访问 Gem 源非常的慢,几乎不可用。
不过解决方案很简单,换国内镜像即可。
升级 Xcode 及 SDK
虽然可能不开发原生应用,但我相信所有开发者 macOS 上都有安装 Xcode。第一次安装 Xcode 的时候,可能会随手装一些 SDK 并创建虚拟机,那么当你准备尝试 React Native 开发,配置开发环境的时候,旧的 SDK 和虚拟机就可能带来问题。
解决方案也不复杂:删掉旧的虚拟机,安装新版本 SDK,即可。
项目目录不能有空格
2024 年了,居然还有这种问题……总之吧,从根目录算起,一直到我们的 React Native 项目目录,目录名都不能有空格。我建议尽量只使用英文字母和数字作为目录名,几乎不会遇到问题。
Android
Android 所需的网络环境之恶劣超出我的想象。可能跟它比较依赖 Google 服务有关,大量服务要么完全无法访问,要么速度非常慢。而且跟梯子没什么关系,很多东西可以直连下载,但就是慢的无法使用。
不要使用太新的 SDK
比如以目前这个时间点,Android 35 和 NDK 27 都不行,最高只可以用 Android 34 + NDK 26。我甚至建议大家用旧一些的,反正我们搞 React Native,没必要追新。
手动处理 gradle-{VERSION}-all.zip
我的 Android 环境 第一关卡在下载 gradle-N-N-all.zip 上。其实我本地网络直连下载也没问题,但就是太慢。200+MB 的包每次都超时。解决方案是先手动下载,慢慢下不着急,普通网络也几乎不会失败。
然后把文件放到
android/gradle/wrapper/目录下面。接着修改android/gradle/wrapper/gradle-wrapper.properties文件,把其中distributionUrl指向对应的相对路径,即可。再次执行
expo run:android,就从本地处理,速度飞快。手动处理
react-android-{VERSION}-debug.aar第二个卡住我的是 react-android-0.74.5-debug.aar,问题表现跟上面的 gradle 一样,不是不能下载,就是慢。以至于我抱着试一试的想法坚持了两天……
最终找到解决方案,手动下载文件,然后找到本地目录
$HOME/.gradle/caches/modules-2/files-2.1/com.facebook.react/react-android,在里面找到对应的版本号,然后在里面可以看到几个目录,找到里面有.pom文件的目录,把刚才下载好的文件放进去,即可。其它文件
其它文件下载也需要很久,但是不至于因为超时失败,我就不多说了,耐心等待即可。我也试过换源,但是不知为何,都不成功,可能跟 React Native 有关?国内镜像源都只针对了一般 Android 仓库?
总结
希望以后网络环境会愈来越好。希望这篇博客里的经验对大家有用。
以后要好好钻研 React Native 开发了,相信会有更多的内容分享给大家。敬请期待吧。如果各位读者有什么想看的,也不妨留言告诉我。
-

iOS Safari 播放音频的技巧分享
开发 Web App 一直是个蛮尴尬的事情。一方面,Google 不断在推;PWA 等技术也越来越好;另一方面,当我们真的像用 Web 开发 App 的时候,总会在不同地方踩到各种坑,猝不及防。尤其是 iOS Safari,各种 bug 层出不穷,坑死个人。今天分享一下在 iOS Safari 播放音频的一些技巧,希望节省大家将来的时间。
基础:使用
new Audio()创建播放器播放声音看起来并不难,在桌面上实现也比较简单,直接
const audio = new Audio(音频地址); audio.play()就好了。只要你的音频文件可以正常访问,那你应该很快就能听到声音。这个过程实际上创建了一个
HTMLAudioElement的实例。所以,你也可以在页面上直接插入一个<audio>,然后通过某种方式拿到它的引用,再使用它播放音频。除了初始化的时候传入音频地址,在任何时候改变音频地址,然后再
.play()都可以,很简单。在用户点击时提前创建播放器
但是在 iOS Safari 上,就有问题。因为手机端的限制更严格,只有用户主动操作(点击、敲击)时,新建的
<audio>才能正常播放。如果你在其它时间创建<audio>并播放,一切看起来都是正常的,没有任何报错,网络也会正常产生请求,但就是不会发出声音。解决方案就是在用户产生点击、敲击的时候,就把
<audio>创建好,然后在需要播放的时候让它播放声音。双播放器切换实现连续播放
有时候我们需要循环连续播放一首歌,比如游戏背景音乐,或者打坐的时候播一些背景音。这个时候我们也有两个选择:
- 使用
<audio>。- 好处:流式播放,速度会更快;有
loop属性,更方便。 - 坏处:如果是长音频,由于流式播放的原因,用过的数据可能被随时丢弃,所以新的一遍开始时可能会卡 0.N 秒,无法避免
- 好处:流式播放,速度会更快;有
- 使用 Web Audio API 等更高级的 API
- 好处:音频在内存里,想怎么播怎么播,还可以加入各种效果
- 坏处:第一次播需要完成加载,或者手动实现音频流,很麻烦
我建议用第二个方法,使用起来比较简单。只要在开始的时候创建两个
<aduio>对象,然后在第一个播放结束前 250ms 启动第二个即可。至于为什么是 250ms,因为按照规范,onTimeUpdate的触发周期是最长不超过 250ms,所以这样可以比较好的切换播放器。以下是我在 Nuxt3 里使用的 composable,供大家参考:
import type { AudioOptions } from '~/types'; export function useAudio({ src, loop, loopOverlap, autoplay, }: AudioOptions = {}) { const audio = shallowRef<HTMLAudioElement>(); const backup = shallowRef<HTMLAudioElement>(); const isStarting = ref<boolean>(false); const isPlaying = ref<boolean>(false); const playhead = ref<number>(0); const duration = ref<number>(0); async function playAudio(src: string, overlap: number = 0.25): Promise<void> { if (!audio.value) { initAudio(src); } if (audio.value) { audio.value.src = src; if (loop && backup.value) { backup.value.src = src; } } if (overlap !== undefined) { loopOverlap = overlap; } isStarting.value = true; await audio.value?.play(); } function pauseAudio(): void { audio.value?.pause(); backup.value?.pause(); } function initAudio(src: string): void { audio.value = new Audio(src); if (src) { audio.value.preload = 'auto'; } audio.value.autoplay = autoplay || false; if (loop) { backup.value = new Audio(src); backup.value.preload = 'auto'; backup.value.autoplay = false; backup.value.addEventListener('ended', onEnded); backup.value.addEventListener('timeupdate', onTimeUpdate); backup.value.addEventListener('play', onPlay); backup.value.addEventListener('playing', onPlaying); backup.value.addEventListener('pause', onPause); backup.value.addEventListener('loadedmetadata', onMetadata); } audio.value.addEventListener('timeupdate', onTimeUpdate); audio.value.addEventListener('loadedmetadata', onMetadata); audio.value.addEventListener('ended', onEnded); audio.value.addEventListener('pause', onPause); audio.value.addEventListener('play', onPlay); audio.value.addEventListener('playing', onPlaying); } function destroy(): void { if (audio.value) { audio.value.removeEventListener('timeupdate', onTimeUpdate); audio.value.removeEventListener('loadedmetadata', onMetadata); audio.value.removeEventListener('ended', onEnded); audio.value.removeEventListener('pause', onPause); audio.value.removeEventListener('play', onPlay); audio.value.removeEventListener('playing', onPlaying); audio.value = undefined; } if (!backup.value) return; backup.value.removeEventListener('ended', onEnded); backup.value.removeEventListener('loadedmetadata', onMetadata); backup.value.removeEventListener('timeupdate', onTimeUpdate); backup.value.removeEventListener('play', onPlay); backup.value.removeEventListener('playing', onPlaying); backup.value.removeEventListener('pause', onPause); backup.value = undefined; } function onPlay(): void { isPlaying.value = true; } function onPlaying(): void { isStarting.value = false; } function onPause(): void { isPlaying.value = false; } function onEnded(event: Event): void { if (loop) { (event.target as HTMLAudioElement).currentTime = 0; } else { isPlaying.value = false; } } function onTimeUpdate(): void { playhead.value = audio.value?.currentTime || 0; if (!loop) return; if (!playhead.value || !duration.value || playhead.value < duration.value - loopOverlap) return; backup.value?.play(); // swap audio and backup const temp = audio.value; audio.value = backup.value; backup.value = temp; } function onMetadata(event: Event): void { duration.value = (event.target as HTMLAudioElement).duration || 0; } if (src) { initAudio(src); } return { audio, isStarting, isPlaying, playhead, playAudio, pauseAudio, destroy, }; }使用 macOS Safari 调试 iOS Safari
Safari 烂归烂,但是有一点:iOS Safari 的 bug 大多可以在 macOS 上复现。所以大多数时候,我们可以直接在 macOS 完成调试。但是有时候,我们必须在 iOS 上进行调试,比如涉及到输入框影响屏幕高度、或者上面提到的播放问题。
于是,使用 macOS Safari 调试 iOS Safari 也是必备技能,需要的同学请看我的这个视频:
总结
我有时候会怀疑,苹果是不是故意劣化 Safari,以避免被 Web App 抢走原生 App 的市场份额,不然 iOS Safari 的 bug 不应该这么多。
如果大家对 Web App 开发感兴趣,有相关的问题,欢迎留言讨论。
- 使用
-

一个超级诡异的 iOS Safari `position: fixed` 失效问题
今天前同事李某找我咨询 Hybrid 开发的问题,想起来大前天搞这个问题搞了一天,赶紧记下来,省得忘记。
先说需求。东家让我做个日历组件,在手机 Web 上用。组件的样式是这样的,很多地方都可以见到,比如南航国航的客户端。

看起来并不复杂,事实上也是,基本上顺顺利利的开发完成,准备交付。这里有个伏笔,开发中我按老习惯,使用桌面 Chrome,和实际生产环境不太一样。不过我自然要去真机上测试,结果一测问题就出来了。
因为组件需要全屏展示,所以我设置了如下的CSS:
.date-picker { position:fixed; top:0; left:0; right:0; bottom:0; background-color: white; z-index:1024; }同时,对原本的
<input name="date">,我给它加上readonly,避免弹出虚拟键盘。理论上,这样的就可以了。但实测时,不滚屏的时候,组件弹出时尺寸是准确的,盖满全屏;然则一旦滚屏,组件就会占据从页面最上方到当前最下面这截位置。大约相当于position:absolte;top:0的效果。

如图,可以看到组件占据了全屏,但实际是从页面最上面开始的,定位有问题。用桌面 Safari 调试也可以看出来它的高度是 968,远大于正常的 667。这很诡异,上下左右全为0,是上古巨兽 IE6 都支持的做法。iOS Safari 虽然 Bug 多多,不应该连这个都有毛病啊。以
ios safari position fixed为关键词 Google 之,结果 iOS Safari 历史不清白,当年 iPhone 刚出的时候的确有定位问题,于是虽有满屏的结果,但都不适用。然后我想到找其它库,比如 Bootstrap,它的 Modal 组件也是类似的效果。但是怎么测都正常,于是我只好一个样式一个样式修改,仍然没有结果。
时间慢慢流逝,转眼已经凌晨2点了,就在我几欲放弃之际,突然发现,虽然组件弹出的时候定位有问题,但只要我点掉下面的完成,定位就会立刻恢复正常。

注意,就是那个“完成”。问题至此已经明朗:在 iOS Safari 里,即使
<input>设置了readonly,它仍然可以获取输入焦点。获取输入焦点之后,虽然没有弹出虚拟键盘,但仍然是待输入状态。此时页面各种交互都是正常工作的,比如点击、滚屏。唯独
position:fixed定位有问题。点击“完成”离开输入状态,Safari 自动刷新页面元素,定位就正常了。于是我在组件弹出后,自动
input.blur(),使其失去焦点,组件的尺寸便正常无误了。
总结
移动端 Web 开发总有各种各样稀奇古怪的问题。有些好解决,有些不好解决,比如这个问题,很难定位:
- 历史不清白,搜也搜不到
- 组件要求全屏,需要避免虚拟键盘,所以会改变默认行为
- 其它情况下都是好的
我能想到的方案,就是想办法,用所有能用的工具,排除掉所有其它问题,最终还是能搞出来的。
-

解决 iOS webkit 使用CSS动画时闪烁的问题
这个,咱们必须承认版本管理不是万能的,尤其对于像我这样习惯不好的人来说,更是如此。比如,上次不知道改了什么东西,导致肉大师制作的杂志在 iOS 里突然就变卡了,而且不仅卡,还伴随黑块、切换图片的最后会闪一下。
开始我一直以为是性能问题,调啊调啊调啊,就是不见好。于是 Google “ios phonegap 闪烁”,然后发现一篇文章,内容是解决 iOS 下 Safari 渲染 Transition 时页面闪动的问题。说只要加一句
-webkit-backface-visibility: hidden;就行,这样可以避免元素转换时显示元素背面。虽然听起来莫名其妙,不过我觉得有戏,但是文章里没有提应该加在哪儿,所以我想了想,在样式表里加了一句:#container { -webkit-backface-visibility: hidden; }因为我用到 iScroll,而负责动画的CSS是加在
#container上,所以我就理所当然的把这段代码加在这里。测试,没有效果。于是我一发狠,把这段样式加在所有元素上,居然问题就解决了。
#viewport * { -webkit-backface-visibility: hidden; }
