Reqable项目日志:从桌面端适配移动端的Flutter实践之旅

9,352 阅读31分钟

Reqable是基于FlutterC/C++实现的API测试+调试一体化开发工具,桌面端版本从6月份正式发布以来,经过小半年的打磨迭代,功能已经基本成型。在国庆之后,我决定推出Reqable移动端版本,覆盖全终端平台,整个过程花费大约两个月的时间。

screenshot_android-36bc1b66363133351dfd5e402c017882.jpg

本文的目的,是向大家分享我关于Reqable从桌面端向移动端适配的实践总结,希望能够帮助大家坑,少走一些弯路,更快交付。有必要说明的是,Reqable项目完全由我个人独立完成,受限于本人经验和能力,肯定有非常多的不足。如果你对本篇文章中提到的技术选型、处理方式等有不同的见解,欢迎提交评论,相互讨论,共同学习。

1. 项目背景

和大部分Flutter项目不一样,Reqable的主要业务方向是在桌面端,虽然早期有移动端的规划(这也是为什么选用Flutter框架的原因之一),但是为了尽快上线桌面端,整个开发模式没有采用常规的移动端方式,所以给移动端适配欠了不少技术债,适配的过程有非常大一部分是在偿还技术债。

Reqable客户端项目由FlutterC/C++实现,其中Dart语言大约占80%,C/C++20%,当然其他还包括Python等语言,占比太小可忽略不计。Reqable的UI/UX全部由Flutter框架实现,大部分的功能也是由Dart编写,但是一些核心的库是通过C/C++来实现,例如网络库、流量分析模块等等。

因此适配主要是三大方面的工作:

  • C/C++库的移植和编译
  • UI/UX的适配
  • 功能的适配

本篇文章将按照这三个部分展开来讲。

2. C/C++库的移植和编译

在不同的硬件和系统平台下,C/C++的编译出来的制品是不同的,比如Reqable在桌面端下的编译制品如下:

  • Windows x64平台:.dll、.lib和.pdb三个文件。
  • Mac(x64/arm64)平台:.dylib动态库。
  • Linux x64平台: .so动态库。

这些基本都没有交叉编译,分别在各自的系统平台上编译,除了Mac是在M2上分别编译了x64和arm64的制品库。

但是移动端制品库一般都是要进行交叉编译,因为移动端本身不是生产平台。由于我的主要工作机器是MacBook M2,也就是需要在MacBook M2上交叉编译出移动端平台的制品库:

  • Android armeabi-v7a:.so动态库。
  • Android arm64-v8a:.so动态库。
  • iOS arm64:.framework库。

2.1 CMake配置

Reqable所有的C/C++库都是使用CMake组织编译的,适配移动端需要额外处理下面两个部分:

  • CMakeLists.txt添加移动端的相关命令
  • cmake命令指定编译工具链。

2.1.1 CMakeLists.txt

Android的处理相对来说简单一点,基本上是通用配置,在配置link_directories的时候需要通过ANDROID_ABI来指定相应架构的依赖库路径,因为有armeabi-v7a和arm64-v8a两个不同的架构平台。

iOS的就相对复杂很多了,在iOS上必须使用.framework库而不能使用.dylib库,由于相关文档不多,这部分花费了较长时间。注意.framework库并不是一个文件,而是一个文件夹,幸好CMake也是支持的,下面是Reqable编写的配置代码。

if (CMAKE_SYSTEM_NAME STREQUAL iOS)
  set(CMAKE_OSX_DEPLOYMENT_TARGET 12.0)
  set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED NO)
  set(CMAKE_XCODE_ATTRIBUTE_ENABLE_BITCODE NO)
  set(CMAKE_XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH NO)
  set(CMAKE_XCODE_ATTRIBUTE_ARCHS[sdk=iphoneos*] "arm64")
  set(CMAKE_XCODE_ATTRIBUTE_ARCHS[sdk=iphonesimulator*] "x86_64")
  set(CMAKE_XCODE_ATTRIBUTE_VALID_ARCHS[sdk=iphoneos*] "arm64")
  set(CMAKE_XCODE_ATTRIBUTE_VALID_ARCHS[sdk=iphonesimulator*] "x86_64")
  set(CMAKE_IOS_INSTALL_COMBINED YES)

  set(CURRENT_VERSION 1.0.0)
  set_target_properties(
    ${CMAKE_PROJECT_NAME}
    PROPERTIES
    FRAMEWORK TRUE
    FRAMEWORK_VERSION A
    MACOSX_FRAMEWORK_IDENTIFIER com.reqable.http
    MACOSX_FRAMEWORK_SHORT_VERSION_STRING ${CURRENT_VERSION}
    MACOSX_FRAMEWORK_BUNDLE_VERSION ${CURRENT_VERSION}
    VERSION ${CURRENT_VERSION}
    SOVERSION 1.0.0
    CXX_VISIBILITY_PRESET hidden
    LINK_FLAGS "-s"
  )
