主要开发内容

  • 将无障碍权限的判断和请求代码移动到PermissionManager中,AccessibilityManager目前仅负责启动无障碍服务
  • 设置页面实现
  • 电池优化白名单

设置页面实现

设置页面的构成很简单:设置标题、每个标题下有一组相关的设置项,最终实现的效果类似下面这样:
设置页效果预览
接下来对UI 实现的细节进行一些说明

设置项组和设置项实现

可以看到,在上述图片中,当一个标题下有多个设置项时,我们会把它们“贴合在一起”,每个设置项的接触处是没有圆角的,仅在整体的四个角有圆角,要实现这一点,就需要一个父组件来统一设置、管理圆角,不仅如此,整个设置项组外围的阴影、每个设置项之间的分割线等等也需要一个统一的父组件来管理,这就是编写设置项组的必要性。
除此之外,每个设置项的组成结构多是如下这样:
在这里插入图片描述
为了提高用户体验,对于一些点击后需要跳转至另一个页面的设置项,我们希望用户点击本设置项任意位置都能进行跳转,而不是只能点击设置项最右边的箭头图标,最好还能提供一个点击的反馈,例如水波纹特效等。
为了做到这些,并且为以后更加复杂的设置项做扩展,我定义了一个数据结构名为 SettingItem,代码很简单:

class SettingItem {
  const SettingItem({
    required this.tail,
    required this.title,
    required this.onTap,
  });

  final Widget tail;
  final Widget title;
  // 点击事件
  final Function() onTap;
}

它就是每个设置项的配置,包括了标题组件和尾部组件,以及整个设置项点击后的回调,注意该类本身并不是一个组件,它没有继承任何类,仅是每个设置项的配置。
然后我们的 SettingItemGroup 将接受一个 List<SettingItem>作为参数,利用该参数来构建每个设置项,这个 SettingItemGroup 就是一个组件类了,它继承自 StatelessWidget,是一个无状态组件,这是因为设置的值是其唯一需要的状态,而该状态由其父组件管理。
上述就是设置项组的设计理念,设置项组展示的实际组件就是设置项了,我们将其定义为SettingItemCard,该组件接收如下参数:

const SettingItemCard({
   super.key,
   required this.title,
   required this.tail,
   required this.onTap,
   this.topLeft = 12.0,
   this.topRight = 12.0,
   this.bottomLeft = 12.0,
   this.bottomRight = 12.0,
 });

final Widget tail;
final Widget title;

// 圆角
final double topLeft;
final double topRight;
final double bottomLeft;
final double bottomRight;

其中 tail、title 来源于前文提到的 SettingItem 配置,由 SettingItemGroup 创建设置项时传入。topLeft、topRight 等变量就是每个设置项的圆角了,每个圆角的默认值都是 12.0,后面我们会说到,设置项组将根据设置项的位置来设定每个角的圆角。
点击的水波纹特效也是在 SettingItemCard 中实现的,这里有一个小技巧,通常呢,我们如果直接将 InkWell 作为 Container 的父组件,将会导致水波纹特效被 Container 遮挡而显示不出来(如果你的 Container 设置了圆角,那么在圆角处能微微看到一点水波纹特效,因为 InkWell 水波纹特效范围默认是无圆角矩形的),为了解决水波纹特效被遮挡的问题,我们可以使用 Stack 组件辅助我们实现,关键代码如下:

Stack(
  children: [
    Positioned.fill(
      child: Material(
        clipBehavior: Clip.antiAlias,
        borderRadius: BorderRadius.only(
          topLeft: Radius.circular(topLeft),
          topRight: Radius.circular(topRight),
          bottomLeft: Radius.circular(bottomLeft),
          bottomRight: Radius.circular(bottomRight),
        ),
        child: InkWell(
          onTap: () {
            onTap();
          },
        ),
      ),
    ),
    Padding(
      padding: const EdgeInsets.all(16.0),
      child: Row(
        	// 省略... 此处就是设置项的实际内容
      ),
    ),
  ],
);

