Android加固

一、360加固功能介绍

二、功能说明

1、免费版

1.1、DEX文件加密

1.2、防二次打包

2、专业防篡改版

2.1、DEX保护–字符串加密

Flutter中的字符串是否加密???

2.2、DEX保护–默认VMP保护

2.3、SO加固–SO保护

2.4、文件保护–文件完整性校验

防篡改

2.5、文件保护–资源文件保护

2.6、数据保护–防截屏

使用方法:加固时候,勾选是否需要防截屏

使用表现:如果使用,则当用户进行该操作的时候,会弹出toast提示。

2.7、Sandhook检测

功能介绍如下:

SandHook是一款开源的Android平台Hook框架,它可以用于检测应用程序是否被Hook。

Hook是指在应用程序执行期间,通过修改应用程序的代码或者内存中的数据,来改变应用程序原有的行为。

如果检测到应用程序被Hook,加固厂商会采取相应的措施,例如弹出警告框、终止应用程序等,以防止应用程序受到攻击。

问:如果Android本身有一些点击的埋点hook,那会被Sandhook检测出异常吗?

如果Android本身的点击埋点是通过Hook技术实现的,那么它们有可能被SandHook检测出异常。

2.8、环境检测–双开检测、脱壳检测

使用方法:加固时候,勾选是否需要该功能。

使用表现:如果使用,则当用户进行该操作的时候,会被检测到并进行退出。

3、高级防逆向

Dex2C

dex2c是一种工具,可以将Android应用程序的DEX文件转换成C/C++代码。DEX文件是Android应用程序的核心文件之一,包含了应用程序的字节码、类、方法等信息。通过将DEX文件转换成C/C++代码,可以使得应用程序的核心代码不再以DEX格式存储在设备上,而是以C/C++代码的形式存储在设备上,从而增强应用程序的安全性。

三、加固前后数据比较

1、基础加固服务的内容及数据对比

image-20230621104154335 image-20230621110245139

2、企业版与专业版加固效果对比

详见:Android合规安全

app打包保证

背景

主要处理问题:

  • 问题1:避免外部人员使用非正式包
    • 1.1、安装生产调试包
      • 途径1:下载到蒲公英生产包
        • 解决1:设置下载密码(iOS+Android)
        • 解决2:iOS限制安装设备
        • 解决3:蒲公英包通过判断标志,禁止登录(白名单除外)
      • 途径2:
    • 1.2、安装测试包
      • 使用生产功能,通过测试包切换到生产环境
        • 生产数据错误
      • 使用测试功能
    • 1.3、使用开发工具
      • 添加密码(优先级:服务端密码 –> app版本密码,跟随build号)
    • 1.4、发生之后,如何避免,版本升级的控制。
      • 优先级:
  • 避免开发人员随意打包正式包
安装生产调试包 下载到蒲公英生产包 设置下载密码(iOS+Android)
iOS限制安装设备
蒲公英包通过判断标志,禁止登录(白名单除外)
问题 限制下载 限制安装 限制使用(登录) 强制升级
蒲公英的iOS生产包 ✅添加下载密码 限制安装设备 ✅根据发布平台/版本号
蒲公英的Android生产包 ✅添加下载密码
蒲公英的iOS调试包 ✅添加下载密码 限制安装设备 白名单
蒲公英的Android调试包 ✅添加下载密码

解决

1、避免外部人员使用非正式包

1、外部禁止安装生产调试包

1.1、iOS限制安装设备

基础数据基类-②商品

一、商品种类

描述 类名
商品基类 包含必须属性 BaseGoodsBean
商城里展示的商品 有多sku SpuGoodsBean
用户想要购买的商品 SkuGoodsBean

枚举的使用

在Flutter中

packages/app_models/lib/goods/base_goods_enum.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import 'package:flutter_data_helper/flutter_data_helper.dart';

// -------------------------- 商品售卖状态 --------------------------
// 商品售卖状态(上架:up; 下架:down)
enum GoodsSaleState {
up, // 上架中
down, // 已下架
}

///string转枚举类型
GoodsSaleState goodsSaleStateFromString(String value) {
Iterable<GoodsSaleState> values = [
GoodsSaleState.up, // 上架中
GoodsSaleState.down, // 已下架
];
return EnumStringUtil.enumFromString(values, value);
}

///枚举类型转string
String goodsSaleStateStringFromEnum(o) {
return EnumStringUtil.enumToString(o);
}