endif()

2.1.2 cmake命令

使用cmake命令编译分为两个阶段,一个是生成配置,一个是编译。后者比较简单,也是通用的,麻烦的主要是前者。

编译Android需要安装NDK,可以直接通过Android Studio选择版本进行安装,也可以前往官网下载压缩包。生成配置的时候,需要在命令行指定NDK的路径以及工具链,示例如下:

cmake . -B build -DCMAKE_TOOLCHAIN_FILE={ndkHome}/build/cmake/android.toolchain.cmake -DCMAKE_BUILD_TYPE=Release -DANDROID_NDK={ndkHome} -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-21

编译iOS需要依赖XCode,并指定iphoneos还是iphonesimulator,示例如下:

cmake . -G Xcode -T buildsystem=1 -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT=iphoneos

如果不需要在iPhone模拟器上运行,只需要编译上面的iphoneos就可以了,否则还需要额外编译iphonesimulator(替换掉上面的iphoneos即可)。Mac有个特点,可以将不同架构的二进制文件合并成一个。为了方便后面编译时链接,Reqable采用的做法就是依次编译iphoneos和iphonesimulator(注意模拟器是x64架构,应该也是arm64才对,这个有点奇怪),然后通过lipo命令将两个架构的库合并到一起,示例如下:

lipo -create a1.framework/a1 a2.framework/a2 -output a.framework/a

2.2 Cronet移植

为了支持HTTP3(QUIC),Reqable的网络库是基于Cronet定制和移植的,这个过程不是本篇文章的内容,不多讲,对其感兴趣的可以阅读我之前写的这篇博客:Flutter如何实现一个支持HTTP3的网络框架 。本文主要讲一下Android和iOS移植的过程和遇到的问题。

2.2.1 编译Cronet

编译Android的Cronet需要Ubuntu环境,当然编译Linux库的环境也是一样的,就直接拿过来用了。但是Android需要配置一些额外的环境。Cronet官方提供了一个脚本,拉完Chromium代码后,运行一下即可:

./build/install-build-deps-android.sh

iOS和Mac一样,没有额外的步骤。

移动端编译和桌面端编译不一样,需要使用官方提供的/components/cronet/tools/cr_cronet.py脚本,这个脚本内置实现了一些配置逻辑,可以帮我们跳过输入编译参数的步骤。

整个编译的细节不多讲,有兴趣阅读我前面的文章,或者参考这个也可以:github.com/Sunbreak/cr…

对于Android,Reqable编译armeabi-v7a和arm64-v8a两个架构;对于iOS,Reqable编译iphoneos和iphonesimulator,然后手动调用lipo命令合并成一个。

2.2.2 集成Cronet

Dart调用C/C++需要使用ffi,这部分工作在开发桌面端的时候就已经完成了,所以我原先以为移动端集成会比较简单,但是事实并不是如此。

在Android上,Cronet默认会编译Java和JNI的代码,除了编译的制品库.so文件外,还需要将一些.jar库文件集成到项目里面,并且需要在Java层使用Context来初始化Cronet库。缺失了这一步,只要调用了Cronet的API必然会出现闪退。

ContextUtils.initApplicationContext(getApplicationContext())

在iOS上,Reqable遇到了代理不生效的问题,比如配置了Wifi代理,Cronet并不会使用Wifi代理。我在debug后发现Cronet在iOS上并没有实现代理的相关功能,但是底层net库是实现了的,然后我在Cronet里面加上了代理逻辑,主要是修改cronet_global_state_ios.mm文件。

至于iOS上为什么没有实现代理功能,我一直没有能搞明白,加上代理逻辑后一度担心兼容性的问题,但是事实上也并没有遇到,一切工作正常。对此,如果有大佬能解惑,我不胜感激。

2.3 Pipeline的扩展

