阅读视图
iOS SwiftUI 布局容器详解
一次弹窗异常引发的思考:iOS present / push 底层机制全解析
iOS逆向-哔哩哔哩增加3倍速播放(2)-[横屏视频-半屏播放]增加3倍速播放
Kingfisher 深度指南:Swift 生态下的高性能图片处理艺术
和媒体共赢 - 读《广告的没落,公关的崛起》
最近读完了《定位》作者艾·里斯的另一本书《广告的没落,公关的崛起》,记录一些心得。
广告的没落
当一个广告让消费者意识到是广告时,广告的效果就会大打折扣。
我记得当年脑白金就把广告做成报纸的新闻报道形式,以此来让大家误以为是报纸在宣传脑白金的功效。但现在广告的监管越来越严,这种擦边的广告越来越难通过审核。
广告追求创意,但消费者购买的是产品。
如果一个产品广告很有创意,但是产品本身很普通。另一个广告很普通,但是产品本身很好。大家还是更可能购买后者。
广告追求创意和讨论,但是真正到了决策环节,影响决策的还是产品本身的心智,而不是广告创意。
产品的创意(创新)比广告的创意更重要。
品牌是潜在顾客心智中的一个认知。
广告很难进入消费者的心智。
相比于广告,公关(具体指通过媒体等第三方途径,间接讲述你的故事)更有可信度,也更有传播性。
消费者在试图评估一个品牌的时候,更倾向从朋友、亲戚,还有权威网站上获得信息,而不是广告。
公关的崛起
因为广告很难进入消费者心智,那么就应该更多通过公关来建立品牌。在通过公关建立品牌后,可以把广告作为维护品牌的工具。
书中结合各种品牌案例,提到了一些技巧。
技巧一:为媒体传播而设计,包括提前透露消息、新的品类/品牌名称、可信度的发言人。书中的案例是 Segway 平衡车。
技巧二:成为争议话题。案例是红牛(某些成份被禁,激发年轻人尝试的好奇心)。
技巧三:创意。为品牌增加一些东西,引起讨论。
技巧四:从小媒体入手。没人比媒体更多地浏览媒体。案例是《定位》一书,该书刚开始只在一个小媒体中被报道,但后来被《华尔街日报》发现,跟进了报道。
我的一些感受
看完本书之后,我刚好刷到一位媒体记者在微博上吐槽小米的公关(如下图)。但是我却从这段话中,看到小米在努力让自己的任何商业行为都成为公关传播的话题。在公关这件事情上,小米做得是非常优秀的。