// -------------------------- 商品物品类型 --------------------------
// 商品物品类型(实体商品:physical; 虚拟物品:virtual)
enum GoodsPhysicalType {
physical, // 实体商品
virtual, // 虚拟物品
}

在iOS中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 协议定义
protocol GoodsPriceProtocol {
var marketPrice: Int? { get set }
var salePrice: Int? { get set }
}

protocol GoodsBrandProtocol {
var brandName: String? { get set }
var brandId: String? { get set }
}

// 类实现协议
class BaseGoodsBean: GoodsPriceProtocol, GoodsBrandProtocol {
var marketPrice: Int?
var salePrice: Int?
var brandName: String?
var brandId: String?

// 类的其他属性和方法
}

在Flutter中

packages/app_models/lib/goods/base_goods_bean.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 商品价格
import 'base_goods_enum.dart';

abstract class GoodsPriceModel {
int? marketPrice; // 市场价格
int? salePrice; // 销售价格
}

// 商品品牌
abstract class GoodsBrandModel {
String? brandId; // 品牌id
String? brandName; // 品牌名称
}

// 商品门店
abstract class GoodsShopModel {
String? shopId; // 门店id
String? shopName; // 门店名称
}

// 商品基类
class BaseGoodsBean implements GoodsPriceModel, GoodsBrandModel, GoodsShopModel {
// 商品必备属性
final String id; // 商品id
final String name; // 商品名称
final String imageOrVideoUrl; // 商品主图图片/视频
final GoodsSaleState goodsSaleState; // 商品售卖状态(上架up; 下架down)
final GoodsPhysicalType physicalType; // 商品物品类型(实体商品:physical; 虚拟物品:virtual)

// 商品价格
@override
int? marketPrice;
@override
int? salePrice;

// 商品品牌
@override
String? brandId;
@override
String? brandName;

// 商品门店
@override
String? shopId;
@override
String? shopName;

BaseGoodsBean({
required this.id,
required this.name,
required this.imageOrVideoUrl,
required this.goodsSaleState,
required this.physicalType,
this.marketPrice,
this.salePrice,
this.brandId,
this.brandName,
this.shopId,
this.shopName,
});
}

展示的商品

选中的商品

packages/app_models/lib/goods/selected_goods_bean.dart

1

End

基础数据整合方案

原则

1、属性

序号 方案 使用平台 推荐星数 其他
1 继承 iOS、Android、Flutter
2 组合 iOS、Android、Flutter
3 协议 iOS
4 抽象类 Flutter

1.1、继承

1.2、组合

1.3、协议(iOS)

在iOS开发中,除了使用Swift语言创建基类和组合属性的方式之外,还可以考虑使用协议(Protocol)来实现类似的效果。协议可以定义一组属性和方法的要求,然后其他类可以遵循该协议并提供相应的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 商品价格
protocol GoodsPriceProtocol {
var marketPrice: Int? { get set } // 市场价格
var salePrice: Int? { get set } // 销售价格
}

// 商品品牌
protocol GoodsBrandProtocol {
var brandName: String? { get set } // 品牌名
var brandId: String? { get set } // 品牌id
}

// 商品基类
class BaseGoodsBean: GoodsPriceProtocol, GoodsBrandProtocol {
var marketPrice: Int?
var salePrice: Int?
var brandName: String?
var brandId: String?
}

1.4、抽象类

在Flutter中,没有与iOS中的协议(Protocol)直接对应的语言特性。然而,您可以使用抽象类(Abstract Class)来实现类似的效果。抽象类可以定义一组抽象方法和属性,然后其他类可以继承该抽象类并提供相应的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 商品价格
abstract class GoodsPriceModel {
int? marketPrice; // 市场价格
int? salePrice; // 销售价格
}

// 商品品牌
abstract class GoodsBrandModel {
String? brandName; // 品牌名称
String? brandId; // 品牌id
}

// 商品基类
class BaseGoodsBean implements GoodsPriceModel, GoodsBrandModel {
// 商品价格
@override
int? marketPrice;
@override
int? salePrice;

// 商品品牌
@override
String? brandName;
@override
String? brandId;
}

End

性能监控:异常与崩溃

异常与崩溃

BUGLY崩溃问题汇总.md

一、异常捕获

通过三方捕获的方式,可以选择 Buly 友盟 等。详细的不展开,下面我们只讲底层自己进行异常捕获。

1、iOS异常捕获

iOS异常捕获