Reqable客户端项目需要依赖4个C/C++的子项目,每次修改、编译、拷贝,步骤繁琐容易出错,所以Reqable有一套自己的制品库管理Pipeline。这个Pipeline的流程是:子模块修改、编译、发布(上传云端制品库),主客户端更新同步。同样的,子模块之间依赖也是这种方式,比如A模块可以依赖B模块的C/C++制品库。

在仅有桌面端的时候,不会涉及到交叉编译,Windows平台下编译、发布和同步Windows的制品库,完全不会涉及到交叉平台。但是开发移动端时候,问题就来了。我需要在Mac平台上编译、发布和同步Android或者iOS制品库,原有的Pipeline就无法支持了。

之前桌面端的制品库都是文件形式存在,现在iOS上面的制品库却是文件夹形式存在,这需要做一些支持。此外,Android需要同时支持armeabi-v7a和arm64-v8a两个架构,也就是同步制品库的时候需要一次同步两个架构的制品库。

Pipeline是使用Dart编写的,主客户端更新制品库的流程大体上这样的:读取pubspec.yaml文件中配置的库和版本,下载相应的制品库到prebuilt模块(ffiPlugin)。 pubspec.yaml是复用的Flutter的,我添加了一些自定义配置,示例如下:

nativelibraries:
  macos:
    - libreqable_http.dylib@1.0.0
  windows:
    - reqable_http.dll@1.0.0
    - reqable_http.lib@1.0.0
  linux:
    - libreqable_http.so@1.0.0
  android:
    - libreqable_http.so@1.0.0
  ios:
    - reqable_http.framework@1.0.0

然后就是同步命令配置:

Future<void> main(List<String> args) async {
  final CommandRunner runner =
      CommandRunner<void>('sync', 'Sync dynamic libraries')
        ..addCommand(LocalLibrarySyncCommand(
            repo: 'release',
            archive: args.length > 1 ? args[1] : null,
            outputs: {
              'macos': 'prebuilt/macos',
              'windows': 'prebuilt/windows',
              'linux': 'prebuilt/linux'
            }))
        ..addCommand(CrossLibrarySyncCommand(
            repo: 'release',
            outputs: {
              'ios-arm64': 'prebuilt/ios',
              'android-arm': 'prebuilt/android/src/main/jniLibs/armeabi-v7a',
              'android-arm64': 'prebuilt/android/src/main/jniLibs/arm64-v8a',
            }));
  await runner.run(args);
}

在原先桌面端sync命令的基础上,添加了一个移动端sync cross的命令。

3. UI/UX的适配

UI/UX是Reqable整个项目中代码量最繁重的部分,因此也是适配最耗时的一部分。桌面端面对的都是大屏和横屏,移动端面对的都是小屏和竖屏,不同的屏幕特性使得UI的布局以及交互方式有非常大的不同,桌面端更注重窗口(Window)交互,移动端更注重页面(Page)交互。此外,桌面端基本是鼠标操作,移动端主要是手指触控操作;对于文字输入,桌面端是硬键盘,移动端是软键盘。虽然Flutter给我们提供了基本且全面的功能,但是在贴近用户的层面上,还是需要我们处理非常多的细节,才能给用户带来优秀的产品体验。

3.1 字体大小

字号是Reqable遇到的第一个视觉问题。项目在手机上运行起来后(当然是桌面端的UI,没任何适配),我第一个感觉是字号严重偏小了。这里的偏小是感官上直接感受,我将桌面端和手机端的应用界面并排放在一起比较,对比看两者文字大小是差不多,但是到了手机的小屏幕上单独看,感官上就是严重偏小,虽然在代码层面上,字号都是一样的。

我不是设计师,困扰了很久,以为是Flutter框架的问题,后来才明白,是屏幕大小不同导致的。在桌面端,由于电脑屏幕大,通过鼠标精确操作,小号字体直观上并不会太难受;到了手机上,屏幕小了,虽然字号没变也都是按照屏幕密度来渲染的但是交互确实就不舒服了;如果直接调大默认字号,桌面端又感觉严重偏大了;因此,我得出一个结论:以桌面端为基准时,移动端需要对元素进行放大显示,无论是字体、图标还是按钮都应该按照这个原则。

最简单的实现是可以有一个全局的scale值,在移动端和桌面端采用不同的配置值。但很遗憾Flutter并没有,有一个讨论很多的Issue #32115。在顶层Widget采用Transform.scale也并不行,会导致事件位置等出问题。