利用 Stack 组件的定义在其 children 列表后面的元素将显示在定义在前面的元素之上的特性来防止我们传入的设置项 title、tail 中的可点击组件的点击事件被 InkWell 拦截而导致无法接收到点击事件,并且通过 Positioned.fill 组件来指定以整个 Padding 组件的大小为标准,水波纹特效覆盖整个面积即可

相当于是整个 Padding 组件的大小把 Stack 的大小“撑起来”,此时 Stack 的大小与整个 Padding 组件相同,而 Positioned.fill 指明其子组件的大小与整个 Stack 组件相同,那么在此处,就是和整个 Padding 组件的大小相同。如此就能做到水波纹刚好全覆盖。

我们用 Material 组件组作为 InkWell 的父组件,这是为什么呢?看源码中 InkWell 的注释:
在这里插入图片描述
翻译一下就是“需要一个 Material 作为父组件来绘制水波纹特效”,我们不管其内部究竟是如何实现的,反正源码注释这样说了,我们这样用就行了。
并且源码注释也给出了另一个关键的内容:
在这里插入图片描述
前面说了,我们需要设置项有圆角,如果不做任何处理的话,水波纹是一个非圆角矩形,将超出设置项卡片的显示范围,这不是我们想要的,上面的注释指出,水波纹特效是在 Material 上绘制的,所以水波纹特效的裁剪应该由 Material 组件来负责,所以我们上述的 SettingItemCard 代码中才会有这么几句:

Material(
 clipBehavior: Clip.antiAlias,
 borderRadius: BorderRadius.only(
   topLeft: Radius.circular(topLeft),
   topRight: Radius.circular(topRight),
   bottomLeft: Radius.circular(bottomLeft),
   bottomRight: Radius.circular(bottomRight),
 ),
 child: InkWell(
   onTap: () {
     onTap();
   },
 ),

给 Material 设定与整个 SettingItemCard 相同圆角,并且设置 clipBehavior 为 Clip.anitAlias,也就是抗锯齿裁剪,视觉效果更好,该字段默认值是 Clip.none,也就是不裁剪,对比下指定该字段和不指定的区别:
不指定:
在这里插入图片描述
指定为 Clip.antiAlias:
在这里插入图片描述
实现好设置项后,就可以开始实现设置项组了,其负责计算设置项的每个角的圆角、设置项之间的分割线,在一个函数中即可完成:

  List<Widget> _buildSettingCards() {
    final result = <Widget>[];
    for (int i = 0; i < settingItems.length; i++) {
      late double topLeft;
      late double topRight;
      late double bottomLeft;
      late double bottomRight;
      if (i == 0 && settingItems.length == 1) {
        topLeft = 12.0;
        topRight = 12.0;
        bottomLeft = 12.0;
        bottomRight = 12.0;
      } else if (i == 0 && settingItems.length > 1) {
        topLeft = 12.0;
        topRight = 12.0;
        bottomLeft = 0;
        bottomRight = 0;
      } else if (i > 0 && i != settingItems.length - 1) {
        topLeft = 0;
        topRight = 0;
        bottomLeft = 0;
        bottomRight = 0;
      } else {
        topLeft = 0;
        topRight = 0;
        bottomLeft = 12.0;
        bottomRight = 12.0;
      }
      final settingItem = settingItems[i];
      result.add(
        SettingItemCard(
          tail: settingItem.tail,
          title: settingItem.title,
          topLeft: topLeft,
          topRight: topRight,
          bottomLeft: bottomLeft,
          bottomRight: bottomRight,
          onTap: () {
            settingItem.onTap();
          },
        ),
      );
      if (addDivider && i < settingItems.length - 1) {
        // 添加分割线
        result.add(
          Container(
            width: double.infinity,
            height: 1,
            color: Colors.grey.withAlpha(33),
          )
        );
      }
    }
    return result;
  }

我们根据当前设计项的位置和遍历时的下标来决定每个角的圆角以及是否添加分割线,注意没有直接使用Flutter 自带的 Divider组件作为分割线,而是自己用 Container 组件实现了一个简单的分割线组件,因为 Divider 组件内部有一个 Padding,将会导致设置项之间有空隙。

设置项值的管理

往往有些设置是存储在手机本地的,我们如何读取并展示呢?以及如何在设置项修改时将修改保存到本地呢?
我们在开发日志 2 中实现了一个 AppSettingsController,此时就派上用场了,例如,我们有一个是否开启数据同步的设置项,在 AppSettingsController 中:

  /// 是否启用数据同步
  final enableDataSync = false.obs;
  Future<void> _loadSettingsFromDisk() async {
   // 已省略无关代码
   enableDataSync.value = StoreUtils.pref.getBool(StoreKeys.ENABLE_DATA_SYNC) ?? true;
  }

_loadSettingsFromDisk 将在应用启动时调用,详见开发日志 2,从本地加载完成的数据直接以 Getx 中可观测的 obs 变量存储在内存中。
在定义设置项时,可以使用 Obx 组件来监听设置项值的变化并更新 UI:

Obx(() {
 return SettingItemGroup(
   settingItems: [
     SettingItem(
       title: Text("数据同步"),
       tail: Switch(
         value: appSettingsController.enableDataSync.value,
         onChanged: (value) {
           appSettingsController.enableDataSync.value = value;
           appSettingsController.saveToDisk();
         },
       ),
       onTap: () {
         appSettingsController.enableDataSync.value =
             !appSettingsController.enableDataSync.value;
         appSettingsController.saveToDisk();
       },
     ),
   ],
 );
}),

在点击尾部的 Switch 开关或整个设置项,都会修改appSettingsController 中的状态(将触发 UI 更新)并调用 saveToDisk 方法将新值保存在本地

权限设置项

我们在前面开发日志 1 中实现了一个权限授予页,并且在开发日志 2 中将其改为仅在 APP 第一次启动时展示,那么如果用户第一次启动时未授予权限,应该怎么办呢?我们可以利用本次开发实现的设置页来为用户提供管理权限的入口。
首先在 AppSettingsController 中创建一个字段用来表示是否有悬浮窗权限和无障碍权限:
在这里插入图片描述
再在_loadSettingsFromDisk 方法中,加载该设置的值:
在这里插入图片描述
我们调用了 PermissionManager 的 checkOverlayPermission 方法和 checkAccessibilityPermission 方法来初始化这两个值。
创建设置项组和设置项标题:

Padding(
 padding: const EdgeInsets.all(30.0),
 child: Obx(() {
   return SettingItemGroup(
     settingItems: [
       SettingItem(
         title: Text("显示在其他应用上层"),
         tail: appSettingsController.hasOverlayPermission.value
             ? TextButton(onPressed: null, child: Text("已授予"))
             : TextButton(onPressed: () {}, child: Text("去授予")),
         onTap: () {},
       ),
       SettingItem(
         title: Text("无障碍服务"),
         tail:
             appSettingsController.hasAccessibilityPermission.value
             ? TextButton(onPressed: null, child: Text("已授予"))
             : TextButton(
                 onPressed: () async {
                   // 跳转到无障碍权限授予页面
                   await permissionManager
                       .requestAccessibilityPermission();
                   // 再次检查是否授予成功
                   appSettingsController
                       .hasAccessibilityPermission
                       .value = await permissionManager
                       .checkAccessibilityPermission();
                   if (!appSettingsController
                       .hasAccessibilityPermission
                       .value) {
                     Fluttertoast.showToast(msg: "权限未授予");
                   } else {
                     Fluttertoast.showToast(msg: "授予成功");
                   }
                 },
                 child: Text("去授予"),
               ),
         onTap: () {},
       ),
     ],
   );
 }),
),

我们根据当前是否具有对应的权限来展示不同的设置项 tail,如果有权限就展示一个被禁用的按钮,不可点击,如果无权限,就展示一个“去授予”的可点击按钮,用户点击后将跳转到系统对应的权限设置页面。

电池优化白名单

电池优化白名单是 Android 中一个名为 IgnoringBatteryOptimization 的权限,这是一个列表,处于列表中的 APP 将被列入 Android 系统墓碑机制的白名单,在进行后台清理时,这类 APP 被清理的概率会低很多(之所以不是完全不清理,是因为不同定制 ROM 的墓碑策略可能不同)。
首先要实现 Android 层的代码,来给 Flutter 层提供权限查询、权限申请的功能。在 pigeon.dart 中定义:

()
abstract class PowerManager {
  /// 检查 APP 是否在电池优化白名单中
  bool checkIgnoreBatteryOpt();

  /// 引导用户将应用加入电池优化白名单
  
  void requestIgnoreBatteryOpt();
}

在 Android 层进行实现,这里实现代码参考开发日志 1 的悬浮窗权限和无障碍权限,不再进行赘述:

package com.example.generateImpls

import PowerManager
import android.content.Intent
import android.provider.Settings
import com.example.consumption_analyst.MainActivity
import androidx.core.net.toUri


class PowerManagerImpl(val activity: MainActivity): PowerManager {
    override fun checkIgnoreBatteryOpt(): Boolean {
        val pm = activity.getSystemService(android.os.PowerManager::class.java)
        return pm.isIgnoringBatteryOptimizations(activity.packageName)
    }

    override fun requestIgnoreBatteryOpt(callback: (Result<Unit>) -> Unit) {
        if (checkIgnoreBatteryOpt()) {
            callback.invoke(Result.success(Unit))
            return
        }
        val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
        intent.setData(("package:" + activity.packageName).toUri())
        activity.setIgnoreBatteryOptCallback {
            callback.invoke(Result.success(Unit))
            activity.setIgnoreBatteryOptCallback(null)
        }
        activity.ignoreBatteryOptLauncher.launch(intent)
    }
}

然后利用我们今天写好的设置页面,将该选项添加到设置页面中:

Padding(
  padding: const EdgeInsets.all(30.0),
  child: Obx(() {
    return SettingItemGroup(
      settingItems: [
        SettingItem(
          title: Text.rich(
            TextSpan(
              children: [
                TextSpan(text: "电池优化白名单"),
                WidgetSpan(
                  child: GestureDetector(
                    onTap: () {
                      showDialog(
                        context: context,
                        builder: (context) {
                          return AlertDialog(
                            title: Text("关于电池优化"),
                            content: Text(
                              "自动识别支付完成的功能需要将本应用加入电池优化白名单,防止 APP 被杀掉后识别功能无法运行。",
                            ),
                          );
                        },
                      );
                    },
                    child: Icon(Icons.question_mark, size: 15),
                  ),
                  alignment: PlaceholderAlignment.middle,
                ),
              ],
            ),
          ),
          tail: appSettingsController.isIgnoreBatteryOpt.value
              ? TextButton(onPressed: null, child: Text("已加入白名单"))
              : TextButton(
                  onPressed: () async {
                    await powerManager.requestIgnoreBatteryOpt();
                    appSettingsController.isIgnoreBatteryOpt.value =
                        await powerManager.checkIgnoreBatteryOpt();
                    if (!appSettingsController
                        .isIgnoreBatteryOpt
                        .value) {
                      Fluttertoast.showToast(msg: "设置失败");
                    } else {
                      Fluttertoast.showToast(msg: "设置成功");
                    }
                  },
                  child: Text("去设置"),
                ),
          onTap: () {},
        ),
      ],
    );
  }),
),

最后不要忘了在 AndroidManifest.xml 中声明电池优化白名单权限:

<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