标签: ios

  • 【系列教程】使用 Vercel Serverless function 连接 APNs 实现 iOS 推送通知(3)时区处理

    【系列教程】使用 Vercel Serverless function 连接 APNs 实现 iOS 推送通知(3)时区处理

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

    (更多…)
  • 【系列教程】使用 Vercel Serverless function 连接 APNs 实现 iOS 推送通知(2)代码解析

    【系列教程】使用 Vercel Serverless function 连接 APNs 实现 iOS 推送通知(2)代码解析

    上一篇文章我们分享了 Push Notification 的基础原理和项目配置,这一篇我们开始看具体的代码。

    (更多…)
  • 2024 中国大陆搭建 React Native 开发环境

    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 播放音频的技巧分享

    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> 创建好,然后在需要播放的时候让它播放声音。

    双播放器切换实现连续播放

    有时候我们需要循环连续播放一首歌,比如游戏背景音乐,或者打坐的时候播一些背景音。这个时候我们也有两个选择:

    1. 使用 <audio>
      • 好处:流式播放,速度会更快;有 loop 属性,更方便。
      • 坏处:如果是长音频,由于流式播放的原因,用过的数据可能被随时丢弃,所以新的一遍开始时可能会卡 0.N 秒,无法避免
    2. 使用 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` 失效问题

    一个超级诡异的 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 截图
    手机截图
    如图,可以看到组件占据了全屏,但实际是从页面最上面开始的,定位有问题。用桌面 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 开发总有各种各样稀奇古怪的问题。有些好解决,有些不好解决,比如这个问题,很难定位:

    1. 历史不清白,搜也搜不到
    2. 组件要求全屏,需要避免虚拟键盘,所以会改变默认行为
    3. 其它情况下都是好的

    我能想到的方案,就是想办法,用所有能用的工具,排除掉所有其它问题,最终还是能搞出来的。

  • 解决 iOS webkit 使用CSS动画时闪烁的问题

    解决 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;
    }