Reqable最后采用的方式是统一定义应用全局的TextStyleIconStyle,在这些Style内部实现里对桌面端和移动端采用不同的scale值,目前的配置是桌面端1.0,移动端1.2。项目内所有的文字显示、图标显示,全部采用全局统一的这些Style。一些不得不Hard Code的宽高参数等需要手动乘以scale值。这很明显不是一个好的方式,但是我没有找到更好的处理方式。如有好的方案,欢迎指导,不甚感激。

3.2 布局设计

前面讲到,桌面端大屏横屏,更注重窗口(Window)交互,移动端小屏竖屏,更注重页面(Page)交互。桌面端很少有页面(Page)的概念,更多是面板(Panel)的概念,一个窗口下有个多个面板。下面是一个经典的桌面端布局,我以Reqable为例:

截屏2023-12-19 15.13.58.png

在移动端一屏中显示这么多元素是不合适的,我们需要将每个面板都设计成单独的页面。例如下图就就是不同面板的布局:

Screenshot 2023-12-19 at 15.22.37.png

在桌面端,我们可以通过顶部Tab来切换不同的功能面板,Tab是横向布局的,在移动端虽然也可以放在顶部来实现,但是会挤压主内容页面的空间,非常不合适。所以我们将其放到了侧边抽屉(Drawer)中。

Screenshot 2023-12-19 at 15.33.55.png

除了各种功能面板外,桌面端还会通过一些弹窗(Dialog)与用户进行交互,有些弹窗可以直接用在移动端,比如警告弹窗(AlertDialog),有些则不合适,比如保存API到集合弹窗。所以在移动端需将弹窗改为页面(Page)形式。

最后一个方面,Reqable在桌面端还支持多窗口,比如设置页面。这些在移动端也需要改成页面形式。

3.3 布局复用

移动端和桌面端的交互形式不同,布局设计方案也不同,如果将桌面端已有的布局代理复制一套修改成移动端,那工作量太大了,后续也难以维护,是完全不可取的。得益于Flutter的Everything is Widget的理念,在桌面端和移动端复用同一个布局变得更非常简单,我们只需要基于已有的功能布局,分别再包装成桌面端和移动端的Widget即可。

当然,前提是采用了UI、逻辑和数据分离的开发模式,Reqable桌面端使用的Bloc状态管理框架,开发时也严格执行了分离模式,因此在布局适配的时候变得异常简单,90%以上的功能布局都是完全复用的,另外不到10%都是桌面端和移动端特定布局,例如移动端的侧边抽屉布局,桌面端的调试列表布局等等。

3.4 上下文菜单

在桌面端,我们可以通过鼠标右键打开一个上下文菜单,例如下面这种:

截屏2023-12-19 23.48.00.png

注意:Reqable并没有采用平台特定风格的上下文菜单,而是通过自定义Widget实现的,方便统一风格和逻辑。

在移动端,上面的交互和样式很明显就不合适了。移动端没有鼠标右键,正常的交互习惯是长按弹出上下文菜单。而且移动空间有限,无法直接实现菜单分组,需要通过二次弹出的方式显示分组菜单。下图是Reqable上下文菜单在移动端的样式。

1291703001200_.pic_副本.png

3.5 代码编辑器

代码编辑器(CodeEditor)是一个单独的Widget,拎出来讲一方面是因为适配过于困难,另一方面是这个Widget的适配过程全面地体现了桌面端和移动端交互的差异。

我之前写过一篇Reqable代码编辑器实现过程的文章:juejin.cn/post/724667… ,那时候还没有考虑过移动端。现在来适配移动端,就遇到了很多的问题。

3.5.1 手势

桌面端我们对屏幕内容进行操作,主要有两种方式:鼠标和触摸板。鼠标一般在台式机上用的比较多,而触摸板在笔记本上用得比较多。当然,还有一些其他交互的方式,比较小众,这里先不考虑。这里文本操作不是指文本输入(后面会单独讲),而是通过手势(Gesture)操作,例如选词、选择文本和滚动文本。

在桌面端,编辑器常见的手势操作有:双击选词,按住鼠标左键(或者长按触摸板)拖动来选择文本、滑动鼠标滚轮(两指滑动触摸板)来滚动文本内容,鼠标右键弹出操作菜单等等;但是在移动端,需要长按屏幕出现选择器,拖动选择器句柄(Handle)来调整文本选中范围,功能菜单也是通过长按手势出现。