以上。
iOS SwiftUI开发所有修饰符使用详解
KSCrash 实现机制深度分析
Swift中的知识点总结
iOS逆向-哔哩哔哩增加3倍速(1)-最大播放速度
前言
作为一名 哔哩哔哩的重度用户,我一直期待官方推出 3 倍速播放 功能。然而等了许久,这个功能始终没有上线 😮💨。
修改前效果:
刚好我自己熟悉 iOS 逆向工程,于是决定 亲自动手,为 B 站加入 3 倍速播放 😆。
修改后效果:
由于整个过程涉及 多处逻辑修改与多个模块的反汇编分析,为了让内容更加清晰易读,我将会分成多篇文章,逐步拆解 如何为 B 站增加 3 倍速播放能力。
场景
哔哩哔哩的视频播放页面
开发环境
- 哔哩哔哩版本:
8.41.0 - MonkeyDev
IDA Professional 9.0- 安装IDA插件:
patching Lookin
目标
视频最大播放速度改为4倍速播放
分析
- 我们知道哔哩哔哩开源了他们的视频播放器ijkplayer,我们可以从中了解到设置播放速度的方法,是
IJKFFMoviePlayerController的- (void)setPlaybackRate:(float)playbackRate
@interface IJKFFMoviePlayerController : NSObject
@property (nonatomic) float playbackRate;
@end
- 从
Mach-O文件导出的IJKFFMoviePlayerController的OC头文件可以知道,它有一个maxPlaybackRate属性,应该是最大播放速度
// IJKFFMoviePlayerController.h
@interface IJKFFMoviePlayerController : NSObject {
/* instance variables */
id _player;
}
...
@property (readonly, nonatomic) double realCurrentPlaybackTime;
@property (readonly, nonatomic) float realPlaybackRate;
@property (readonly, nonatomic) float maxPlaybackRate;
@property (readonly, nonatomic) IJKMediaPlayerItem *currentItem;
...
- 我们
hookIJKFFMoviePlayerController的- (void)setPlaybackRate:(float)playbackRate,添加断点并打印一些日志,其中就有打印maxPlaybackRate-
inputPlaybackRate:要设置的播放速度 -
changedPlaybackRate:更改后的播放速度 -
realPlaybackRate:真实播放速度 -
maxPlaybackRate:最大播放速度
-
%hook IJKFFMoviePlayerController
- (void)setPlaybackRate:(float)playbackRate {
%orig(playbackRate);
NSLog(@"%@:%@-%p-%s-inputPlaybackRate:%lf-changedPlaybackRate:%lf-realPlaybackRate%lf-maxPlaybackRate:%lf", nj_logPrefix, NSStringFromClass([(id)self class]), self, __FUNCTION__, playbackRate, self.playbackRate, self.realPlaybackRate, self.maxPlaybackRate);
}
%end
- 每次设置播放速度,
IJKFFMoviePlayerController的- (void)setPlaybackRate:(float)playbackRate的断点都会触发,我们从日志中可以看到,最大播放速度为3.0
cxzcxz:IJKFFMoviePlayerController-0x280519220-_logos_method$App$IJKFFMoviePlayerController$setPlaybackRate$-inputPlaybackRate:1.500000-changedPlaybackRate:1.500000-realPlaybackRate0.000000-maxPlaybackRate:3.000000
- 因为倍速面板无法选择超过
2.0的速度,所以我们就设置,如果倍速面板选择的是2.0,我们就改成4.0,看是否会限制播放速度到3.0
%hook IJKFFMoviePlayerController
- (void)setPlaybackRate:(float)playbackRate {
playbackRate = playbackRate == 2.0 ? 4.0 : playbackRate;
%orig(playbackRate);
NSLog(@"%@:%@-%p-%s-inputPlaybackRate:%lf-changedPlaybackRate:%lf-realPlaybackRate%lf-maxPlaybackRate:%lf", nj_logPrefix, NSStringFromClass([(id)self class]), self, __FUNCTION__, playbackRate, self.playbackRate, self.realPlaybackRate, self.maxPlaybackRate);
}
%end
- 倍速面板选择
2.0,从日志中可以看到,播放速度(changedPlaybackRate)改成了3.0而不是4.0,这也证明maxPlaybackRate就是最大播放速度
cxzcxz:IJKFFMoviePlayerController-0x281304380-_logos_method$App$IJKFFMoviePlayerController$setPlaybackRate$-inputPlaybackRate:4.000000-changedPlaybackRate:3.000000-realPlaybackRate0.000000-maxPlaybackRate:3.000000
- 我们尝试
hookmaxPlaybackRate的getter方法,看能不能修改最大播放速度?
%hook IJKFFMoviePlayerController
- (void)setPlaybackRate:(float)playbackRate {
playbackRate = playbackRate == 2.0 ? 4.0 : playbackRate;
%orig(playbackRate);
NSLog(@"%@:%@-%p-%s-inputPlaybackRate:%lf-changedPlaybackRate:%lf-realPlaybackRate%lf-maxPlaybackRate:%lf", nj_logPrefix, NSStringFromClass([(id)self class]), self, __FUNCTION__, playbackRate, self.playbackRate, self.realPlaybackRate, self.maxPlaybackRate);
}
- (float)maxPlaybackRate {
return 4.0;
}
%end
- 从日志可以看到,虽然
maxPlaybackRate的输出值改成了4.0,但是播放速度(changedPlaybackRate)还是3.0,所以修改无效
cxzcxz:IJKFFMoviePlayerController-0x2808e2a40-_logos_method$App$IJKFFMoviePlayerController$setPlaybackRate$-inputPlaybackRate:4.000000-changedPlaybackRate:3.000000-realPlaybackRate0.000000-maxPlaybackRate:4.000000
- 我们从
IDA中查看IJKFFMoviePlayerController的- (void)setPlaybackRate:(float)playbackRate伪代码实现,发现调用的是[self->_player setPlaybackRate:]方法
void __cdecl -[IJKFFMoviePlayerController setPlaybackRate:](IJKFFMoviePlayerController *self, SEL a2, float a3)
{
-[IJKMediaPlayback setPlaybackRate:](self->_player, "setPlaybackRate:");
}
- 我们给
IJKFFMoviePlayerController的- (void)setPlaybackRate:(float)playbackRate,添加断点,看看self->_player的值是什么?
- 断点触发,发现
self->_player的类型是IJKFFMoviePlayerControllerFFPlay
(lldb) po self->_player
- 从
Mach-O文件导出的IJKFFMoviePlayerControllerFFPlay的OC头文件可以知道,IJKFFMoviePlayerControllerFFPlay是一个视频播放器,看来IJKFFMoviePlayerController是基于IJKFFMoviePlayerControllerFFPlay实现的
@interface IJKFFMoviePlayerControllerFFPlay : NSObject {
/* instance variables */
struct IjkMediaPlayer * _mediaPlayer;
...
@property (weak, nonatomic) IJKMediaPlayerItem *item;
@property (retain, nonatomic) id fileOpenDelegate;
@property (retain, nonatomic) id segmentOpenDelegate;
@property (readonly, nonatomic) double fpsInMeta;
@property (readonly, nonatomic) double fpsAtOutput;
@property (nonatomic) _Bool shouldShowHudView;
@property (readonly, nonatomic) long long numberOfBytesTransferred;
@property (nonatomic) _Bool allowsMediaAirPlay;
@property (nonatomic) _Bool isDanmakuMediaAirPlay;
@property (readonly, nonatomic) _Bool airPlayMediaActive;
@property (readonly, nonatomic) int isSeekBuffering;
@property (readonly, nonatomic) int isAudioSync;
@property (readonly, nonatomic) int isVideoSync;
@property (readonly, nonatomic) int currentVideoIdentifier;
@property (readonly, nonatomic) int currentAudioIdentifier;
@property (readonly, nonatomic) double realCurrentPlaybackTime;
@property (readonly, nonatomic) float realPlaybackRate;
@property (readonly, nonatomic) float maxPlaybackRate;
@property (readonly, nonatomic) IJKMediaPlayerItem *currentItem;
...
- 我们从
IDA中查看IJKFFMoviePlayerControllerFFPlay的- (void)setPlaybackRate:(float)playbackRate伪代码实现,发现跟开源版本的IJKFFMoviePlayerController的- (void)setPlaybackRate:(float)playbackRate是一致的
// 哔哩哔哩版本
void __cdecl -[IJKFFMoviePlayerControllerFFPlay setPlaybackRate:](
IJKFFMoviePlayerControllerFFPlay *self,
SEL a2,
float a3)
{
sub_10F3F5768(
0LL,
32LL,
"IJKFFMoviePlayerControllerFFPlay: setPlaybackRate ts = %lld, playbackRate = %f\n",
+[IJKFFUtils getIjkTickHR](&OBJC_CLASS___IJKFFUtils, "getIjkTickHR"),
a3);
if ( self->_mediaPlayer )
sub_10F0BAFD4(a3);
}
// 开源版本
- (void)setPlaybackRate:(float)playbackRate
{
if (!_mediaPlayer)
return;
return ijkmp_set_playback_rate(_mediaPlayer, playbackRate);
}
- 我们从
IDA中查看sub_10F0BAFD4的伪代码实现,发现跟开源版本的ijkmp_set_playback_rate的实现是一致的
// 哔哩哔哩版本
__int64 __fastcall sub_10F0BAFD4(__int64 a1, float a2)
{
printf("%s(%f)\n", "ijkmp_set_playback_rate", a2);
pthread_mutex_lock((pthread_mutex_t *)(a1 + 8));
sub_10F0A70B4(*(_QWORD *)(a1 + 136), a2);
pthread_mutex_unlock((pthread_mutex_t *)(a1 + 8));
return printf("%s()=void\n", "ijkmp_set_playback_rate");
}
// 开源版本
void ijkmp_set_playback_rate(IjkMediaPlayer *mp, float rate)
{
assert(mp);
MPTRACE("%s(%f)\n", __func__, rate);
pthread_mutex_lock(&mp->mutex);
ffp_set_playback_rate(mp->ffplayer, rate);
pthread_mutex_unlock(&mp->mutex);
MPTRACE("%s()=void\n", __func__);
}
- 我们从
IDA中查看sub_10F0A70B4的伪代码实现,可以知道是从sub_10F101034获取最大播放速度的
__int64 __fastcall sub_10F0A70B4(__int64 result, float a2)
{
__int64 v3; // x19
float v4; // s0
__int64 v5; // x8
float v6; // s1
float v7; // [xsp+1Ch] [xbp-24h] BYREF
if ( result )
{
v3 = result;
v7 = 0.0;
sub_10F101034(10LL, &v7, 4LL);
if ( v7 < a2 )
{
sub_10F3F5768(0LL, 32LL, "%s: origin %f, new %f \n", "adjust_playback_rate", a2, v7);
a2 = v7;
}
sub_10F3F5768((__int64 *)v3, 32LL, "Playback rate: %f\n", a2);
if ( a2 == 0.0 )
v4 = 1.0;
else
v4 = a2;
if ( v4 != 1.0 )
*(_DWORD *)(v3 + 884) = 1;
v5 = *(_QWORD *)(v3 + 8);
if ( v5 )
{
v6 = *(float *)(v3 + 876);
if ( v6 != v4 )
*(double *)(v5 + 8040) = vabds_f32(v6, v4);
}
if ( v4 > *(float *)(v3 + 6724) )
*(float *)(v3 + 6724) = v4;
*(float *)(v3 + 876) = v4;
*(_DWORD *)(v3 + 880) = 1;
return sub_10F100F98(8LL, v3 + 9272);
}
return result;
}
- 我们从
IDA中查看sub_10F101034的伪代码实现,再根据sub_10F101034(10LL, &v7, 4LL); 知道参数a1=10,a3=4,将伪代码和参数交给chatgpt分析,得出下面几个结论:-
sub_10F10449C(a9)计算最大播放速度 - 该函数内部会将计算结果放入某个寄存器(例如
d0) - 解码器将其反编译成
v14 LABEL_38 → v13 = v14- 最终写入:
*(float*)a2 = v13
-
void __fastcall sub_10F101034(
int a1,
double *a2,
__int64 a3,
__int64 a4,
__int64 a5,
__int64 a6,
__int64 a7,
__int64 a8,
_QWORD *a9,
__int64 a10,
unsigned int a11,
__int64 a12)
{
float v13; // s0
double v14; // d0
__int64 v15; // x20
int v16; // w0
__int64 *v17; // [xsp+18h] [xbp-68h] BYREF
__int64 v18; // [xsp+20h] [xbp-60h]
__int64 v19; // [xsp+30h] [xbp-50h]
unsigned int *v20; // [xsp+58h] [xbp-28h]
if ( a2 && a3 )
{
switch ( a1 )
{
...
case 10:
if ( a3 == 4 )
{
v17 = &a10;
sub_10F10449C((unsigned int)a9);
goto LABEL_38;
}
break;
...
LABEL_38:
v13 = v14;
}
LABEL_44:
*(float *)a2 = v13;
}
break;
...
- 我们从
IDA中查看sub_10F10449C的伪代码实现,发现有可能返回两个值,一个是2.0,一个是全局变量qword_117084280的值
double __fastcall sub_10F10449C(__int64 a1)
{
__int64 v2; // x0
double result; // d0
double v4[6]; // [xsp+0h] [xbp-40h] BYREF
v2 = sub_10F104600();
sub_10F104B7C(v4, v2, a1);
result = 2.0;
if ( v4[0] < 50.0 )
return *(double *)&qword_117084280;
return result;
}
- 我们查看全局变量
qword_117084280的值,发现是3.0,而3.0就是最大播放速度-
qword_117084280的16进制值0000000117084280 00 00 00 00 00 00 08 40 00 00 00 00 00 00 00 00 -
关键是前
8字节:00 00 00 00 00 00 08 40 -
ARM64、Mach-O中double存储方式都是little-endian,所以按小端序解析:40 08 00 00 00 00 00 00 -
IEEE-754双精度格式:0x4008000000000000 → 3.0(double) -
所以
qword_117084280 = 3.0
-
- 我们添加符号断点
sub_10F10449C,看它的返回值多少
-
sub_10F10449C断点触发,返回值发现是3.0,这样就验证了全局变量qword_117084280的值是3.0
(lldb) register read d0
d0 = 3
-
总结:哔哩哔哩的最大播放速度方法是
sub_10F10449C,最大播放速度有可能是2倍速,也可能是3倍速,猜测跟视频有关。
越狱解决方案
我们hook sub_10F10449C,将最大值改为4.0
// 视频最大播放速度
public let maxPlaybackRateValue = 4.0
// 声明原函数类型
public typealias orig_get_max_playback_rate_type = @convention(c) (_ a1: Int64) -> Double
// 定义全局函数指针变量,并绑定一个 C 名字
@_silgen_name("orig_get_max_playback_rate")
nonisolated(unsafe) public var orig_get_max_playback_rate: orig_get_max_playback_rate_type? = nil
// 获取最大播放速度方法
@_cdecl("my_get_max_playback_rate")
func my_get_max_playback_rate(a1: Int64) -> Double {
return maxPlaybackRateValue
}
// 获取最大播放速度方法
long long get_max_playback_rate_address = g_slide+0x10F10449C;
NSLog(@"[%@] cal func get_max_playback_rate address:0x%llx", nj_logPrefix, get_max_playback_rate_address);
MSHookFunction((void *)get_max_playback_rate_address,
(void*)my_get_max_playback_rate,
(void**)&orig_get_max_playback_rate);
- 我们再
hookIJKFFMoviePlayerControllerFFPlay的- (void)setPlaybackRate:(float)playbackRate方法,用以打印日志
%hook IJKFFMoviePlayerControllerFFPlay
- (void)setPlaybackRate:(float)playbackRate {
playbackRate = playbackRate == 2.0 ? 4.0 : playbackRate;
%orig(playbackRate);
NSLog(@"%@:%@-%p-%s-inputPlaybackRate:%lf-changedPlaybackRate:%lf-realPlaybackRate%lf-maxPlaybackRate:%lf", nj_logPrefix, NSStringFromClass([(id)self class]), self, __FUNCTION__, playbackRate, self.playbackRate, self.realPlaybackRate, self.maxPlaybackRate);
}
%end
- 倍速面板选择
2.0,从日志中可以看到,最大播放速度(maxPlaybackRate)改成了4.0,播放速度(changedPlaybackRate)改成了4.0,👍
cxzcxz:IJKFFMoviePlayerControllerFFPlay-0x136428000-_logos_method$App$IJKFFMoviePlayerControllerFFPlay$setPlaybackRate$-inputPlaybackRate:4.000000-changedPlaybackRate:4.000000-realPlaybackRate0.000000-maxPlaybackRate:4.000000
非越狱解决方案
修改Mach-O文件的汇编指令
- 从
sub_10F10449C方法的伪代码可知,sub_10F10449C方法返回的就是最大播放速度
double __fastcall sub_10F10449C(__int64 a1)
{
__int64 v2; // x0
double result; // d0
double v4[6]; // [xsp+0h] [xbp-40h] BYREF
v2 = sub_10F104600();
sub_10F104B7C(v4, v2, a1);
result = 2.0;
if ( v4[0] < 50.0 )
return *(double *)&qword_117084280;
return result;
}
-
修改
sub_10F10449C方法的实现,改为类似下面的伪代码return 4.0;-
对应的汇编指令就是
FMOV D0, #4.0 RET
-
-
我们修改
sub_10F10449C方法的前两条汇编指令,改为下面
FMOV D0, #4.0
RET
-
示例
修改第一条汇编指令
-
鼠标点击
000000010F10449C__text:000000010F10449C SUB SP, SP, #0x50 -
右键选择
Assemble -
改成
FMOV D0, #4.0
- 点击
enter
-
-
全部修改结果
-
保存到
Mach-O文件中-
IDA->Edit->Patch program->Apply patches to input file->Apply patches
-
-
保存后,底部会显示
log:
Patch successful: /Users/touchworld/Documents/iOSDisassembler/hook/bilibili/IDA_max_0/bili-universal
代码
相关链接
Objective-C 类与对象详细入门
SDWebImage深度解析:高效图片加载背后的架构设计与卓越实践
在移动应用开发中,图片加载和缓存是影响用户体验的关键环节。SDWebImage作为iOS平台上最受欢迎的图片加载库,以其高性能、丰富的功能和稳定的表现赢得了全球开发者的信赖。本文将深入探讨SDWebImage的核心原理、架构设计,并解答实际使用中的常见问题。
一、整体架构设计:模块化与职责分离
为了让你能迅速抓住核心,我们先用一张图,从宏观视角看懂它的工作原理与核心流程。
SDWebImage采用了清晰的分层架构设计,将复杂的图片加载过程分解为独立的模块,各司其职:
1. 核心协调者:SDWebImageManager
这是SDWebImage的"大脑",负责协调缓存查找、下载和图片处理流程。它使用组合模式将SDImageCache和SDWebImageDownloader组合在一起,实现了对图片加载全生命周期的管理。
2. 缓存模块:SDImageCache 负责内存和磁盘缓存的双层存储。内存缓存基于NSCache实现,提供快速访问;磁盘缓存将图片持久化存储到文件系统中,支持自定义缓存策略。
3. 下载模块:SDWebImageDownloader 基于NSURLSession构建的异步下载器,支持并发控制、请求优先级设置和身份验证等高级功能。它通过操作队列管理下载任务,确保高效利用网络资源。
4. 解码与处理模块 负责图片解码、缩放、裁剪等后处理操作。SDWebImage在后台线程执行这些操作,避免阻塞主线程。
5. 视图扩展:UIImageView+WebCache 为UIKit组件提供的便捷接口,开发者可以通过一行代码实现图片的异步加载和缓存管理。
这种模块化设计不仅使代码结构清晰,还提高了库的可扩展性和可维护性。
二、图片解码机制:后台线程的高效处理
iOS系统在渲染图片时需要将其解码为位图格式,这个过程默认在主线程进行,可能导致界面卡顿。SDWebImage对此进行了重要优化:
解码时机与线程策略
SDWebImage在图片从磁盘加载或网络下载完成后,立即在后台线程进行解码。解码器使用专门的NSOperationQueue,避免了解码任务阻塞主线程。
空间换时间的缓存策略 解码后的位图数据会被缓存到内存中。当同一图片再次请求时,可以直接使用缓存的解码结果,无需重复解码,显著提升了性能。
渐进式解码支持 对于网络下载的大图片,SDWebImage支持渐进式解码。图片在下载过程中逐步显示,用户可以更快地看到图片内容,提升等待体验。
三、缓存机制:智能的双层存储系统
SDWebImage的缓存系统是其高性能的核心保障:
1. 内存缓存(Memory Cache)
基于NSCache实现,具有自动清理机制。当系统内存紧张时,NSCache会自动释放部分缓存。默认情况下,SDWebImage不限制内存缓存大小,但支持通过totalCostLimit和countLimit进行自定义限制。
2. 磁盘缓存(Disk Cache) 图片以文件形式存储在Cache目录中,文件名经过MD5哈希处理,确保唯一性和安全性。
3. 默认最大缓存大小
可设置SDWebImage的磁盘缓存大小为100MB。当缓存超过此限制时,SDWebImage会基于文件的最后访问时间进行清理,优先移除最久未访问的图片。这一设置可以在SDImageCacheConfig中自定义。
四、缓存清理机制:灵活的资源管理
SDWebImage提供了多种缓存清理方式,满足不同场景的需求:
1. 自动清理机制
- 基于时间的清理:默认配置下,SDWebImage会自动清理超过一周的缓存文件
- 基于大小的清理:当缓存超过设定的大小时,自动清理最旧的图片文件
2. 手动清理接口
// 清理所有内存缓存
[[SDImageCache sharedImageCache] clearMemory];
// 清理所有磁盘缓存(异步)
[[SDImageCache sharedImageCache] clearDiskOnCompletion:nil];
// 清理过期的磁盘缓存(异步)
[[SDImageCache sharedImageCache] deleteOldFilesWithCompletionBlock:nil];
3. 细粒度控制 开发者可以针对特定URL或key清理缓存,实现更精准的缓存管理。
五、动态图支持:从GIF到现代动画格式
SDWebImage对动态图的支持经历了显著的演进:
早期方案 早期版本通过将GIF分解为帧序列,使用UIImage的动画API播放,这种方式内存占用高且功能有限。
现代方案:SDAnimatedImage协议
从SDWebImage 5.0开始,引入了SDAnimatedImage协议,提供了统一的动画图片接口,支持多种格式:
- GIF:完整的解码和播放支持
- APNG:Apple原生支持的动画格式
- WebP:Google的高效图片格式(需要额外编码器)
- HEIC:高效的现代图片格式
内存优化
SDAnimatedImageView采用惰性解码策略,仅解码当前显示和预加载的帧,大幅降低了内存占用。开发者还可以通过maxBufferSize属性控制缓冲帧数,在流畅度和内存消耗之间找到平衡。
六、视图可见性触发加载:按需加载的智能策略
在列表等场景中,实现"视图出现在屏幕才开始加载图片"是提升性能的关键。SDWebImage与UIKit的协同工作实现了这一目标:
UITableView/UICollectionView的优化加载
// 在cellForRowAtIndexPath中设置图片
- (UITableViewCell *)tableView:(UITableView *)tableView
cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
// 获取图片URL
NSURL *imageURL = [self imageURLForIndexPath:indexPath];
// 使用SDWebImage加载图片
[cell.imageView sd_setImageWithURL:imageURL
placeholderImage:[UIImage imageNamed:@"placeholder"]
completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
// 加载完成处理
}];
return cell;
}
// 在prepareForReuse中取消未完成的加载
- (void)prepareForReuse {
[super prepareForReuse];
[self.imageView sd_cancelCurrentImageLoad];
}
工作原理
- 当cell准备显示时,
tableView:cellForRowAtIndexPath:被调用,开始图片加载 - 如果cell滚出屏幕,
prepareForReuse会被调用,取消未完成的图片加载 - SDWebImage内部会管理加载队列,优先处理可见cell的请求
高级优化技巧
// 1. 预加载:提前加载即将显示的图片
- (void)tableView:(UITableView *)tableView
willDisplayCell:(UITableViewCell *)cell
forRowAtIndexPath:(NSIndexPath *)indexPath {
// 预加载下一批图片
if (indexPath.row + 5 < [self.dataSource count]) {
NSURL *preloadURL = [self imageURLForIndexPath:[NSIndexPath indexPathForRow:indexPath.row + 5 inSection:indexPath.section]];
[[SDWebImagePrefetcher sharedImagePrefetcher] prefetchURLs:@[preloadURL]];
}
}
// 2. 设置不同的加载优先级
SDWebImageOptions options = SDWebImageLowPriority | SDWebImageProgressiveLoad;
[cell.imageView sd_setImageWithURL:imageURL placeholderImage:nil options:options];
七、静态图与动态图的选择策略
在实际开发中,我们经常面临选择:使用UIImageView还是SDAnimatedImageView?以下是明确的指导原则:
1. 静态图片场景
- 使用标准的
UIImageView - 通过
sd_setImageWithURL:方法加载 - 性能最佳,内存占用最低
2. 动态图片场景
- 使用
SDAnimatedImageView - 通过
sd_setImageWithURL:方法加载(SDWebImage会自动检测图片类型) - 支持GIF、APNG、WebP等多种动态格式
3. 未知图片类型的处理策略 当不确定图片是静态还是动态时,推荐采用以下方案:
// 方案1:统一使用SDAnimatedImageView(推荐)
// 优点:自动适应所有图片类型,代码简洁
// 缺点:对静态图有轻微性能开销
SDAnimatedImageView *imageView = [SDAnimatedImageView new];
[imageView sd_setImageWithURL:imageURL];
// 方案2:根据URL或响应头动态选择
// 在知道图片类型的情况下优化性能
if ([url.pathExtension isEqualToString:@"gif"] ||
[url.absoluteString containsString:@"animated"]) {
// 使用SDAnimatedImageView
SDAnimatedImageView *animatedImageView = [SDAnimatedImageView new];
[animatedImageView sd_setImageWithURL:url];
} else {
// 使用普通UIImageView
UIImageView *staticImageView = [UIImageView new];
[staticImageView sd_setImageWithURL:url];
}
// 方案3:使用SDWebImage的自动检测功能
// SDWebImage 5.0+ 可以自动检测图片类型并选择合适的视图
UIImageView *imageView = [UIImageView new];
SDWebImageOptions options = SDWebImageAutoHandleAnimatedImage;
[imageView sd_setImageWithURL:url options:options];
性能对比与建议
| 场景 | 推荐视图 | 内存占用 | CPU使用 | 兼容性 |
|---|---|---|---|---|
| 已知静态图 | UIImageView | 低 | 低 | 最佳 |
| 已知动态图 | SDAnimatedImageView | 中等 | 中等 | 最佳 |
| 未知类型 | SDAnimatedImageView | 中等 | 中等 | 最佳 |
| 大量静态图列表 | UIImageView | 低 | 低 | 最佳 |
最佳实践建议
- 对于图片社交应用(如Instagram),用户上传内容类型未知,建议统一使用
SDAnimatedImageView - 对于电商应用,商品主图大多是静态图,使用
UIImageView即可 - 在性能敏感的场景(如大规模图片列表),可以考虑先获取图片元信息再决定视图类型
八、高级功能与定制扩展
自定义缓存策略
// 创建自定义缓存配置
SDImageCacheConfig *config = [SDImageCacheConfig defaultCacheConfig];
config.maxDiskAge = 7 * 24 * 60 * 60; // 一周
config.maxDiskSize = 200 * 1024 * 1024; // 200MB
config.maxMemoryCost = 100 * 1024 * 1024; // 100MB内存缓存
config.diskCacheExpireType = SDImageCacheConfigExpireTypeAccessDate; // 按访问时间过期
// 创建自定义缓存实例
SDImageCache *customCache = [[SDImageCache alloc] initWithNamespace:@"Custom" diskCacheDirectory:customPath config:config];
图片转换器
// 创建圆角图片转换器
SDImageRoundCornerTransformer *transformer = [SDImageRoundCornerTransformer transformerWithRadius:10 corners:UIRectCornerAllCorners borderWidth:1 borderColor:[UIColor whiteColor]];
// 加载时应用转换器
[imageView sd_setImageWithURL:url placeholderImage:nil options:0 context:@{SDWebImageContextImageTransformer: transformer}];
九、性能监控与调试
内存使用监控
// 获取缓存统计信息
NSUInteger memCost = [[SDImageCache sharedImageCache] totalMemoryCost];
NSUInteger diskCount = [[SDImageCache sharedImageCache] totalDiskCount];
NSUInteger diskSize = [[SDImageCache sharedImageCache] totalDiskSize];
// 监控图片加载性能
[SDWebImageManager.sharedManager setCacheKeyFilter:^NSString * _Nullable(NSURL * _Nullable url) {
// 记录加载时间
CFTimeInterval startTime = CACurrentMediaTime();
return [url absoluteString];
}];
十、总结与展望
SDWebImage通过其精良的架构设计,在图片加载的各个关键环节都做了深度优化。从后台解码到智能缓存,从动态图支持到可见性触发加载,每一个设计决策都体现了对性能与用户体验的极致追求。
随着iOS开发技术的演进,SDWebImage也在不断发展。未来,我们期待看到:
- 对Swift Concurrency的更好支持
- 与SwiftUI的更深度集成
- 对新型图片格式的更快适配
- 更智能的缓存预取和淘汰算法
无论你是刚刚接触SDWebImage的新手,还是希望深入优化图片加载性能的资深开发者,理解SDWebImage的核心原理都将帮助你构建更流畅、更高效的iOS应用。
求求你试试 DiffableDataSource!别再手算 indexPath 了(否则迟早崩)
IOS开发SwiftUI相关学习记录
iOS 电量监控与优化完整方案
从 YaoYao 到 Tooboo:watchOS 开发避坑与实战
作为 YaoYao 和 Tooboo 的作者,Haozes 分享了 watchOS 开发中关于版本兼容、App 唤起通信、数据同步、重启恢复、内存泄露和电量优化等高质量实战经验。这篇文章涵盖了从 HealthKit 到 WCSession、从 HKWorkoutSession 到 TimelineSchedule 的完整开发避坑与性能调优指南,对于正在开发或计划开发 Apple Watch 应用的开发者具有极高参考价值。
Swift 迭代三巨头(下集):Sequence、Collection 与 Iterator 深度狂飙
🛡️ 第一重防线:破解 AsyncSequence 的错误恢复漏洞
异步序列的 “错误恢复漏洞”,本质是没搞懂AsyncSequence的错误传播规则 —— 就像 F1 赛车的刹车系统没校准,一踩就抱死,一松就失控。
当next()抛出错误时,默认会直接终止迭代,可如果粗暴重试,又会导致 “重复接收元素”;如果不重试,又会丢失关键数据。
在本堂F1调教课中,您将学到如下内容:
- 🛡️ 第一重防线:破解 AsyncSequence 的错误恢复漏洞
- 错误传播的 “底层逻辑”
- 精准重试:给迭代器加 “记忆功能”
- 实战效果:再也不重复,再也不丢失
- 🛡️ 第二重防线:给 SafeRingBuffer 加 “数据校验锁”
- 校验逻辑:哈希值是 “数据身份证”
- 升级后的 SafeRingBuffer(核心校验代码)
- 效果:“迭代异常兽” 的篡改彻底失效
- 🏆 终局:组合拳粉碎 “迭代异常兽”
- 完整流程代码(终局方案)
- 剧情收尾:赛道恢复平静,迭代真相揭晓
- 📝 终极总结:Swift 迭代的 “黄金法则”
艾拉和杰西要做的,就是找到 “精准重试” 的平衡点。
错误传播的 “底层逻辑”
先搞懂一个关键:AsyncSequence的错误是 “终止性的”—— 一旦next()抛出错误,整个迭代就会停止,就像赛车引擎爆缸后再也没法前进。比如传感器断连抛出SensorDisconnectError,for try await循环会立刻跳出,进入catch块,后续的元素再也接收不到。
看这个 “踩坑示例”:
// 错误示范:粗暴重试导致重复数据
Task {
do {
for try await data in SensorSequence() {
sensorBuffer.enqueue(data)
print("接收数据:\(data)")
}
} catch is SensorDisconnectError {
// 断连后直接重试,却没记录“已接收的元素ID”
print("传感器断连,重试中...")
await retrySensorSequence() // 重试时会重新接收之前已处理的元素
}
}
这段代码的问题在于:重试时会生成全新的异步迭代器,它不知道之前已经接收过哪些元素,导致 “旧数据重复入队”—— 这正是 “迭代异常兽” 想要的结果。
精准重试:给迭代器加 “记忆功能”
杰西的解决方案是:自定义一个RetryableSensorSequence,给异步迭代器加 “元素 ID 记忆”,重试时跳过已处理的元素,就像赛车在维修后重回赛道,能精准接上之前的位置继续跑。
// 带“记忆功能”的可重试异步序列
struct RetryableSensorSequence: AsyncSequence {
typealias Element = SensorData // 传感器数据(含唯一ID和校验码)
typealias AsyncIterator = RetryableSensorIterator
private let baseSequence: SensorSequence // 原始传感器序列
private var processedIDs: Set<String> = [] // 记录已处理的元素ID(防重复)
private let maxRetries: Int = 3 // 最大重试次数(避免无限循环)
private var currentRetry: Int = 0 // 当前重试次数
init(baseSequence: SensorSequence) {
self.baseSequence = baseSequence
}
func makeAsyncIterator() -> RetryableSensorIterator {
RetryableSensorIterator(
baseIterator: baseSequence.makeAsyncIterator(),
processedIDs: &processedIDs,
maxRetries: maxRetries,
currentRetry: ¤tRetry
)
}
// 带记忆功能的异步迭代器
struct RetryableSensorIterator: AsyncIteratorProtocol {
typealias Element = SensorData
private var baseIterator: SensorSequence.AsyncIterator
private var processedIDs: inout Set<String> // 引用外部的已处理ID集合
private let maxRetries: Int
private var currentRetry: inout Int
mutating func next() async throws -> SensorData? {
do {
guard let data = try await baseIterator.next() else {
return nil // 序列正常结束
}
// 关键:检查元素ID是否已处理,避免重复
guard !processedIDs.contains(data.id) else {
return try await next() // 跳过重复元素,继续获取下一个
}
processedIDs.insert(data.id) // 记录已处理的ID
return data
} catch is SensorDisconnectError {
// 达到最大重试次数,抛出最终错误
guard currentRetry < maxRetries else {
currentRetry = 0 // 重置重试次数,方便后续复用
throw error // 重试失败,终止迭代
}
currentRetry += 1
print("第\(currentRetry)次重试传感器连接...")
// 重建基础迭代器(重新连接传感器)
self.baseIterator = SensorSequence().makeAsyncIterator()
// 递归调用next(),继续迭代(不重复已处理元素)
return try await next()
} catch {
// 其他错误(比如数据格式错误),直接抛出
throw error
}
}
}
}
实战效果:再也不重复,再也不丢失
艾拉把这个 “带记忆的序列” 集成到系统后,效果立竿见影:
// 正确姿势:用可重试序列消费传感器数据
Task {
do {
let retryableSequence = RetryableSensorSequence(baseSequence: SensorSequence())
for try await data in retryableSequence {
sensorBuffer.enqueue(data)
print("安全接收数据:\(data)(ID:\(data.id))")
}
} catch {
print("最终失败:\(error),已触发备用传感器")
}
}
当传感器断连时,序列会自动重试(最多 3 次),且重试后绝不会重复接收旧数据 —— 因为processedIDs会牢牢记住 “哪些数据已经处理过”。就像赛车在维修区快速换胎后,能精准回到赛道的正确位置,既不落后,也不跑偏。
🛡️ 第二重防线:给 SafeRingBuffer 加 “数据校验锁”
解决了重复问题,下一个目标是 “数据篡改”——“迭代异常兽” 会修改传感器数据的校验码,让错误数据混入缓冲区。杰西的方案是:给SafeRingBuffer升级,在 “入队” 和 “访问” 时双重校验数据完整性,就像给赛车装 “指纹锁”,不是自己人的数据,一概不让进。
校验逻辑:哈希值是 “数据身份证”
传感器数据会自带一个checksum(哈希值),计算规则是 “数据内容 + 时间戳” 的 MD5 值。SafeRingBuffer在入队时要验证这个哈希值,不匹配就拒绝入队;在访问时再二次校验,确保数据没被篡改。
升级后的 SafeRingBuffer(核心校验代码)
struct SafeRingBuffer<Element: DataVerifiable>: Collection {
// 新增约束:Element必须遵守DataVerifiable协议(有校验能力)
private var storage: [Element?]
private var head = 0
private var tail = 0
private(set) var count = 0
private let lock = NSLock()
// 入队时校验:篡改的数据直接拒之门外
mutating func enqueue(_ element: Element) throws {
lock.lock()
defer { lock.unlock() }
// 关键:验证数据哈希值,不匹配则抛出“数据篡改错误”
guard element.verifyChecksum() else {
throw DataTamperingError.invalidChecksum(
"数据校验失败,ID:\(element.id),可能被篡改"
)
}
storage[tail] = element
tail = (tail + 1) % storage.count
if count == storage.count {
head = (head + 1) % storage.count
} else {
count += 1
}
}
// 下标访问时二次校验:防止缓冲区内部数据被篡改
subscript(position: Index) -> Element {
lock.lock()
defer { lock.unlock() }
precondition((0..<count).contains(position), "索引超出范围")
let actualPosition = (head + position) % storage.count
guard let element = storage[actualPosition] else {
preconditionFailure("缓冲区数据丢失,位置:\(actualPosition)")
}
// 二次校验:确保数据在缓冲区中没被篡改
precondition(element.verifyChecksum(), "缓冲区数据被篡改,ID:\(element.id)")
return element
}
}
// 数据校验协议:所有需要校验的数据都要遵守
protocol DataVerifiable {
var id: String { get } // 唯一ID
var checksum: String { get } // 数据哈希值
// 校验方法:计算当前数据的哈希值,和自带的checksum对比
func verifyChecksum() -> Bool
}
// 传感器数据实现校验协议
extension SensorData: DataVerifiable {
func verifyChecksum() -> Bool {
// 计算“内容+时间戳”的MD5哈希值(真实项目中建议用更安全的SHA256)
let calculatedChecksum = "\(content)-\(timestamp)".md5()
return calculatedChecksum == self.checksum
}
}
// 自定义错误:数据篡改错误
enum DataTamperingError: Error {
case invalidChecksum(String)
}
效果:“迭代异常兽” 的篡改彻底失效
当 “迭代异常兽” 试图把篡改后的传感器数据(校验码不匹配)入队时,enqueue会直接抛出DataTamperingError,错误数据连缓冲区的门都进不了;就算它想偷偷修改缓冲区里的数据,subscript访问时的二次校验也会触发preconditionFailure,立刻暴露问题。
艾拉测试时故意注入一条篡改数据,系统瞬间弹出警告:“DataTamperingError:数据校验失败,ID:sensor_123,可能被篡改”—— 就像赛车的防盗系统检测到非法入侵,立刻锁死引擎,让 “小偷” 无从下手。
🏆 终局:组合拳粉碎 “迭代异常兽”
解决了错误恢复和数据篡改,艾拉和杰西打出最后一套 “组合拳”:用RetryableSensorSequence处理异步错误,用SafeRingBuffer做数据缓存和校验,再配合一个 “监控 Task” 实时监控序列状态 —— 三者联动,形成无死角的防御网。
完整流程代码(终局方案)
// 1. 创建带校验的环形缓冲区(容量10,只存合法数据)
var verifiedBuffer = SafeRingBuffer<SensorData>(capacity: 10)
// 2. 创建可重试的传感器序列(防断连、防重复)
let sensorSequence = SensorSequence()
let retryableSequence = RetryableSensorSequence(baseSequence: sensorSequence)
// 3. 主Task:消费序列,存入缓冲区
let mainTask = Task {
do {
for try await data in retryableSequence {
do {
try verifiedBuffer.enqueue(data)
print("成功入队:ID=\(data.id),转速=\(data.engineRPM)转")
// 实时更新仪表盘(只传合法数据)
await dashboard.update(with: verifiedBuffer[verifiedBuffer.count - 1])
} catch DataTamperingError.invalidChecksum(let message) {
print("拦截篡改数据:\(message)")
// 触发警报,记录日志
await alertSystem.triggerLevel(.high, message: message)
}
}
} catch {
print("迭代终止:\(error)")
// 重试失败,切换到备用传感器
await switchToBackupSensor()
}
}
// 4. 监控Task:实时检查缓冲区状态,防止异常
let monitorTask = Task {
while !Task.isCancelled {
guard verifiedBuffer.count > 0 else {
print("警告:缓冲区为空,可能传感器无数据")
await alertSystem.triggerLevel(.low, message: "缓冲区空")
try await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
// 每1秒检查一次最新数据的时效性(防止数据过期)
let latestData = verifiedBuffer[verifiedBuffer.count - 1]
if Date().timeIntervalSince(latestData.timestamp) > 5 {
print("警告:最新数据已过期,可能序列卡顿")
await alertSystem.triggerLevel(.medium, message: "数据过期")
}
try await Task.sleep(nanoseconds: 1_000_000_000)
}
}
剧情收尾:赛道恢复平静,迭代真相揭晓
当这套组合拳部署完成后,赛车仪表盘的转速数据瞬间稳定下来 ——10000 转、10020 转、9980 转,每一个数字都精准跳动,控制室的警报声渐渐平息。艾拉看着屏幕上 “所有传感器正常” 的绿色提示,长舒一口气:“‘迭代异常兽’被打跑了?”
杰西笑着点开日志,里面全是 “拦截篡改数据”“重试成功” 的记录:“不是打跑,是它再也没法钻漏洞了。你看 ——AsyncSequence 的核心是‘错误可控’,Collection 的核心是‘数据可信’,迭代器的核心是‘状态独立’,只要守住这三个核心,再狡猾的问题也能解决。”
突然,仪表盘弹出一条新消息:“检测到外部干扰源已断开连接”——“迭代异常兽” 彻底消失了。
赛道上,F1 赛车重新加速,引擎的轰鸣声再次变得均匀有力;屏幕前,艾拉和杰西相视一笑,他们不仅修复了系统,更摸清了 Swift 迭代的 “底层逻辑”。
📝 终极总结:Swift 迭代的 “黄金法则”
- Sequence 是 “入门契约”:只承诺 “能迭代一次”,适合懒加载、生成器场景,像 F1 的 “练习赛”—— 灵活但不追求稳定。
- Collection 是 “进阶契约”:多轮迭代、索引访问、数据稳定,适合需要反复操作的数据,像 F1 的 “正赛”—— 稳定且高效。
- AsyncSequence 是 “异步契约”:支持暂停、抛错,适合数据流场景,但要注意 “错误终止性” 和 “重试防重复”,像 F1 的 “夜间赛”—— 更复杂,但有专属的应对策略。
- 迭代器的 “值语义优先”:尽量用 struct 实现迭代器,避免共享可变状态,就像赛车的 “独立操控系统”—— 互不干扰,安全可控。
最后记住:Swift 的迭代看似简单,实则是 “协议驱动” 的精妙设计。当你写下for item in list时,背后是 Sequence、Collection、Iterator 的协同作战 —— 就像 F1 赛车的引擎、刹车、底盘完美配合,才能跑出最快的速度,也才能写出最稳定、最高效的代码。
那么,宝子们看到这里学到了吗?
感谢观赏,下次我们再会吧!8-)