iOS上获取崩溃日志的N+1种方法

开发iOS应用,解决Crash问题始终是一个难题。Crash分为两种,

一种是由EXC_BAD_ACCESS引起的,原因是访问了不属于本进程的内存地址,有可能是访问已被释放的内存;

另一种是未被捕获的Objective-C异常(NSException),导致程序向自身发送了SIGABRT信号而崩溃。其实对于未捕获的Objective-C异常,我们是有办法将它记录下来的,如果日志记录得当,能够解决绝大部分崩溃的问题。

UncaughtExceptionHandler

2、Flutter异常捕获

系统已在performRebuild中异常捕获 FlutterError.onError = (FlutterErrorDetails details) { };

runZoned 中 handleUncaughtError 拦截未处理的异步错误

文档:《Flutter实战第二版:Flutter异常捕获:最终的错误上报代码

flutter_error_catch

点击查看源码

如果我想在应用程序退出时执行一些清理工作,我应该在 RunLoop 的哪个阶段进行操作?

1
2
3
4
// 程序被手动杀死。即将终止,可以在这里执行一些必要的清理工作
- (void)applicationWillTerminate:(UIApplication *)application {

}

applicationWillTerminate:这个方法并不总是可靠的,因为它的调用取决于多种因素,包括用户如何退出应用(例如,通过按 Home 键或通过系统设置)和系统状态。相比之下,RunLoop Observer 提供了一种更为可靠的方法来监听应用生命周期的特定阶段。通过在 RunLoop 的退出阶段添加一个 Observer,你可以确保在 RunLoop 即将退出时执行一些操作,这通常意味着应用即将进入后台或者终止。(还未亲自验证!!!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 在 AppDelegate.m 文件中
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// 创建 RunLoop Observer,并将 Observer 添加到主线程的 RunLoop
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopExit, TRUE, 0,&exitRunLoopCallback);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
// 由于这是一个全局的 Observer,我们不需要在这里释放它。它会在应用退出时自动释放。

// 其他初始化代码...
return YES;
}

// Observer 的回调函数
void exitRunLoopCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
if (activity == kCFRunLoopExit) {
// 执行清理工作
NSLog(@"Performing cleanup work before the application exits.");

// 这里添加你的清理代码
}
}

二、如何追踪app

iOS 如何追踪app

三、起死回生/回光返照

1、iOS 使用Runloop实现起死回生/回光返照

核心:创建一个Runloop,将主线程的所有Runmode都拿过来跑,作为应用程序主Runloop的替代。

但是:这样固然可以实现我们想要做的事情,但是会带来一个问题:因为我们为了继续执行程序而没有将控制权返回给导致崩溃的调用函数,并且我们启动了自己的Runloop,所以永远不会返回到原始的Runloop中去了,这将意味着导致异常的线程使用的堆栈内存将永久泄漏。因此这种类型的方法应被视为调试工具或最后手段,所以,不要在Debug以外的环境使用它。本段摘自:线程保活提醒

视频:“Runloop起死回生/回光返照” 见 00:57:55–01:03:00 的视频 2021-08-21_Crash分析.wmv

文档:RunLoop总结:RunLoop的应用场景(五)阻止App崩溃一次

示例代码:https://github.com/Haley-Wong/RunLoopDemos

高磁盘占用的排查与优化

图片缓存区太大的案例

1、案例描述

在我们的Flutter打包的app上,出现测试人员对首页的无限列表疯狂频繁刷,冷启动刷的时候还好,越刷越后面越卡。

2、案例分析过程

经测试验证在其他机器上无法重现。后通过观察应用的磁盘占比,发现出现崩溃的该设备该应用的磁盘占比巨大,达好几G(只要占用有网络点播视频缓存、打赏动画视频、图片)。进一步观察发现在图片不大的情况下,其中图片的占比竟然也能接近上G,说明图片数量巨大。由此猜测是图片数量太多导致。随后通过高磁盘占用环境模拟,确实也出现了同样的情况。由此我们开始设想磁盘对应用性能的影响 的几种原因,并进行对应修复。经过代码排查图片库内部的同步、阻塞问题,发现了一个潜在的同步危险。