可以看出,在两端上手势控制存在巨大的差异,原先桌面端的手势逻辑在移动端几乎都不适用了。我不得不将所有手势控制逻辑全部重写,桌面端和移动端采用不同的实现方案。

3.5.2 文本选择器

在桌面端是没有文本选择器(SelectionOverlay)这个概念的,但是在移动端控制文本选择范围必须通过选择器来操作,毕竟手指控制的精确度远远比不上鼠标控制。选择器一般在长按文本内容或者双击选词后出现,手指拖动选择器的两端的句柄(Handle)可以对选择范围的进行调整。手指离开屏幕后,自动弹出功能菜单(Toolbar)。此外,出现选择器的时候不出现光标(Caret),编辑器失去焦点后选择器也会消失,Android和iOS上选择器的句柄样式也各不相同,等等。

Screenshot 2023-12-19 at 22.20.42.png

从绘制层级上来讲,文本选择器的层级在文本内容、背景色、前景色等层级之上。实现有两种方案:

  • 直接在编辑器的RenderBox的里面绘制,但是绘制区域只能被限制在编辑器视图内部,此外还需要实现选择器句柄的各种触摸事件,例如拖动和点击。这个方案难度有点大,句柄移动到视图边界时会显示不全或者无法显示。
  • 写一个自定义Widget加入Overlay来实现,前面方案的缺陷都可以轻松解决,但是难点在于选择器两端句柄的位置,毕竟不在一个视图里面了。

我研究了下官方TextFeild文本选择器的实现逻辑,最后采用了上面的第二种方案,那么句柄位置是如何解决的呢?有一对叫做LeaderLayerFollowerLayer的兄弟,可以通过共享LayerLink来同步位置,在编辑器的RenderBox里面使用LeaderLayer确定好绘制位置,那么在RenderBox外部就可以通过FollowerLayer来在相同位置绘制出内容。

void _drawHandleLayer(PaintingContext context, LayerLink layer, Offset position, Offset offset) {
    final Offset point = Offset(
      clampDouble(position.dx, 0.0, size.width),
      clampDouble(position.dy, 0.0, size.height),
    );
    context.pushLayer(
      LeaderLayer(link: layer, offset: point + offset),
      super.paint,
      Offset.zero,
    );
  }

注意,上面函数中的LayerLink参数是和外部共享的相同实例。

除此之外,文本选择器还有更多的细节需要处理,例如拖动句柄超出编辑器视图范围时,应该自动滚动编辑器内容以便选择更多。还有文本选择器句柄显示和消失时淡入淡出的动画效果(哈?这个有必要吗,但我是有追求的!),等等。

3.5.3 功能菜单

在桌面端,功能菜单(Toolbar)一般通过鼠标右键(触摸板双指长按)打开;在移动端,功能菜单的逻辑更为复杂,一般是在文本选择器选择完成后自动弹出,当重新开始进行文本选择时自动消失,如果菜单消失还可以通过长按选择文本或者点击选择器句柄重新弹出。而弹出的位置同样需要精心设计才能满足用户体验,例如手指向上拖动选择器句柄后松开,菜单应该出现在句柄上面,就近原则才方便用户后续操作。

从绘制层级上来讲,功能菜单的层级在所有内容上面,包括编辑器本身、上一小节的文本选择器等等。

从实现机制上来讲,功能菜单的绘制逻辑和文本选择器有些类似,但是仍然有很大的不同。类似是因为都是使用OverlayLayerLink机制来控制绘制位置,不同是因为使用了CompositedTransformTargetCompositedTransformFollower这两个现成类来实现。

这部分的代码大量参考了Flutter官方的TextFeild的内部实现逻辑,有必要说明下。

3.5.4 放大镜

在移动端上,用户调整文本选择区域的时候,会出现一个放大镜视图,方便用户更清晰地看到选择区域边界的移动,因为手指可能会遮挡住文字内容。目前版本,Reqable编辑器还没有实现这个功能,后续有时间再补上。

3.5.5 键盘事件

终于讲到文本输入了,也是最后一个适配比较难的部分。文本输入一般是靠键盘,无论是主机键盘外设还是笔记本自带键盘,都是属于硬键盘(Hardware Keyboard),而智能手机是软键盘(Soft Keyboard)。严格来讲,桌面端也有软键盘,移动端也有硬键盘,但场景太小我们先不考虑。

在Flutter中,我们可以使用FocusonKey或者onKeyEvent回调来捕获键盘输入事件,但是对于软键盘来讲并不适用,这点在API的注释里面有明确的说明。实际测试下来,一些键盘事件确实无法捕获,例如换行键、回退键,部分Android机器上可以收到,但iPhone上完全不行。

Reqable的编辑器严重依赖换行键和回退键的监听,因为编辑器只会将当前编辑行的内容同步给IME模块,主要是处于性能考虑,详细的解释请阅读我的这篇博客:如何基于Flutter开发一个代码编辑器

换行键好处理,可以通过监听IME内容变化来检测,例如末尾多了一个换行符号。但是回退键就不好处理了,例如当输入光标位于行首的时候,回退键应该触发合并上一行和当前行的逻辑,由于编辑器和IME只同步当前行,光标位于行首就删无可删了,IME并不会通知你内容变更了。在寻找解决方案的时候,这篇文章Why you can’t detect a “delete” action in an empty Flutter text field 启发了我。最后Reqable解决方案就是在与IME同步内容的时候,默认在字符串前面追加一个额外字符,例如\u200b,当检测到这个字符被删除的时候,就触发删除上一行换行符的调用。

3.5.6 其他细节

除了上面这些,在细节方面还有很多需要完善的地方。比如,移动端没有快捷键触发搜索的功能,我们需要提供一个搜索图标让用户点击弹出搜索框;输入JSON时,字母键盘和符号键盘频繁切换带来非常差的用户体验,是否有必要开发一个自定义键盘功能?等等等等。

3.6 手势交互

在前面的编辑器小节里面,我已经讲过了部分手势交互的适配逻辑。在桌面端很少有手势控制的概念(除了苹果笔记本),例如横向手势控制Tab左右切换,多指控制图片放大缩小等等。

TabView为例,默认情况下官方组件是支持手势切换的,但是在开发桌面端的时候我给禁用了,原因就是测试下来非常不习惯。在移动端适配的时候,我尝试放开,但是遇到一个体验非常差的问题,当垂直方向也有滚动的时候,上下滑动会频繁误触发左右滑动,可以参考这个Issue:#110567

由于篇幅关系,有关UI/UX适配的部分就先讲到这里了,面面俱到的话真的就太多了。

4. 功能的适配

对于不同平台的功能实现,一般都是通过插件来实现。这个我就不多讲了,不同的应用有不同的功能,写出来实际的参考意义也不大。所以,在这一节里面我就挑一些重点事项讲讲。

4.1 文件存储

文件存储分为两种类型。一种是软件自动生成的数据文件,比如日志;一种是用户手动保存的文件,比如某张图片。前者,一般都是保存在应用沙盒目录下,通过path_provider插件库来获取沙盒路径,基本是没什么问题,只需要注意下不同平台可能需要调用不同的API;后者,桌面端和移动端的差异就很明显了,在桌面端一般是调用系统的文件管理器弹窗,让用户选择存储目录以及输入文件保存名称,常用的插件库是官方提供的file_selector,很遗憾在移动端就不适用了。下图是官方文档中的支持性说明:

截屏2023-12-20 12.05.22.png

Reqable的做法是弹出一个自定义的弹窗,让用户输入文件名称,然后保存到特定的目录下,并提示用户前往此目录查看。

另一个重要问题,Android的文件权限。从Android 13开始,废弃了READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE权限,禁止应用直接在sdcard目录下读写文件。对于媒体文件,有专门的替代权限用于存储,但是对于自定义文件(例如证书文件)就没有替代方案了。唯一的选择似乎只能将文件存储到应用沙盒路径,但是有两个新的问题,一是文件随着应用卸载会被自动删除,二是其他应用没法从沙盒目录下读取文件(例如证书安装器无法读到沙盒下的证书文件)。我研究了下,在Android 13上可以免权限将文件存储到公共的Download目录,大体上解决了问题,但是存在一个严重的bug:卸载应用后重新安装,再写入相同的文件覆盖,会提示没有权限。只能引导用户手动删除文件再保存,这个真的就很蛋疼。

4.2 文件读取