1
if (cacheFlie.existsSync()) {  /// existsSync是一个同步方法,用于检查具有该路径的文件系统实体是否存在。

3、为什么原生iOS上没发现类似问题/微信为什么没出现类似问题?

可能原因:①之前原图相同的图片显示在不同地方,也只是原图和缩略图两套而已,而不像图片万象这样,可能会存在好多套。②原生的SDWebImage在监测到内存不足的时候会自动进行清除。

图片缓存(无、太小、太大)的问题

小结:

图片缓冲区默认太小时(一定有问题) 图片缓冲区设大后(可能有问题)
出现的平台 Flutter上默认的缓存上限1000个图片或者100MB内。
原生iOS的SDWebImage一般不设大小,只根据maxCacheAge
Flutter上extended_image判断图片是否已缓存时候有性能问题,而导致卡顿。
原因滑动列表会进行很多图片的获取,从而由于任务过多,对性能产生影响,例如增加CPU使用率,导致电池消耗加快,进而可能引发崩溃。
后果 加载过的图片很容易再次出现。 可能:存的图片数量太大。影响图片存取,而崩溃。
出现的案例 ①列表划过之后再回来;
②列表页面离开后再回来。
测试人员频繁刷,导致刷满图片缓冲区后,图片数量太大,影响图片存取,从而崩溃。
解决方案 图片存储进行分区。对 url 进行 md5 取值,对 md5 取前两个字母为新文件夹。

1、图片缓冲区默认太小时

在Flutter中加载图片(一般是网络图片),我们常常会遇到下面几个问题:Flutter 图片缓存问题分析

  1. 同页面内,加载过的图片,再次出现的时候,会重新加载,特别是列表的图片;

    根源:Flutter 内置的缓存机制 PaintingBinding.instance.imageCache 的 maximumSizemaximumSizeBytes 属性默认的缓存上限1000个图片或者100MB内。

    1
    2
    const int _kDefaultSize = 1000;
    const int _kDefaultSizeBytes = 100 << 20; // 100 MiB

    即图片没有加载到100MB,加载到1000个图片,也会开始根据LRU的规则清理释放缓存。在某些情况下,比如电商,一整个页面80%的元素都用图片占满,小到图标,大到广告banner,个数很容易就到1000了,但是经过压缩剪裁,图片普遍都控制在几十kb甚至10kb以下,即使1000张也远远达不到内存的上限,并且100MB的上限对于某些机型来讲也相对较小了。

    解决:设置大一点

    1
    2
    PaintingBinding.instance.imageCache.maximumSize = 10000;
    PaintingBinding.instance.imageCache.maximumSizeBytes = 800 << 20; // 800 MiB

    附:除了 Flutter 内置的缓存机制外,还有第三方库如 cached_network_image 提供了更丰富的图片缓存功能,包括硬盘缓存等 。如果需要更复杂的图片缓存策略,可以考虑使用这类第三方库来扩展 Flutter 的图片加载和缓存能力。

    附2:extended_image 提供了一套完整的图片加载和缓存解决方案,其缓存的控制也还是通过修改 ImageCache 的配置来控制内存缓存的大小和数量。

    附2:SDWebImage 默认情况下会缓存图片一周(maxCacheAge 的默认值),并且没有对缓存空间大小设置限制(maxCacheSize 默认值未设定),这意味着理论上应用中的图片缓存可以占满整个设备存储空间。

    1
    [SDImageCache sharedImageCache].maxCacheSize = 50 * 1024 * 1024; *// 50MB*
  2. 列表快速滑动时,加载完成再往回滑动,之前的图片还是需要重新加载;

    根源:等同于按需加载。

    解释:加载图片时,为了避免过快滑动,使得同时加载的图片过多导致卡顿甚至崩溃。所以若是内存中已有缓存,则直接返回缓存,若是没有则判断是否在快速滑动,若是正在快速滑动,则下一帧再加入队列处理,若是图片已经被移出屏幕(即没有在tree上),可能会被跳过。

    解决:快速滑动如此,可不处理。

  3. 有时返回上一页面时,上一页面已经加载完成的图片,会重新加载,假如没有占位图会特别明显的闪动。

    同现象1一样。

2、图片缓冲区设大后

图片分区:

SDWebImage 所有的图片都放在 _diskCachePath 目录下,_diskCachePath是只有一层吗?还是里面还会再分文件目录?

SDWebImage 的 _diskCachePath 目录是 SDWebImage 用来存储磁盘缓存的根目录。在这个根目录下,SDWebImage 会进一步组织文件结构,以避免所有图片都放在单一目录下造成文件系统性能问题。具体来说,**图片文件会根据图片 URL 的 MD5 值进行散列,然后根据这个散列值来创建子目录和文件名,从而在磁盘上形成一个树状的目录结构 **。

这种目录结构的设计有助于分散文件系统操作,减少单个目录下的文件数量,从而提高文件检索和写入的性能。此外,SDWebImage 还提供了设置选项,允许开发者自定义图片的存储路径,以及通过 addReadOnlyCachePath: 方法添加额外的只读缓存路径 。

总的来说,_diskCachePath 不是单一层级,而是一个包含多个子目录的复杂结构,每个子目录下存储着根据 MD5 散列值组织好的图片文件,以此来优化磁盘缓存的性能和效率 。

一、磁盘对应用性能的影响

1、原因猜测

可能原因:应用使用过程时候,存在对磁盘中的某个文件的操作,从而导致应用性能问题。操作的可能性如下:

序号 阶段 如果 后果
1 访问 文件量太大 访问效率问题
2 读写 文件大小太大 读写问题

2、排查方案

高磁盘占用的排查与优化

图片来源于:高磁盘占用的排查与优化.graffle

二、高磁盘占用环境模拟

步骤 阶段 操作 目的
1 资源准备1 提供图片的万象接口,以通过万象能获取到各种图片地址 避免得去找非常多的图片数据源
2 资源准备2 提供下载万象图片的接口(指定宽范围),以通过万象图片能下载到很多图片 避免得去找非常多的图片数据源
3 资源下载 下载指定宽范围的万象图片,得到各种图片
每次只添加 100 张,避免模拟功能在同步状态下执行出错;
通过下载占据磁盘空间
4 数据验证1 打印磁盘(沙盒)目录,验证下载
iOS-查看沙盒文件(真机+模拟器)
验证确实磁盘空间被增加
5 数据验证2 计算磁盘大小 验证确实磁盘空间被增加

相应的代码实现,见 附1:高磁盘占用环境模拟的相关代码

三、高磁盘占用环境优化

序号 解决 方案
1 避免单文件夹太大 存储分区处理
2 单文件夹已经太大 存储空间清理

1、避免单文件夹太大—-存储分区处理

1、背景:图片文件下文件数量太多,导致搜索效率下降

1
2
3
4
5
6
7
8
9
10
final Directory _cacheImagesDirectory = Directory(
join((await getTemporaryDirectory()).path, cacheImageFolderName));

// exist, try to find cache image file
if (_cacheImagesDirectory.existsSync()) { /// existsSync是一个同步方法,用于检查具有该路径的文件系统实体是否存在。
final File cacheFlie = File(join(_cacheImagesDirectory.path, md5Key));
if (cacheFlie.existsSync()) { /// existsSync是一个同步方法,用于检查具有该路径的文件系统实体是否存在。

}
}

2、优化方案:对图片存储,进行分区存储。获取方式同理。

3、优化步骤如下

对 url 进行 md5 取值

对 md5 取首字母为新文件夹

将该url所对应的图片存储在图片存储目录下的子目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// get md5 from key
static String keyToMd5(String key) => md5.convert(utf8.encode(key)).toString();

/// 获取网络图片最后的本地存储文件夹路径
static Future<String> getLastDirPath(String imageUrl, {bool? useSubDir}) async {
Directory cacheDir = await getTemporaryDirectory();
String imageCacheHomeDirPath = join(cacheDir.path, cacheImageFolderName);
if (useSubDir != true) { // 不使用子目录,直接一个文件夹存放
return imageCacheHomeDirPath;
}

String subDirHome = keyToMd5(imageUrl);
String imageCacheSubDirName = subDirHome.substring(0, 1);
String lastDirPath = "$imageCacheHomeDirPath/$imageCacheSubDirName";
return lastDirPath;
}

4、优化验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// 计算图片文件的查找耗时(查找失败返回-1)
static Future<int> calculateFindDuration(String imageUrl, {bool? useSubDir}) async {
String md5Key = keyToMd5(imageUrl);
final String lastDirPath = await getLastDirPath(imageUrl, useSubDir: useSubDir);
final Directory cacheImagesDirectory = Directory(lastDirPath);

int foundStartTime = DateTime.now().millisecondsSinceEpoch;
// exist, try to find cache image file
if (cacheImagesDirectory.existsSync()) { /// existsSync同步方法,检查该路径的文件夹是否存在
final File cacheFlie = File(join(cacheImagesDirectory.path, md5Key));
if (cacheFlie.existsSync()) { /// existsSync同步方法,检查该路径的文件是否存在
int foundEndTime = DateTime.now().millisecondsSinceEpoch;
int foundDuration = foundEndTime - foundStartTime;
return foundDuration;
}
}

return -1;
}

2、单文件夹已经太大—-存储空间清理

文件的属性有创建时间、修改时间

原理:变化过期时间,遍历文件属性,删除修改时间大于该过期时间的文件

1、设置过期时间的初始值,遍历文件属性,删除修改时间大于该过期时间的文件;

2、检查清理后的大小,如果大小还不在规定大小内,则继续清理;

3、继续清理:设置每次清理的过期时间增加值,对过期时间的初始值增加,并重复以上步骤;

四、优化方案的实施

目的:避免功能异常,出现集体性问题。

灰度方案:请参照 灰度系统

五、延伸

问题:一旦修改分区规则(如原本使用url进行md5后的首位值,后面改成md5后的两位值),旧文件就得重新下载。

解决:使用一致性哈希算法。16 张图解带你掌握一致性哈希算法

1、对存储节点进行hash,创建节点和hash值的哈希映射,如 {100001: “a”, 300001: “a”, 400001: “a”}

2、对数据url进行hash寻址

  • 首先,对 key 进行哈希计算,确定此 key 在环上的位置;
  • 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。(对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?)

结果:在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响

假设节点数量从 3 减少到了 2,比如将节点 A 移除:你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。

image-20231008173625108

隐患:一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题。如下:

image-20231008173700010

说明:一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值

附:hash算法

使用一致性哈希(Consistent Hashing)可以帮助您将大批图片文件分散存储到不同的文件中,其中每个文件对应一个字母或数字。一致性哈希算法能够提供高效的数据分布和负载均衡,同时允许动态添加或删除文件节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import 'dart:convert';
import 'package:crypto/crypto.dart';

class ConsistentHashing {
List<String> nodes;
Map<int, String> ring;

// 构造hash环
ConsistentHashing(List<String> nodes) {
this.nodes = nodes;
this.ring = {};

for (String node in nodes) {
addNode(node);
}
}

// ring[_hash(node + n.toString())] = node;
void addNode(String node) { // a、b...
for (int n = 0; n < 3; n++) { // 每个节点有3个虚拟节点(a0、a1、a2、b0、b1、b2...)
String virtualNode = node + n.toString();
int hashValue = _hash(virtualNode);
ring[hashValue] = node; // {hash_a0: "a", hash_a1: "a", hash_a2: "a"}
}
}

// ring.remove(_hash(node + n.toString()));
void removeNode(String node) { // a、b...
for (int n = 0; n < 3; n++) { // 每个节点有3个虚拟节点(a0、a1、a2、b0、b1、b2...)
String virtualNode = node + n.toString();
int hashValue = _hash(virtualNode);
ring.remove(hashValue);
}
}

// 从小到大排序所有虚拟节点的hash值,并进行遍历,找到最接近(第一个>=hashValue的节点)的虚拟节点。判断值应该存储的节点
String getNode(String key) {
if (ring.isEmpty) {
return null;
}

int hashValue = _hash(key);
List<int> sortedKeys = ring.keys.toList()..sort(); // [hash_a0, hash_b0, hash_a1, hash_b1, ...]
for (int ringKey in sortedKeys) {
if (hashValue <= ringKey) { // hash_xxx <= hash_a1 遍历找到第一个大于等于 hash_xxx 的节点
return ring[ringKey]; // ring[hash_a1] = "a" => 返回 "a"
}
}

return ring[sortedKeys[0]];
}

int _hash(String key) {
Digest hash = md5.convert(utf8.encode(key));
int hashValue = int.parse(hash.toString(), radix: 16);
return hashValue;
}
}

void main() {
// 创建36个文件节点,每个节点对应一个字母或数字
List<String> nodes = List.generate(26, (index) => String.fromCharCode(97 + index))
..addAll(List.generate(10, (index) => index.toString()));

// 初始化一致性哈希环
ConsistentHashing ring = ConsistentHashing(nodes);

// 假设有一批图片文件需要存储
List<String> imageFiles = ['image1.jpg', 'image2.jpg', 'image3.jpg'];

for (String imageFile in imageFiles) {
// 根据文件名获取对应的节点
String node = ring.getNode(imageFile);

// 将文件存储到对应的节点中
// 这里可以根据需要自行实现文件存储逻辑
print('Storing $imageFile to node $node');
}
}

附1:高磁盘占用环境模拟的相关代码

1、ts_toomany_image_data_vientiane.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// [图片处理方式:快速缩略模板](https://cloud.tencent.com/document/product/460/6929)
/// [图片处理方式:数据万象-缩放thumbnail](https://cloud.tencent.com/document/product/460/36540)
/// [图片处理方式:数据万象-旋转rotate](https://cloud.tencent.com/document/product/460/36542)
class TSTooManyImageDataVientiane {
static String newImageUrl(String imageUrl, {required int width}) {
String thumbnail = '?imageMogr2/thumbnail/';
thumbnail += '${width}x/';
thumbnail += 'format/webp/auto-orient/quality/100';

String newImageUrl = "$imageUrl$thumbnail";
// debugPrint('newImageUrl = $newImageUrl');
return newImageUrl;
}
}

2、ts_toomany_image_download_util.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import 'dart:io';

import 'package:extended_image/extended_image.dart';
import 'ts_toomany_image_data_vientiane.dart';

class TSTooManyImageDownloadUtil {
static String vientianeImageUrl = "https://images.xihuanwu.com/mcms/uploads/1647604960983901.jpg";

static void simulate_download_too_many(int imageWidthStart, int imageWidthEnd) {
int imageCount = imageWidthEnd - imageWidthStart;
for (var i = 0; i < imageCount; i++) {
int iImageWidth = imageWidthStart + i;
String iImageUrl = TSTooManyImageDataVientiane.newImageUrl(vientianeImageUrl, width: iImageWidth);
getNetworkImageData(iImageUrl);
sleep(const Duration(milliseconds: 10)); // 延迟10ms,避免下载太多计算问题
}
}
}

3、ts_toomany_image_optimize_check_util.dart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:extended_image_library/extended_image_library.dart';

import 'ts_toomany_image_data_vientiane.dart';
import 'ts_toomany_image_download_util.dart';

class TSTooManyImageOptimizeCheckUtil {
/// 计算图片的查找时长
static checkImageFindDuration() async {
String foundImageUrl = TSTooManyImageDataVientiane.newImageUrl(TSTooManyImageDownloadUtil.vientianeImageUrl,
width: 6600);
getNetworkImageData(foundImageUrl);
int foundDuration = await TSTooManyImageOptimizeCheckUtil.calculateFindDuration(foundImageUrl);
debugPrint("$foundImageUrl 的查找时间为:$foundDuration");
}
}

End

图片框架

思维脑图:请点击查看图片框架.xmind

流程图:请点击查看图片框架.graffle

一、图片优化

1、图片体验优化

1.1、图片占位图、异常图

1.2、图片加载动画

可适当在图片视图本身加入loading动画

2、图片内存优化

3、降低图片质量(缩略图/数据万象)

注意:图片所占内存的大小与图片的尺寸有关,而不是图片的文件大小

3.1、使用缩略图

让后台返回缩略图地址,而不是大图地址

3.2、图片数据万象及其优化\根据视图大小,获取保持比例的合适目标图片(完整图)

后台不是给缩略图地址的情况下,自己通过图片地址,配置对应的参数,来展示。

图片数据万象的优化,请继续查看下文 数据万象/根据视图大小,获取保持比例的合适目标图片(完整图)

4、图片缓存

3.1、直接图片视图加载

3.2、先加载图片数据,再赋值到图片视图上

3.3、去除多余的图片切换动画

5、提前解压缩

图片解压缩的过程其实就是将图片的二进制数据转换成像素数据的过程。

图片的解压缩是一个非常耗时的 CPU 操作,并且它默认是在主线程中执行的。那么当需要加载的图片比较多时,就会对我们应用的响应性造成严重的影响,尤其是在快速滑动的列表上,这个问题会表现得更加突出。这就是 UIImageView 的一个性能瓶颈。

二、数据万象/根据视图大小,获取保持比例的合适目标图片(完整图)

内存大小的计算:内存大小=宽度×高度×每像素字节数

对ARGB来说,每个像素有4个通道,每个通道1个字节,即其每个像素需要4个字节

对SRGB来说,其每个像素也是4个字节。(虽然其不包含Alpha通道,但存储时仍然可能按照每个像素4字节来处理,以保持数据对齐和优化内存访问。)

1、背景

在服务端,我们保存的是图片的原图。但实际在app使用过程中,我们常常只需要该图的缩略图。那么如何及合理的得到我们想要的缩略图呢?

2、改进前

2.1、方法

缩略图的获取,最基本的方法是使用数据万象提供的接口,通过为其添加尺寸参数,即可得到缩略图片。

添加后的缩略图地址形如:https://www.demo.com/1.jpg?width=44&height=44

2.2、存在的问题

使用上述方法,我们虽然得到了自适应每个image图片视图自身大小的缩略图。

但同时也引入了另一个问题:就是如果某张图需要出现在不同大小的image视图中,则我们会下载及缓存多份数据。这无形中增加了①流量的消耗、②手机存储空间的消耗;③同时也因为认为要下载新缩略图,而导致无法使用就近的缩略图。所以我们需要改进。

示例:

1
2
3
4
5
6
优化前:在 44*44 和 50*50 大小的视图区域上的图片地址分别如下:
https://www.demo.com/1.jpg?width=44&height=44
https://www.demo.com/1.jpg?width=50&height=50

优化后:两个区域统一为使用 64 * 64 大小的视图区域上的图片地址,从而如果避免获取到太多份不同大小的同图?
https://www.demo.com/1.jpg?width=64&height=64

3、改进后

3.1、改进方法

改进方式,在进行万象拼接前,我们通过提前新增缩略图梯度,将相近大小的缩略图归为使用同一张。

梯度的层数,可根据自己实际项目图片规范设计。

举例:以宽为 375pt 的手机屏幕为例。假设你图片规范是 [ 64pt 、188pt、375pt ]。

则当你的视图是 100pt 和 120pt 的视图都使用 188pt 的图片。

3.2、改进的好处

通过上述方法,我们能够达到①减少流量的消耗、②减少手机存储空间的消耗;③同时使得对于同一图片在相近大小的image图片视图的地方,由于计算出的缩略图处在同一梯度,而可以不用重新下载等待,而是直接使用缓存渲染,从而大大提升了用户体验。

4、流程

4.1、主要流程

获取梯度范围 -> 完成万象拼接

4.2、主要流程图

以下为”获取梯度范围”的流程

image-20230531212509071

图片的更多完整流程图,请查看 图片框架.graffle

三、图片高磁盘占用的排查与优化

文档:《高磁盘占用的排查与优化.md

四、图片展示

1、竖直滚动列表上的图片展示

2、图片大图浏览

image-20240725230554593

图片来源:图片框架.xmind

附1:图片优化的相关代码

1、图片占位图、异常图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
return CachedNetworkImage(
width: width,
height: height,
fit: fit,
imageUrl: imageUrl,
placeholder: placeholder,
errorWidget: (context, url, error) {
if (this.errorWidget != null) {
return this.errorWidget(context, url, error);
} else {
return Container();
}
},
);

2、图片加载动画

可适当在图片视图本身加入loading动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
return CachedNetworkImage(
width: width,
height: height,
fit: fit,
imageUrl: imageUrl,
placeholder: placeholder,
errorWidget: (context, url, error) {
if (this.errorWidget != null) {
return this.errorWidget(context, url, error);
} else {
return Container();
}
},
placeholderFadeInDuration: placeholderFadeInDuration,
fadeOutDuration: fadeOutDuration,
fadeInDuration: fadeInDuration,
progressIndicatorBuilder: (context, url, progress) {
if (this.progressIndicatorBuilder != null) {
return this.progressIndicatorBuilder(context, url, progress);
} else {
return Container(color: Color(0xFFF0F0F0));
}
},
);

3、图片缓存

3.1、直接图片视图加载

1
2
3
4
5
TolerantNetworkImage(
imageUrl: networkUrl,
width: 100,
height: 300,
)

3.2、先加载图片数据,再赋值到图片视图上

eg:首页频道图片切换

1
2
3
4
5
6
7
8
9
10
11
12
13
// 定义
ImageProvider imageProvider_network;

// 生成数据
imageProvider_network = CachedNetworkImageProvider(networkUrl);

// 使用数据
return Image(
image: imageProvider_network,
width: 100,
height: 300,
fit: BoxFit.cover,
);

3.3、去除多余的图片切换动画

eg:许个愿图片切换

1
2
3
4
5
6
7
8
9
10
11
12
return CachedNetworkImage(
width: width,
height: height,
fit: fit,
imageUrl: imageUrl,
placeholderFadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
fadeInDuration: Duration.zero,
progressIndicatorBuilder: (context, url, progress) {
return Container(color: Color(0xFFF0F0F0));
},
);

End