文件读取比文件存储的问题要少很多,我目前遇到的主要是Android通过file_selector无法获取文件路径的问题,获取到的路径都是content://这样的格式,这个是ContentProvider机制导致的。好在file_selector会一次性读取文件内容返回给调用者,我们只需要将文件内容写入到沙盒缓存目录下(创建一个临时文件)并返回这个临时文件路径即可。

4.3 动态Route

前面讲到桌面端主要交互形式都是基于窗口(Window)而不是页面(Page),所以Reqable在桌面端的实现里完全没有用到Router。在移动端我就不得不采用GoRouter的方案了,但是桌面端和移动端各写一套路由逻辑那肯定是相当得难受。例如,桌面端弹出保存API到集合弹窗(Dialog),移动端跳转保存API到集合页面(Page),下面的代码是一种简单的解决方案,但是不够优美:

if (Platform.isAndroid || Platform.iOS) {
    final result = navigateToSaveApiDialog();
    // TODO
} else {
    final result = navigateToSaveApiPage();
    // TODO
}

我认为好的解决方式是无论是桌面端还是移动端,在业务逻辑里面都应该是一套代码,例如这样:

final result = navigateToSaveApi();
// TODO

因此,Reqable采用了拓展GoRoute的方案,内部根据平台类型跳转相应的视图(Dialog或者Page)。

class PlatformGoRoute extends GoRoute {

  PlatformGoRoute({
    required super.path,
    GoRouterPageBuilder? desktopPageBuilder,
    GoRouterPageBuilder? mobilePageBuilder,
  }) : super(
    pageBuilder: (context, state) {
      if (context.isDesktop && desktopPageBuilder != null) {
        return desktopPageBuilder.call(context, state);
      }
      if (context.isMobile && mobilePageBuilder != null) {
        return mobilePageBuilder.call(context, state);
      }
      throw UnimplementedError();
    }
  );

}

PlatformGoRoute(
    path: _kRouterPatSaveApi,
    desktopPageBuilder: (context, state) => const MaterialDialog(
      child: SaveApiDialog()
    ),
    mobilePageBuilder: (context, state) => const MaterialPage(
      child: SaveApiPage()
    ),
)

context.push(_kRouterPatSaveApi);

4.4 平台特性的处理

桌面端应用上有很多特有的东西,例如应用菜单、托盘菜单、多窗口等等。在适配移动端的时候,我们需要屏蔽掉这些特性,这里以托盘功能为例:

if (Platform.isAndroid || Platform.iOS) {
    // Do nothing
} else {
    setupTray();
}

托盘功能可能有很多个API,如果在调用前都先判断下平台类型,那代码真的就太啰嗦了。我们在设计API的时候肯定是希望外部调用者傻瓜式调用,所以将平台判断放到托盘功能内部是十分有必要的,但是下面这样的代码也不好:

void setupTray() {
    if (Platform.isAndroid || Platform.iOS) {
        // TODO
    } else {
        // TODO
    }
}

Reqable的做法是抽象接口,然后委托给各自平台的实现,尽量不出现平台判断的逻辑,类似下面的代码:

void init() {
    _impl = Platform.isAndroid || Platform.iOS ? _MobileImpl() : _DesktopImpl();
}

void setupTray() {
    _impl.setupTray();
}

void updateTray() {
    _impl.updateTray();
}

4.5 Bloc的适配

Bloc类承载了Reqable中几乎所有与用户交互的逻辑,例如点击事件的处理、页面刷新的逻辑等等。在实际的适配过程中,我很少去修改这部分的代码,因为在页面交互和功能设计的时候,都严格保持了桌面端和移动端的一致性。当然,也有一些例外,比如首页。

虽然两端首页大部分功能都是一致的,但是依然会有一些差异。比如,桌面端首页会处理一些全局的快捷键功能;移动端首页可以添加或删除远程设备,切换Tab之后要自动关闭侧边菜单,等等。对于这种大部分功能一致,小部分功能有差异的,一般还是将通用功能写在BaseBloc中,桌面端和移动端分别继承再实现各自平台特有的逻辑。

4.6 应用内购

Reqable移动端的一个主要功能就是内购。相比于桌面端,应用内购是移动端得天独厚的优势,可以部分疏解产品在境外收款的困境。Reqable内购主要是针对两个平台:App Store和Play Store,接入方案是in_app_purchase,这两个平台接入内购各有各的麻烦,下面来简单讲一讲。

4.6.1 App Store

App Store也就是Apple应用商店,众所周知,Apple审核极严,尤其是内购这一块儿。Reqable售卖的许可证,属于数字产品,数字产品必须使用应用内购,甚至不能出现跳转网页或者提示去网页支付等行为。Reqable许可证是多平台通用,属于非订阅商品,要求稍微松一点,如果是订阅商品,还要求在应用详情中添加详细的订阅(续期)描述,否则审核会被拒。从工程方面来讲,没有什么难点,用到的API不多,参考in_app_purchase的示例代码即可。

4.6.1 Play Store

App Store也就是Google应用商店,审核就是走个流程,不出意外都能通过,难点在于测试。由于谷歌在国内被限制的原因,Play Store需要我们自行安装并登录谷歌账户才能进行后续的工作。虽然我提前准备好了,但是在测试的时候还是遇到两个问题。

  • 错误码6。根据文档描述说是Play自身的问题。解决方法是更新Play的应用版本,注意在应用内检查更新虽然一直提示是最新版本,但实际上并不是。
  • 错误码3。根据文档描述说是用户的问题,比如Play版本问题,账户地区不支持等。解决方法是使用非中国大陆地区的账号登录,没有的话可以自行注册一个,地区选美国,不需要绑定银行卡,后台配置成测试账号即可。

5. 移动端的定位

技术性的内容已经讲得差不多了,在最后谈一谈我对移动端开发工具的一些想法。相比于桌面端,移动端不适合作为生产平台,至少对于API开发测试这个领域是这样,但是移动端仍然具有其不可替代的优势。

我们可以想象一下这样的场景:下班的路上,有同事@你说接口挂了,你可以不理他,但如果确实你的问题第二天上班你可能就会被炒了。但是你身边并没有电脑,你不确定是不是有问题,是不是你的问题,但是掉头回去又不太合适,你希望回家吃饭回家陪家人,如果问题是误报或者与你无关,你为什么要回公司?这时候你肯定希望有一款能够在手机运行的开发工具,能够帮你紧急排查问题。这就是Reqable移动端的价值所在:随时随地帮你定位API问题。

我们不能否定上面场景的存在,但也不得不承认,这确实太小众了。所以Reqable在移动端主打的还是协同功能。那么,协同功能是什么?我们来梳理下使用Charles或者Fiddler等桌面端调试软件调试手机设备的步骤:

  • 电脑和手机设备连入同一个Wifi局域网。
  • 查看电脑的IP地址,并手动设置到手机端的Wifi代理中。
  • 一边操作手机,一边开始调试。
  • 调试结束,手动取消Wifi代理。

很多时候你会发现,捣鼓Wifi代理花费你非常多的时间。有可能是IP不对,有可能是输入错了,有可能是没有设置成功,有可能是IP变了,有可能是调试完忘记取消设置代理手机上不了网了,等等。如果你有以上这些烦恼,那么你可以试试Reqable的协同模式。

另一个问题,你可能和我一样都使用了Flutter框架来开发手机应用程序,然后你发现在抓包调试的时候,Wifi代理正确设置但是没有用。如果你对Dart的网络框架有研究,你会明白网络框架并没有尊重系统代理,代理就是是个摆设。那么你可以试试Reqable的VP.N模式。

Reqable移动端同步实现了桌面端的大部分功能,包括API请求测试,调试和测试联动,HTTP3(QUIC),API集合等等,相比于传统的API工具更加具有优势。

虽然我们希望移动端的功能越来越多,越来越好,但是不得不考虑限制滥用的问题。手机已经成为了我们生活的一部分,但也被很多不法分子盯上,间接受影响的便是开发工具,尤其是调试工具。所以,Reqable移动端删除了重写、脚本、断点等调试功能;如果有必要,我们会采取白名单机制,对可能受影响的应用进行过滤。

6 结语

好了,本篇文章就到这里,感谢大家阅读。

Reqable的理念是先进API生产力工具,宗旨是做优秀的国产软件。无论您是企业工程师还是个人开发者,我都希望Reqable的项目经验能够对您有所帮助。如果您对本篇文章满意的话,也可以通过订阅Reqable的方式来支持我。

Reqable的官网:reqable.com
GitHub建议&反馈:github.com/reqable/req…

如果您对Reqable有任何问题都可以与我联系,或者在GitHub上提交Issue!

感谢支持Reqable,谢谢!