注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

http拦截神器:Chuck

前言:Chuck是Android OkHttp客户端的一个简单的应用内HTTP检查器。Chuck拦截并持久化应用程序中的所有HTTP请求和响应,并提供用于检查其内容的UI。使用Chuck的应用程序将显示一个通知,显示正在进行的HTTP活动的摘要。点击通知启动完...
继续阅读 »

前言:Chuck是Android OkHttp客户端的一个简单的应用内HTTP检查器。Chuck拦截并持久化应用程序中的所有HTTP请求和响应,并提供用于检查其内容的UI。


使用Chuck的应用程序将显示一个通知,显示正在进行的HTTP活动的摘要。点击通知启动完整的Chuck UI。应用程序可以选择性地抑制通知,并直接从自己的界面中启动Chuck UI。HTTP交互及其内容可以通过共享意图导出。

主要的Chuck活动是在它自己的任务中启动的,允许它与使用Android7.x多窗口支持的主机应用程序UI一起显示。

警告:使用此拦截器时生成和存储的数据可能包含敏感信息,如授权或Cookie头,以及请求和响应主体的内容。它用于开发过程中,而不是发布版本或其他生产部署中。

如果你使用OkHttp作为网络请求库,那么这个Chuck库可以帮助你拦截留存所有的HTTP请求和相应信息。同时也提供UI来显示拦截的信息

效果如下:


安装
配置:

dependencies {
debugCompile 'com.readystatesoftware.chuck:library:1.0.4'
releaseCompile 'com.readystatesoftware.chuck:library-no-op:1.0.4'
}


在您的应用程序代码中,创建一个ChuckInterceptor实例(您需要为它提供一个上下文,因为Android)并在构建OkHttp客户端时将其添加为拦截器:

OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(new ChuckInterceptor(context))
.build();


就这样!Chuck现在将记录OkHttp客户端进行的所有HTTP交互。您可以选择通过在拦截器实例上调用showNotification(false)来禁用通知,并使用Chuck.getLaunchIntent()中的意图直接在应用程序中启动Chuck UI。

Github地址:https://github.com/jgilfelt/chuck

下载地址:chuck-master.zip


收起阅读 »

预览帧画面库:PreviewSeekBar

PreviewSeekBar其实大家用PC优酷看视频的时候,鼠标放到进度条撒花姑娘就可以预览到所指向的帧画面。 一个叫[Ruben Sousa](https://medium.com/@rubensousa)的哥们做出了一个库并开源。 效果如下L:使用说明:...
继续阅读 »

PreviewSeekBar

其实大家用PC优酷看视频的时候,鼠标放到进度条撒花姑娘就可以预览到所指向的帧画面。

一个叫[Ruben Sousa](https://medium.com/@rubensousa)的哥们做出了一个库并开源。

效果如下L:


使用说明:

Build

  1. dependencies {
  2.     compile 'com.github.rubensousa:previewseekbar:0.3'
  3. }

Add the following XML:

  1. <com.github.rubensousa.previewseekbar.PreviewSeekBarLayout
  2.       android:id="@+id/previewSeekBarLayout"
  3.       android:layout_width="match_parent"
  4.       android:layout_height="wrap_content"
  5.       android:orientation="vertical">
  6.       <FrameLayout
  7.           android:id="@+id/previewFrameLayout"
  8.           android:layout_width="@dimen/video_preview_width"
  9.           android:layout_height="@dimen/video_preview_height">
  10.           <View
  11.               android:id="@+id/videoView"
  12.               android:layout_width="match_parent"
  13.               android:layout_height="match_parent"
  14.               android:layout_gravity="start"
  15.               android:background="@color/colorPrimary" />
  16.       </FrameLayout>
  17.       <com.github.rubensousa.previewseekbar.PreviewSeekBar
  18.           android:id="@+id/previewSeekBar"
  19.           android:layout_width="match_parent"
  20.           android:layout_height="wrap_content"
  21.           android:layout_below="@id/previewFrameLayout"
  22.           android:layout_marginTop="25dp"
  23.           android:max="800" />
  24. </com.github.rubensousa.previewseekbar.PreviewSeekBarLayout>

你需要在PreviewSeekBarLayout中至少添加一个PreviewSeekBar和一个FrameLayout,否则会出现异常。

PreviewSeekBarLayout继承自RelativeLayout因此还可以添加别的视图或者布局。

为seekBar添加一个标准的OnSeekBarChangeListener:

  1. // setOnSeekBarChangeListener was overridden to do the same as below
  2. seekBar.addOnSeekBarChangeListener(this);

实现你自己的预览逻辑:

  1. @Override
  2. public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
  3.     // I can't help anymore
  4. }

Github地址:https://github.com/rubensousa/PreviewSeekBar

下载地址:Store-feature-rx2.zip

收起阅读 »

异步数据加载和缓存数据的库:Store

StoreStore是一个异步数据加载和缓存数据的库。 实现一个 Disk Cache 需要以下几个步骤:在 Retrofit 的 API 下@GET("/v1/events")Single getEventsResponseBody();两点需要注意,一是要...
继续阅读 »

Store

Store是一个异步数据加载和缓存数据的库。

实现一个 Disk Cache 需要以下几个步骤:

在 Retrofit 的 API 下

@GET("/v1/events")
Single getEventsResponseBody();
两点需要注意,一是要用 Single,而是要用 ResponseBody
    创建 fetcher
private fun fetcher(): Single {
return service.getEventsResponseBody().map({ it.source() })
}
创建 Store
private fun provideStore(): Store {
return StoreBuilder.parsedWithKey()
.fetcher { fetcher() }
.persister(FileSystemPersister.create(FileSystemFactory.create(filesDir)) { key -> key })
.parser(JacksonParserFactory.createSourceParser(Events::class.java))
.open()
}
    使用 Store
store.get("cache_key") // get or fetch


Github地址:https://github.com/NYTimes/Store

下载地址:Store-feature-rx2.zip

收起阅读 »

Toast增强库

StyleableToast这也是一个Toast增强库。 设置背景颜色的吐司。设置吐司和存档的圆角半径不同的形状。设置吐司给你所有的透明固体或透明的吐司。设置笔划宽度和中风颜色在你的吐司。设置一个图标旁边的面包文本。你的图标上设置一个旋转的动画效果(见下面的例...
继续阅读 »

StyleableToast

这也是一个Toast增强库。
  • 设置背景颜色的吐司。
  • 设置吐司和存档的圆角半径不同的形状。
  • 设置吐司给你所有的透明固体或透明的吐司。
  • 设置笔划宽度和中风颜色在你的吐司。
  • 设置一个图标旁边的面包文本。
  • 你的图标上设置一个旋转的动画效果(见下面的例子01) 从Api 16 +工作

效果如下:


使用

1,在app/build.gradle文件中添加如下代码:

compile 'com.muddzdev:styleabletoast:1.0.6'

2,布局文件:main.xml就是几个按钮就不贴出来了,直接上一个是动态刷新的那个布局和飞行模式:
飞行模式:ic_airplanemode_inactive_black_24dp.xml

<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#ffffff"
android:pathData="M13,9V3.5c0,-0.83 -0.67,-1.5 -1.5,-1.5S10,2.67 10,3.5v3.68l7.83,7.83L21,16v-2l-8,-5zM3,5.27l4.99,4.99L2,14v2l8,-2.5V19l-2,1.5V22l3.5,-1 3.5,1v-1.5L13,19v-3.73L18.73,21 20,19.73 4.27,4 3,5.27z"/>
</vector>

动态刷新: ic_autorenew_black_24dp.xml

<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#fff"
android:pathData="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z"/>
</vector>

3,使用方式:
1>在styles.xml文件中添加样式:

<style name="StyleableToast">

<item name="android:textColor">@color/red</item>
<item name="android:colorBackground">@color/gray</item>
<!--<item name="android:fontFamily">fonts/dosis.otf"</item>-->
<item name="android:textStyle">bold</item>
<!--<item name="android:strokeWidth">2</item>-->
<!--<item name="android:strokeColor">@color/colorPrimary</item>-->
<!--<item name="android:radius">@dimen/toastRadius</item>-->
<item name="android:alpha">255</item>
<item name="android:icon">@drawable/ic_file_download</item>
</style>


使用:

case R.id.btn_toast_style_05 :
StyleableToast.makeText(this, "Picture saved in gallery", Toast.LENGTH_LONG, R.style.StyleableToast).show();
break;

2>,使用code:

private StyleableToast st;

case R.id.btn_toast_style_01 :
st = new StyleableToast(this, "加载中...", Toast.LENGTH_LONG);
st.setBackgroundColor(Color.parseColor("#ff5a5f"));
st.setTextColor(Color.WHITE);
st.setIcon(R.drawable.ic_autorenew_black_24dp);
st.spinIcon();
st.setMaxAlpha();
st.show();
break;


3> .Builder

private StyleableToast stBuilder;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

initView();

stBuilder = new StyleableToast.Builder(this,"已关闭飞行模式")
.withBackgroundColor(Color.parseColor("#865aff"))
.withIcon(R.drawable.ic_airplanemode_inactive_black_24dp)
.withMaxAlpha()
.build();
}
...
case R.id.btn_toast_style_02 :
stBuilder.show();
break;

Github地址:https://github.com/Muddz/StyleableToast

下载地址:StyleableToast-master.zip


收起阅读 »

Toasty 一个自定义toast库

Toasty这是一个自定义Toast的库。 效果图:1. 添加这个在你的Project的 build.gradle 文件( 不是 你的Module的 build.gradle 文件):allprojects {repositories {...maven { ...
继续阅读 »

Toasty

这是一个自定义Toast的库。

效果图:


1. 添加这个在你的Project的 build.gradle 文件( 不是 你的Module的 build.gradle 文件):

allprojects {
repositories {
...
maven { url "https://jitpack.io" }
}
}


依赖
添加到你的Module的build.gradle文件:

dependencies {
...
compile 'com.github.GrenderG:Toasty:1.2.5'
}


配置
这一步是可选的,但如果你想要,你可以配置一些Toasty参数。把这个地方放在你的应用程序中

Toasty.Config.getInstance()
.setErrorColor(@ColorInt int errorColor) // optional
.setInfoColor(@ColorInt int infoColor) // optional
.setSuccessColor(@ColorInt int successColor) // optional
.setWarningColor(@ColorInt int warningColor) // optional
.setTextColor(@ColorInt int textColor) // optional
.tintIcon(boolean tintIcon) // optional (apply textColor also to the icon)
.setToastTypeface(@NonNull Typeface typeface) // optional
.setTextSize(int sizeInSp) // optional
.apply(); // required
To display an error Toast:

Toasty.error(yourContext, "This is an error toast.", Toast.LENGTH_SHORT, true).show();
To display a success Toast:

Toasty.success(yourContext, "Success!", Toast.LENGTH_SHORT, true).show();
To display an info Toast:

Toasty.info(yourContext, "Here is some info for you.", Toast.LENGTH_SHORT, true).show();
To display a warning Toast:

Toasty.warning(yourContext, "Beware of the dog.", Toast.LENGTH_SHORT, true).show();
To display the usual Toast:

Toasty.normal(yourContext, "Normal toast w/o icon").show();
To display the usual Toast with icon:

Toasty.normal(yourContext, "Normal toast w/ icon", yourIconDrawable).show();
You can also create your custom Toasts with the custom() method:

Toasty.custom(yourContext, "I'm a custom Toast", yourIconDrawable, tintColor, duration, withIcon,
shouldTint).show();

Github地址:https://github.com/GrenderG/Toasty

下载地址:Toasty-master.zip

收起阅读 »

Lottie动画库

Lottie效果图如下:本文主要介绍动画开源库 Lottie 在 Android 中的使用。 前言 在日常APP开发中,为了提升用户感官舒适度等原因,我们会在APP中加入适量动画。 而传统手写动画方式往往存在诸多问题: 动画复杂而实现困难 ...
继续阅读 »

Lottie

效果图如下:


本文主要介绍动画开源库 Lottie 在 Android 中的使用。



前言


在日常APP开发中,为了提升用户感官舒适度等原因,我们会在APP中加入适量动画。
而传统手写动画方式往往存在诸多问题:



  • 动画复杂而实现困难


  • 图片素材占用体积过大


  • 不同Native平台都需各自实现,开发成本高


  • 不同Native平台实现的最终效果不一致


  • 后期视觉联调差异化大




难道就没有一种简便且高效的方案来减缓或解决上述问题吗?


答: 有的,那就是本文要介绍的主角 Lottie


一、Lottie 是什么?



Lottie是Airbnb开源的一个面向IOS、Android、React Native的动画库,可以解析用 Adobe After Effects 制作动画后通过 Bodymovin 插件导出的 Json 数据文件并在移动端原生渲染。



通俗点说,它是一款动画开源库,通过解析特定的Json文件或Json文本,可直接在移动端上渲染出复杂的动画效果。


参考图释




二、Lottie 能干什么?


它可以简便高效的实现复杂动画,替代传统低效的手写动画方式。


动画展示:



上方的动画是通过Lottie直接实现的。


如果我们使用手写代码方式实现,可以说是很难!


而通过 Lottie 实现时,需要的仅是一个Json文件或一段Json文本,并通过简洁的代码集成即可。


集成代码可能精简到如下模样:









1
2
3
4
5
6
7
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/animation_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:lottie_rawRes="@raw/anim_lottie"
app:lottie_loop="true"
app:lottie_autoPlay="true" />

三、为什么使用 Lottie?



  • 简便,开发成本低
    相对于传统方式,动画不再是全部手写,所需做得只是嵌入XML并配置动画属性,集成快,开发时间少,开发成本低。


  • 不同 Native 平台效果基本一致
    渲染交由Lottie引擎内部实现,无需开发者处理平台差异,多平台共用同一个动画配置文件,效果一致性高。


  • 占用包体积小
    Lottie配置文件由Json文本串构成,相对于图片,占用体积更小。


  • 动画效果不失真
    传统图片拉伸或压缩导致失真,而Lottie为矢量图展示,不会出现失真情况。


  • 动画效果可以云端控制
    由于Lottie动画基于Json文件或文本解析,因此可以实现云端下发,实现动态加载,动态控制动画样式。



四、如何使用 Lottie?


Lottie 仅支持用 Gradle 构建配置,最低支持安卓版本 16。


1. 添加依赖到 build.gradle









1
2
3
4
5
6
7
dependencies {
implementation 'com.airbnb.android:lottie:2.5.4'
}

dependencies {
implementation "com.airbnb.android:lottie:${全局版本变量}"
}

2. 添加 Adobe After Effects 导出的动画 Json 文件


通常由视觉工程师确认动效后通过AE导出, 我们只需将该Json文件保存至 /raw 或 /assets文件夹下。


3. XML中嵌入基本布局









1
2
3
4
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/animation_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

4. 加载播放动画,两类方式可选


① XML中静态配置, 举例:









1
2
3
4
5
6
7
8
9
10
11
12
13
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/animation_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"

//加载方式如下2种任选其一
app:lottie_rawRes="@raw/hello_world"
app:lottie_fileName="hello_world.json"

//循环播放
app:lottie_loop="true"
//加载完毕后自动播放
app:lottie_autoPlay="true" />

② 代码动态配置, 举例:


如下代码会在后台异步加载动画文件,并在加载完成后开始渲染动画。









1
2
3
4
LottieAnimationView animationView = ...;
animationView.setAnimation(R.raw.hello_world);
animationView.loop(true);
animationView.playAnimation();

五、其他拓展使用


1. 直接解析Json文本串加载动画









1
2
3
4
5
6
7
8
9
10
JsonReader jsonReader = new JsonReader(new StringReader(jsonStr));
lottieView.setAnimation(jsonReader);
lottieView.playAnimation();

Cancellable cancellable = LottieComposition.Factory.fromJsonString(jsonStr, composition -> {
lottieView.setComposition(composition);
lottieView.playAnimation();
});
//必要时取消进行中的异步操作
cancellable.cancel();

2. 配合网络下载,实现下载Json配置并动态加载









1
2
3
4
5
6
7
8
9
10
11
12
13
Call<ResponseBody> call = RetrofitComponent.fetchLottieConfig();//伪代码
call.enqueue(new Callback<ResponseBody>() {
@Override
public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) {
//String json = response.body().string();
//交由Lottie处理...
}

@Override
public void onFailure(Call<ResponseBody> call, Throwable t) {
//do something.
}
});

3. 动画加载监听器


根据业务需要进行动画过程监听:









1
2
3
4
5
6
7
8
9
10
11
animationView.addAnimatorUpdateListener((animation) -> {
// do something.
});
animationView.playAnimation();
...
if (animationView.isAnimating()) {
// do something.
}
...
animationView.setProgress(0.5f);
...

4. LottieDrawable 的使用









1
2
3
4
5
6
7
8
LottieDrawable drawable = new LottieDrawable();
LottieComposition.Factory.fromAssetFileName(getApplicationContext(), "lottie_pin_jump.json", composition -> {
drawable.setComposition(composition);
//目前显示为静态图
ivLottie.setImageDrawable(drawable);
//调用start()开始播放动画
drawable.start();
});



六、需要考虑的问题



  1. 由于是依赖于AE做的动画,估计以后不只是要求视觉设计师精通AE,连前端开发都需要了解AE

  2. Lottie 对 Json 文件的支持待完善,目前存在部分AE导出成 Json 文件无法渲染或渲染不佳

  3. 支持的功能存在限制

Github地址:https://github.com/airbnb/lottie-android

下载地址:lottie-android-master.zip

收起阅读 »

iOS timer定时器正确使用方式

1. 初始化,添加定时器前先移除[self.timer invalidate];self.timer = nil;self.timer = [NSTimer scheduledTimerWithTimeInterval:2.f target:self sele...
继续阅读 »

1. 初始化,添加定时器前先移除

[self.timer invalidate];
self.timer = nil;
self.timer = [NSTimer scheduledTimerWithTimeInterval:2.f target:self selector:@selector(lookforCard:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

2. 释放timer

[self.timer invalidate];
self.timer = nil;

3. NSTimer不释放原因

  • 原因是 Timer 添加到 Runloop 的时候,会被 Runloop 强引用;然后 Timer 又会有一个对 Target 的强引用(也就是 self )

注意target参数的描述:
The object to which to send the message specified by aSelector when the timer fires. The timer maintains a strong reference to target until it (the timer) is invalidated.
注意:文档中写的很清楚,timer对target会有一个强引用,直到timer is invalidated。也就是说,在timer调用 invalidate方法之前,timer对target一直都有一个强引用。这也是为什么控制器的dealloc 方法不会被调用的原因。
方法的文档介绍:
The receiver retains aTimer. To remove a timer from all run loop modes on which it is installed, send an invalidate message to the timer.
也就是说,runLoop会对timer有强引用,因此,timer修饰符是weak,timer还是不能释放,timer的target也就不能释放。

4. 解决办法

  • viewWillDisappear或viewDidDisappear中 invalidate
    这种方式是可以释放掉的,但如果我只是想在离开此页时要释放,进入下一页时不要释放,场景就不适用了

- (void)viewWillDisappear:(BOOL)animated
- (void)viewDidDisappear:(BOOL)animated
  • 添加一个NSTimer的分类,把target指给[NSTimer class],事件由加方法接收,然后把事件通过block传递出来

@interface NSTimer (Block)

+ (instancetype)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void(^)(NSTimer *timer))block;

@end

@implementation NSTimer (Block)

+ (instancetype)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void(^)(NSTimer *timer))block{
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:interval target:self selector:@selector(trigger:) userInfo:[block copy] repeats:repeats];
return timer;
}

+ (void)trigger:(NSTimer *)timer{
void(^block)(NSTimer *timer) = [timer userInfo];
if (block) {
block(timer);
}
}

@end
  • 使用示例

@interface SecondViewController ()

@property (nonatomic, strong) NSTimer *timer;

@end

@implementation SecondViewController

- (void)viewDidLoad {
[super viewDidLoad];
__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer * _Nonnull timer) {
[weakSelf doSomeThing];
}];
[[NSRunLoop currentRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}

- (void)dealloc {
[self.timer invalidate];
}

@end

5. invalidate方法注意事项

invalidate方法的介绍:
(1)This method is the only way to remove a timer from an NSRunLoop object. The NSRunLoop object removes its strong reference to the timer, either just before the invalidate method returns or at some later point.
(2)You must send this message from the thread on which the timer was installed. If you send this message from another thread, the input source associated with the timer may not be removed from its run loop, which could prevent the thread from exiting properly.
两点:
(1)invalidate方法是唯一能从runloop中移除timer的方式,调用invalidate方法后,runloop会移除对timer的强引用
(2)timer的添加和timer的移除(invalidate)需要在同一个线程中,否则timer可能不能正确的移除,线程不能正确退出

附:我的博客地址

链接:https://www.jianshu.com/p/a05c556f2a8a

收起阅读 »

“小家碧玉”中的UIStackView

KeyWordsAutoLayout UIStackView背景随着需求的迭代,项目中在列表的同一个区域新增业务标签貌似成了每个产品经理的“特殊嗜好”。如下图中的区域(其实本人的项目中在箭头区域大概有7个类似的标签,当然在业务上不会同时出现,能同时出现的时候最...
继续阅读 »

KeyWords
AutoLayout UIStackView

背景

随着需求的迭代,项目中在列表的同一个区域新增业务标签貌似成了每个产品经理的“特殊嗜好”。如下图中的区域


(其实本人的项目中在箭头区域大概有7个类似的标签,当然在业务上不会同时出现,能同时出现的时候最多会有四个),随着标签的增加,势必会造成繁重的视图维护工作,再加上要控制优先级之类的,估计头都大了,好在apple给咱们提供了强大的视图管理:UIStackView。我們可以透过它轻易的定义好在 UIStackView 中元件的布局,不需对于所有元件进行 AutoLayout 的约束设置,UIStackView会处理大部分的工作。

正文

apple官方文档对UIStackView的描述是:用于在列或行中布置视图集合

UIStackView要点

UIStackView是在iOS 9中引入的, 是Cocoa Touch中UI控件分类的最新成员。
通过UIStackView,你可以利用 AutoLayout 的強大功能,创建用户视图,可以动态适应设备方向,屏幕大小和可用空间任何变化的用户界面。UIStackView 管理其 arrangeSubviews 属性中所有视图的布局。這些视图基于它們在 arrangeSubviews 阵列中的順序,沿著 UIStackView 的 axis 排列。最终精确的布局依赖于 UIStackView 的 axis、distribution、alignment、spacing 以及其他属性。

我们只需要负责 UIStackView 的位置和尺寸,然后 UIStackView 就会管理其內容的布局和尺寸。

注意:放到 StackView ≠ 完成 AutoLayout

所以你还必須設定 StackView 的位置和尺寸(可選)才算是完成。StackView 只有為其 arrangeSubviews 做佈局

虽然堆栈视图允许您直接布局其内容而不直接使用“自动布局”,但仍需要使用“自动布局”来定位堆栈视图本身。通常,这意味着定位堆叠视图的至少两个相邻边缘以限定其位置。如果没有其他约束,系统将根据其内容计算堆栈视图的大小。

1、沿着堆栈视图的轴,其拟合大小等于所有排列视图的大小加上视图之间的空间的总和。

2、垂直于堆栈视图的轴,其拟合大小等于最大排列视图的大小。

3、如果堆栈视图的属性设置为,则堆栈视图的拟合大小会增加,以包含边距的空间。layoutMarginsRelativeArrangement

您可以提供其他约束来指定堆栈视图的高度,宽度或两者。在这些情况下,堆栈视图会调整其排列视图的布局和大小以填充指定区域。确切的布局根据堆栈视图的属性而有所不同。有关堆栈视图如何处理其内容的额外空间或空间不足的完整说明,请参阅和枚举。UIStackViewDistribution 、UIStackViewAlignment

您还可以基于其第一个或最后一个基线定位堆栈视图,而不是使用顶部,底部或中心Y位置。与堆栈视图的拟合大小一样,这些基线是根据堆栈视图的内容计算的。

1、水平堆栈视图返回其和方法的最高视图。如果最高视图也是堆栈视图,则返回调用结果或嵌套堆栈视图。

2、垂直堆栈视图返回其第一个排列的视图以及其最后排列的视图。如果这些视图中的任何一个也是堆栈视图,则它返回调用的结果或嵌套堆栈视图。

创建一个StackView

UILabel * main = [[UILabel alloc]init];
main.text = @"Learn More";
main.font = [UIFont boldSystemFontOfSize:28];
main.translatesAutoresizingMaskIntoConstraints = false;
main.backgroundColor = [UIColor redColor];

UILabel * sub = [[UILabel alloc]init];
sub.translatesAutoresizingMaskIntoConstraints = false;
sub.numberOfLines = 0;
sub.text = @"[self.collectionView.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:40].active = true";
sub.font = [UIFont systemFontOfSize:18];
sub.backgroundColor = [UIColor greenColor];

UILabel * third = [[UILabel alloc]init];
third.translatesAutoresizingMaskIntoConstraints = false;
third.numberOfLines = 0;
third.text = @"Object_C";
third.font = [UIFont systemFontOfSize:18];
third.backgroundColor = [UIColor brownColor];

UIStackView * stackView = [[UIStackView alloc]initWithArrangedSubviews:@[main,sub,third]];
stackView.translatesAutoresizingMaskIntoConstraints = false;
stackView.axis = UILayoutConstraintAxisVertical;
stackView.distribution = UIStackViewDistributionFillProportionally;
stackView.alignment = UIStackViewAlignmentFill;
stackView.spacing = 10;

[stackView.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:100].active = true;
[stackView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:12].active = true;
[stackView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-12].active = true;

效果图如下:


这样一个简单的堆叠视图就创建出来了,然后咱们沿着这段代码逐一分解下

Axis

配置视图的轴线,简单说就是 UIStackView 整体的排列方式(行或列)

  • UILayoutConstraintAxisVertical


  • UILayoutConstraintAxisHorizontal


Distribution

确定沿堆栈轴排列的视图的布局

這个属性算是比较难以理解的属性,所以我下面一样用特殊的图形來表示,希望大家能理解。

  • UIStackViewDistributionFill

 如同前面的 Fill 类似,一樣是把自身的范围給占满。


  • UIStackViewDistributionFillEqually
    StackView 会平均分配各個子视图的配置,例如下面的 StackView 垂直排列时,高度则会为其子视图最高的高度。反之,水平排列则取其子视图最寬的寬度。


  • UIStackViewDistributionFillProportionally
    你可能会觉得此属性与 Fill 属性沒有差异,但兩者的差异在于 Fill 会根据自身的尺寸來決定在 StackView 中的尺寸,而 Fill Proportionally 則是會根据 StackView 的寬或高按比例分配給其中的子视图。


  • UIStackViewDistributionEqualSpacing
    此属性简单來說就会根据 StackView 剩余可用空間的尺寸,來分配 StackView 子视图间的间隔。


  • UIStackViewDistributionEqualCentering
    此属性与上面的 Equal Spacing 意思类似,只是它是以其子视图中心點的中心与中心距離是相同來做為配置。


若說明得還不夠清楚可以看看這篇文章,了解當中的差异。
Alignment
确定垂直于堆栈轴的排列视图的布局

  • 在 Axis 为 Vertical 下,各个状态下的对齐方式:


  • 在 Axis 為 Horizontal 下,各个状态下的对齐方式:


Spacing

這個属性就不用多加以赘述了,就是可以自定义 StackView 的間隔大小,但是这边要注意,如果你沒有限制 StackView 的尺寸,那么就會加長或加寬 StackView。但是如果你有限制 StackView 的尺寸,那麼就會在限制尺寸下增加间隔(可能會造成跑版或失敗)。

baselineRelativeArrangement

该属性确定视图之间的垂直间距是否从基线测量。

layoutMarginsRelativeArrangement

该属性确定堆栈视图是否相对于其布局边距布置其排列的视图

注意

详情参考

从堆栈视图中删除子视图时,堆栈视图也会将其从数组中删除

从数组中删除视图不会将其作为子视图删除。堆栈视图不再管理视图的大小和位置,但视图仍然是视图层次结构的一部分,并且如果可见,则在屏幕上呈现

无论何时添加,删除或插入视图,或者每当其中一个已排列的子视图的属性发生更改时,堆栈视图都会自动更新其布局(比如hidden,更改布局方向...)

后记

堆栈视图为我们执行的自动布局计算会带来性能成本。在大多数情况下,它可以忽略不计。但是当堆栈视图嵌套超过两层时,可能会变得明显。
为了安全起见,请避免使用深层嵌套的堆栈视图,尤其是在可重用的视图(如表和集合视图单元格)中。

链接:https://www.jianshu.com/p/7920d287c13b

收起阅读 »

Swift5.0的Runtime机制浅析

导读:你想知道Swift内部对象是如何创建的吗?方法以及函数调用又是如何实现的吗?成员变量的访问以及对象内存布局又是怎样的吗?这些问题都会在这篇文章中得到解答。为了更好的让大家理解这些内部实现,我会将源代码翻译为用C语言表示的伪代码来实现。Objective-...
继续阅读 »

导读:你想知道Swift内部对象是如何创建的吗?方法以及函数调用又是如何实现的吗?成员变量的访问以及对象内存布局又是怎样的吗?这些问题都会在这篇文章中得到解答。为了更好的让大家理解这些内部实现,我会将源代码翻译为用C语言表示的伪代码来实现。

Objective-C语言是一门以C语言为基础的面向对象编程语言,其提供的运行时(Runtime)机制使得它也可以被认为是一种动态语言。运行时的特征之一就是对象方法的调用是在程序运行时才被确定和执行的。系统提供的开放接口使得我们可以在程序运行的时候执行方法替换以便实现一些诸如系统监控、对象行为改变、Hook等等的操作处理。然而这种开放性也存在着安全的隐患,我们可以借助Runtime在AOP层面上做一些额外的操作,而这些额外的操作因为无法进行管控, 所以有可能会输出未知的结果。

可能是苹果意识到了这个问题,所以在推出的Swift语言中Runtime的能力得到了限制,甚至可以说是取消了这个能力,这就使得Swift成为了一门静态语言。Swift语言中对象的方法调用机制和OC语言完全不同,Swift语言的对象方法调用基本上是在编译链接时刻就被确定的,可以看做是一种硬编码形式的调用实现。

Swfit中的对象方法调用机制加快了程序的运行速度,同时减少了程序包体积的大小。但是从另外一个层面来看当编译链接优化功能开启时反而又会出现包体积增大的情况。Swift在编译链接期间采用的是空间换时间的优化策略,是以提高运行速度为主要优化考虑点。具体这些我会在后面详细谈到。

通过程序运行时汇编代码分析Swift中的对象方法调用,发现其在Debug模式下和Release模式下的实现差异巨大。其原因是在Release模式下还同时会把编译链接优化选项打开。因此更加确切的说是在编译链接优化选项开启与否的情况下二者的实现差异巨大。

在这之前先介绍一下OC和Swift两种语言对象方法调用的一般实现。

OC类的对象方法调用

对于OC语言来说对象方法调用的实现机制有很多文章都进行了深入的介绍。所有OC类中定义的方法函数的实现都隐藏了两个参数:一个是对象本身,一个是对象方法的名称。每次对象方法调用都会至少传递对象和对象方法名称作为开始的两个参数,方法的调用过程都会通过一个被称为消息发送的C函数objc_msgSend来完成。objc_msgSend函数是OC对象方法调用的总引擎,这个函数内部会根据第一个参数中对象所保存的类结构信息以及第二个参数中的方法名来找到最终要调用的方法函数的地址并执行函数调用。这也是OC语言Runtime的实现机制,同时也是OC语言对多态的支持实现。整个流程就如下表述:


Swift类的对象创建和销毁

在Swift中可以定义两种类:一种是从NSObject或者派生类派生的类,一类是从系统Swift基类SwiftObject派生的类。对于后者来说如果在定义类时没有指定基类则默认会从基类SwiftObject派生。SwiftObject是一个隐藏的基类,不会在源代码中体现。

Swift类对象的内存布局和OC类对象的内存布局相似。二者对象的最开始部分都有一个isa成员变量指向类的描述信息。Swift类的描述信息结构继承自OC类的描述信息,但是并没有完全使用里面定义的属性,对于方法的调用则主要是使用其中扩展了一个所谓的虚函数表的区域,关于这部分会在后续中详细介绍。

Swift类的对象实例都是在堆内存中创建,这和OC语言的对象实例创建方式相似。系统会为类提供一个默认的init构造函数,如果想自定义构造函数则需要重写和重载init函数。一个Swift类的对象实例的构建分为两部分:首先是进行堆内存的分配,然后才是调用init构造函数。在源代码编写中不会像OC语言那样明确的分为alloc和init两个分离的调用步骤,而是直接采用:类名(初始化参数)这种方式来完成对象实例的创建。在编译时系统会为每个类的初始化方法生成一个:模块名.类名.__allocating_init(类名,初始化参数)的函数,这个函数的伪代码实现如下:

//假设定义了一个CA类。
class CA {
init(_ a:Int){}
}
//编译生成的对象内存分配创建和初始化函数代码
CA * XXX.CA.__allocating_init(swift_class classCA, int a)
{
CA *obj = swift_allocObject(classCA); //分配内存。
obj->init(a); //调用初始化函数。
}

//编译时还会生成对象的析构和内存销毁函数代码
XXX.CA.__deallocating_deinit(CA *obj)
{
obj->deinit() //调用析构函数
swift_deallocClassInstance(obj); //销毁对象分配的内存。
}

其中的swift_class 就是从objc_class派生出来,用于描述类信息的结构体。

Swift对象的生命周期也和OC对象的生命周期一样是通过引用计数来进行控制的。当对象初次创建时引用计数被设置为1,每次进行对象赋值操作都会调用swift_retain函数来增加引用计数,而每次对象不再被访问时都会调用swift_release函数来减少引用计数。当引用计数变为0后就会调用编译时为每个类生成的析构和销毁函数:模块名.类名.__deallocating_deinit(对象)。这个函数的定义实现在前面有说明。

这就是Swift对象的创建和销毁以及生命周期的管理过程,这些C函数都是在编译链接时插入到代码中并形成机器代码的,整个过程对源代码透明。下面的例子展示了对象创建和销毁的过程。

////////Swift源代码

let obj1:CA = CA(20);
let obj2 = obj1
///////C伪代码

CA *obj1 = XXX.CA. __allocating_init(classCA, 20);
CA *obj2 = obj1;
swift_retain(obj1);
swift_release(obj1);
swift_release(obj2);

swift_release函数内部会在引用计数为0时调用模块名.类名.__deallocating_deinit(对象)函数进行对象的析构和销毁。这个函数的指针保存在swift类描述信息结构体中,以便swift_release函数内部能够访问得到。

Swift类的对象方法调用

Swift语言中对象的方法调用的实现机制和C++语言中对虚函数调用的机制是非常相似的。(需要注意的是我这里所说的调用实现只是在编译链接优化选项开关在关闭的时候是这样的,在优化开关打开时这个结论并不正确)。

Swift语言中类定义的方法可以分为三种:OC类的派生类并且重写了基类的方法、extension中定义的方法、类中定义的常规方法。针对这三种方法定义和实现,系统采用的处理和调用机制是完全不一样的。

OC类的派生类并且重写了基类的方法

如果在Swift中的使用了OC类,比如还在使用的UIViewController、UIView等等。并且还重写了基类的方法,比如一定会重写UIViewController的viewDidLoad方法。对于这些类的重写的方法定义信息还是会保存在类的Class结构体中,而在调用上还是采用OC语言的Runtime机制来实现,即通过objc_msgSend来调用。而如果在OC派生类中定义了一个新的方法的话则实现和调用机制就不会再采用OC的Runtime机制来完成了,比如说在UIView的派生类中定义了一个新方法foo,那么这个新方法的调用和实现将与OC的Runtime机制没有任何关系了! 它的处理和实现机制会变成我下面要说到的第三种方式。下面的Swift源代码以及C伪代码实现说明了这个情况:

////////Swift源代码

//类定义
class MyUIView:UIView {
open func foo(){} //常规方法
override func layoutSubviews() {} //重写OC方法
}

func main(){
let obj = MyUIView()
obj.layoutSubviews() //调用OC类重写的方法
obj.foo() //调用常规的方法。
}
////////C伪代码

//...........................................运行时定义部分

//OC类的方法结构体
struct method_t {
SEL name;
IMP imp;
};

//Swift类描述
struct swift_class {
... //其他的属性,因为这里不关心就不列出了。
struct method_t methods[1];
... //其他的属性,因为这里不关心就不列出了。
//虚函数表刚好在结构体的第0x50的偏移位置。
IMP vtable[1];
};


//...........................................源代码中类的定义和方法的定义和实现部分

//类定义
struct MyUIView {
struct swift_class *isa;
}

//类的方法函数的实现
void layoutSubviews(id self, SEL _cmd){}
void foo(){} //Swift类的常规方法中和源代码的参数保持一致。

//类的描述信息构建,这些都是在编译代码时就明确了并且保存在数据段中。
struct swift_class classMyUIView;
classMyUIView.methods[0] = {"layoutSubviews", &layoutSubviews};
classMyUIView.vtable[0] = {&foo};


//...........................................源代码中程序运行的部分

void main(){
MyUIView *obj = MyUIView.__allocating_init(classMyUIView);
obj->isa = &classMyUIView;
//OC类重写的方法layoutSubviews调用还是用objc_msgSend来实现
objc_msgSend(obj, @selector(layoutSubviews);
//Swift方法调用时对象参数被放到x20寄存器中
asm("mov x20, obj");
//Swift的方法foo调用采用间接调用实现
obj->isa->vtable[0]();
}

extension中定义的方法

如果是在Swift类的extension中定义的方法(重写OC基类的方法除外)。那么针对这个方法的调用总是会在编译时就决定,也就是说在调用这类对象方法时,方法调用指令中的函数地址将会以硬编码的形式存在。在extension中定义的方法无法在运行时做任何的替换和改变!而且方法函数的符号信息都不会保存到类的描述信息中去。这也就解释了在Swift中派生类无法重写一个基类中extension定义的方法的原因了。因为extension中的方法调用是硬编码完成,无法支持多态!下面的Swift源代码以及C伪代码实现说明了这个情况:

////////Swift源代码

//类定义
class CA {
open func foo(){}
}

//类的extension定义
extension CA {
open func extfoo(){}
}

func main() {
let obj = CA()
obj.foo()
obj.extfoo()
}
////////C伪代码

//...........................................运行时定义部分


//Swift类描述。
struct swift_class {
... //其他的属性,因为这里不关心就不列出了。
//虚函数表刚好在结构体的第0x50的偏移位置。
IMP vtable[1];
};


//...........................................源代码中类的定义和方法的定义和实现部分


//类定义
struct CA {
struct swift_class *isa;
}

//类的方法函数的实现定义
void foo(){}
//类的extension的方法函数实现定义
void extfoo(){}

//类的描述信息构建,这些都是在编译代码时就明确了并且保存在数据段中。
//extension中定义的函数不会保存到虚函数表中。
struct swift_class classCA;
classCA.vtable[0] = {&foo};


//...........................................源代码中程序运行的部分

void main(){
CA *obj = CA.__allocating_init(classCA)
obj->isa = &classCA;
asm("mov x20, obj");
//Swift中常规方法foo调用采用间接调用实现
obj->isa->vtable[0]();
//Swift中extension方法extfoo调用直接硬编码调用,而不是间接调用实现
extfoo();
}

需要注意的是extension中是可以重写OC基类的方法,但是不能重写Swift类中的定义的方法。具体原因根据上面的解释就非常清楚了。

类中定义的常规方法

如果是在Swift中定义的常规方法,方法的调用机制和C++中的虚函数的调用机制是非常相似的。Swift为每个类都建立了一个被称之为虚表的数组结构,这个数组会保存着类中所有定义的常规成员方法函数的地址。每个Swift类对象实例的内存布局中的第一个数据成员和OC对象相似,保存有一个类似isa的数据成员。isa中保存着Swift类的描述信息。对于Swift类的类描述结构苹果并未公开(也许有我并不知道),类的虚函数表保存在类描述结构的第0x50个字节的偏移处,每个虚表条目中保存着一个常规方法的函数地址指针。每一个对象方法调用的源代码在编译时就会转化为从虚表中取对应偏移位置的函数地址来实现间接的函数调用。下面是对于常规方法的调用Swift语言源代码和C语言伪代码实现:

////////Swift源代码

//基类定义
class CA {
open func foo1(_ a:Int){}
open func foo1(_ a:Int, _ b:Int){}
open func foo2(){}
}

//扩展
extension CA{
open func extfoo(){}
}

//派生类定义
class CB:CA{
open func foo3(){}
override open func foo1(_ a:Int){}
}

func testfunc(_ obj:CA){
obj.foo1(10)
}

func main() {
let objA = A()
objA.foo1(10)
objA.foo1(10,20)
objA.foo2()
objA.extfoo()

let objB = B()
objB.foo1(10)
objB.foo1(10,20)
objB.foo2()
objB.foo3()
objB.extfoo()

testfunc(objA)
testfunc(objB)
}
////////C伪代码

//...........................................运行时定义部分

//Swift类描述。
struct swift_class {
... //其他的属性,因为这里不关心就不列出了
//虚函数表刚好在结构体的第0x50的偏移位置。
IMP vtable[0];
};


//...........................................源代码中类的定义和方法的定义和实现部分


//基类定义
struct CA {
struct swift_class *isa;
};

//派生类定义
struct CB {
struct swift_class *isa;
};

//基类CA的方法函数的实现,这里对所有方法名都进行修饰命名
void _$s3XXX2CAC4foo1yySiF(int a){} //CA类中的foo1
void _$s3XXX2CAC4foo1yySi_SitF(int a, int b){} //CA类中的两个参数的foo1
void _$s3XXX2CAC4foo2yyF(){} //CA类中的foo2
void _$s3XXX2CAC6extfooyyF(){} //CA类中的extfoo函数

//派生类CB的方法函数的实现。
void _$s3XXX2CBC4foo1yySiF(int a){} //CB类中的foo1,重写了基类的方法,但是名字不一样了。
void _$s3XXX2CBC4foo3yyF(){} //CB类中的foo3

//构造基类的描述信息以及虚函数表
struct swift_class classCA;
classCA.vtable[3] = {&_$s3XXX2CAC4foo1yySiF, &_$s3XXX2CAC4foo1yySi_SitF, &_$s3XXX2CAC4foo2yyF};

//构造派生类的描述信息以及虚函数表,注意这里虚函数表会将基类的函数也添加进来而且排列在前面。
struct swift_class classCB;
classCB.vtable[4] = {&_$s3XXX2CBC4foo1yySiF, &_$s3XXX2CAC4foo1yySi_SitF, &_$s3XXX2CAC4foo2yyF, &_$s3XXX2CBC4foo3yyF};

void testfunc(A *obj){
obj->isa->vtable[0](10); //间接调用实现多态的能力。
}


//...........................................源代码中程序运行的部分

void main(){
CA *objA = CA.__allocating_init(classCA);
objA->isa = &classCA;
asm("mov x20, objA")
objA->isa->vtable[0](10);
objA->isa->vtable[1](10,20);
objA->isa->vtable[2]();
_$s3XXX2CAC6extfooyyF()

CB *objB = CB.__allocating_init(classCB);
objB->isa = &classCB;
asm("mov x20, objB");
objB->isa->vtable[0](10);
objB->isa->vtable[1](10,20);
objB->isa->vtable[2]();
objB->isa->vtable[3]();
_$s3XXX2CAC6extfooyyF();

testfunc(objA);
testfunc(objB);

}

从上面的代码中可以看出一些特点:

1、Swift类的常规方法中不会再有两个隐藏的参数了,而是和字面定义保持一致。那么问题就来了,方法调用时对象如何被引用和传递呢?在其他语言中一般情况下对象总是会作为方法的第一个参数,在编译阶段生成的机器码中,将对象存放在x0这个寄存器中(本文以arm64体系结构为例)。而Swift则不同,对象不再作为第一个参数来进行传递了,而是在编译阶段生成的机器码中,将对象存放在x20这个寄存器中(本文以arm64体系结构为例)。这样设计的一个目的使得代码更加安全。

2、每一个方法调用都是通过读取方法在虚表中的索引获取到了方法函数的真实地址,然后再执行间接调用。在这个过程虚表索引的值是在编译时就确定了,因此不再需要通过方法名来在运行时动态的去查找真实的地址来实现函数调用了。虽然索引的位置在编译时确定的,但是基类和派生类虚表中相同索引处的函数的地址确可以不一致,当派生类重写了父类的某个方法时,因为会分别生成两个类的虚表,在相同索引位置保存不同的函数地址来实现多态的能力。

3、每个方法函数名字都和源代码中不一样了,原因在于在编译链接是系统对所有的方法名称进行了重命名处理,这个处理称为命名修饰。之所以这样做是为了解决方法重载和运算符重载的问题。因为源代码中重载的方法函数名称都一样只是参数和返回类型不一样,因此无法简单的通过名字进行区分,而只能对名字进行修饰重命名。另外一个原因是Swift还提供了命名空间的概念,也就是使得可以支持不同模块之间是可以存在相同名称的方法或者函数。因为整个重命名中是会带上模块名称的。下面就是Swift中对类的对象方法的重命名修饰规则:
_$s<模块名长度><模块名><类名长度><类名>C<方法名长度><方法名>yy<参数类型1>_<参数类型2>_<参数类型N>F

就比如上面的CA类中的foo1两个同名函数在编译链接时刻就会被分别重命名为:

//这里面的XXX就是你工程模块的名称。
void _$s3XXX2CAC4foo1yySiF(int a){} //CA类中的foo1
void _$s3XXX2CAC4foo1yySi_SitF(int a, int b){} //CA类中的两个参数的foo1

下面这张图就清晰的描述了Swift类的对象方法调用以及类描述信息。


Swift类中成员变量的访问

虽然说OC类和Swift类的对象内存布局非常相似,每个对象实例的开始部分都是一个isa数据成员指向类的描述信息,而类中定义的属性或者变量则一般会根据定义的顺序依次排列在isa的后面。OC类还会为所有成员变量,生成一张变量表信息,变量表的每个条目记录着每个成员变量在对象内存中的偏移量。这样在访问对象的属性时会通过偏移表中的偏移量来读取偏移信息,然后再根据偏移量来读取或设置对象的成员变量数据。在每个OC类的get和set两个属性方法的实现中,对于属性在类中的偏移量值的获取都是通过硬编码来完成,也就是说是在编译链接时刻决定的。

对于Swift来说,对成员变量的访问得到更加的简化。系统会对每个成员变量生成get/set两个函数来实现成员变量的访问。系统不会再为类的成员变量生成变量偏移信息表,因此对于成员变量的访问就是直接在编译链接时确定成员变量在对象的偏移位置,这个偏移位置是硬编码来确定的。下面展示Swift源代码和C伪代码对数据成员访问的实现:

////////Swift源代码

class CA
{
var a:Int = 10
var b:Int = 20
}

void main()
{
let obj = CA()
obj.b = obj.a
}
////////C伪代码

//...........................................运行时定义部分

//Swift类描述。
struct swift_class {
... //其他的属性,因为这里不关心就不列出了
//虚函数表刚好在结构体的第0x50的偏移位置。
IMP vtable[4];
};


//...........................................源代码中类的定义和方法的定义和实现部分

//CA类的结构体定义也是CA类对象在内存中的布局。
struct CA
{
struct swift_class *isa;
long reserve; //这里的值目前总是2
int a;
int b;
};

//类CA的方法函数的实现。
int getA(){
struct CA *obj = x20; //取x20寄存器的值,也就是对象的值。
return obj->a;
}
void setA(int a){
struct CA *obj = x20; //取x20寄存器的值,也就是对象的值。
obj->a = a;
}
int getB(){
struct CA *obj = x20; //取x20寄存器的值,也就是对象的值。
return obj->b;
}
void setB(int b){
struct CA *obj = x20; //取x20寄存器的值,也就是对象的值。
obj->b = b;
}

struct swift_class classCA;
classCA.vtable[4] = {&getA,&setA,&getB, &setB};


//...........................................源代码中程序运行的部分

void main(){
CA *obj = CA.__allocating_init(classCA);
obj->isa = &classCA;
obj->reserve = 2;
obj->a = 10;
obj->b = 20;
asm("mov x20, obj");
obj->isa->vtable[3](obj->isa->vtable[0]()); // obj.b = obj.a的实现
}

从上面的代码可以看出,Swift类会为每个定义的成员变量都生成一对get/set方法并保存到虚函数表中。所有对对象成员变量的方法的代码都会转化为通过虚函数表来执行get/set相对应的方法。 下面是Swift类中成员变量的实现和内存结构布局图:


结构体中的方法

在Swift结构体中也可以定义方法,因为结构体的内存结构中并没有地方保存结构体的信息(不存在isa数据成员),因此结构体中的方法是不支持多态的,同时结构体中的所有方法调用都是在编译时硬编码来实现的。这也解释了为什么结构体不支持派生,以及结构体中的方法不支持override关键字的原因。

类的方法以及全局函数

Swift类中定义的类方法和全局函数一样,因为不存在对象作为参数,因此在调用此类函数时也不会存在将对象保存到x20寄存器中这么一说。同时源代码中定义的函数的参数在编译时也不会插入附加的参数。Swift语言会对所有符号进行重命名修饰,类方法和全局函数也不例外。这也就使得全局函数和类方法也支持名称相同但是参数不同的函数定义。简单的说就是类方法和全局函数就像C语言的普通函数一样被实现和定义,所有对类方法和全局函数的调用都是在编译链接时刻硬编码为函数地址调用来处理的。

OC调用Swift类中的方法

如果应用程序是通过OC和Swift两种语言混合开发完成的。那就一定会存在着OC语言代码调用Swift语言代码以及相反调用的情况。对于Swift语言调用OC的代码的处理方法是系统会为工程建立一个桥声明头文件:项目工程名-Bridging-Header.h,所有Swift需要调用的OC语言方法都需要在这个头文件中声明。而对于OC语言调用Swift语言来说,则有一定的限制。因为Swift和OC的函数调用ABI规则不相同,OC语言只能创建Swift中从NSObject类中派生类对象,而方法调用则只能调用原NSObject类以及派生类中的所有方法以及被声明为@objc关键字的Swift对象方法。如果需要在OC语言中调用Swift语言定义的类和方法,则需要在OC语言文件中添加:#import "项目名-Swift.h"。当某个Swift方法被声明为@objc关键字时,在编译时刻会生成两个函数,一个是本体函数供Swift内部调用,另外一个是跳板函数(trampoline)是供OC语言进行调用的。这个跳板函数信息会记录在OC类的运行时类结构中,跳板函数的实现会对参数的传递规则进行转换:把x0寄存器的值赋值给x20寄存器,然后把其他参数依次转化为Swift的函数参数传递规则要求,最后再执行本地函数调用。整个过程的实现如下:

////////Swift源代码

//Swift类定义
class MyUIView:UIView {
@objc
open func foo(){}
}

func main() {
let obj = MyUIView()
obj.foo()
}

//////// OC源代码
#import "工程-Swift.h"

void main() {
MyUIView *obj = [MyUIView new];
[obj foo];
}
////////C伪代码

//...........................................运行时定义部分

//OC类的方法结构体
struct method_t {
SEL name;
IMP imp;
};

//Swift类描述
struct swift_class {
... //其他的属性,因为这里不关心就不列出了。
struct method_t methods[1];
... //其他的属性,因为这里不关心就不列出了。
//虚函数表刚好在结构体的第0x50的偏移位置。
IMP vtable[1];
};

//...........................................源代码中类的定义和方法的定义和实现部分

//类定义
struct MyUIView {
struct swift_class *isa;
}

//类的方法函数的实现

//本体函数foo的实现
void foo(){}
//跳板函数的实现
void trampoline_foo(id self, SEL _cmd){
asm("mov x20, x0");
self->isa->vtable[0](); //这里调用本体函数foo
}

//类的描述信息构建,这些都是在编译代码时就明确了并且保存在数据段中。
struct swift_class classMyUIView;
classMyUIView.methods[0] = {"foo", &trampoline_foo};
classMyUIView.vtable[0] = {&foo};


//...........................................源代码中程序运行的部分

//Swift代码部分
void main()
{
MyUIView *obj = MyUIView.__allocating_init(classMyUIView);
obj->isa = &classMyUIView;
asm("mov x20, obj");
//Swift方法foo的调用采用间接调用实现。
obj->isa->vtable[0]();
}

//OC代码部分
void main()
{
MyUIView *obj = objc_msgSend(objc_msgSend(classMyUIView, "alloc"), "init");
obj->isa = &classMyUIView;
//OC语言对foo的调用还是用objc_msgSend来执行调用。
//因为objc_msgSend最终会找到methods中的方法结构并调用trampoline_foo
//而trampoline_foo内部则直接调用foo来实现真实的调用。
objc_msgSend(obj, @selector(foo));
}

下面的图形展示了Swift中带@objc关键字的方法实现,以及OC语言调用Swift对象方法的实现:


Swift类方法的运行时替换实现的可行性

从上面的介绍中我们已经了解到了Swift类的常规方法定义和调用实现的机制,同样了解到Swift对象实例的开头部分也有和OC类似的isa数据,用来指向类的信息结构。一个令人高兴的事情就是Swift类的结构定义部分是存放在可读写的数据段中,这似乎给了我们一个提示是说可以在运行时通过修改一个Swift类的虚函数表的内容来达到运行时对象行为改变的能力。要实现这种机制有三个难点需要解决:

1、一个是Swift对内存和指针的操作进行了极大的封装,同时Swift中也不再支持简单直接的对内存进行操作的机制了。这样就使得我们很难像OC那样直接修改类结构的内存信息来进行运行时的更新处理,因为Swift不再公开运行时的相关接口了。虽然可以将方法函数名称赋值给某个变量,但是这个变量的值并非是类方法函数的真实地址,而是一个包装函数的地址。

2、第二个就是Swift中的类方法调用和参数传递的ABI规则和其他语言不一致。在OC类的对象方法中,对象是作为方法函数的第一个参数传递的。在机器指令层面以arm64体系结构为例,对象是保存在x0寄存器作为参数进行传递。而在Swift的对象方法中这个规则变为对象不再作为第一个参数传递了,而是统一改为通过寄存器x20来进行传递。需要明确的是这个规则不会针对普通的Swift函数。因此当我们想将一个普通的函数来替换类定义的对象方法实现时就几乎变得不太可能了,除非借助一些OC到Swift的桥的技术和跳板技术来实现这个功能也许能够成功。

当然我们也可以通过为类定义一个extension方法,然后将这个extension方法函数的指针来替换掉虚函数表中类的某个原始方法的函数指针地址,这样能够解决对象作为参数传递的寄存器的问题。但是这里仍然需要面临两个问题:一是如何获取得到extension中的方法函数的地址,二是在替换完成后如何能在合适的时机调用原始的方法。

3、第三是Swift语言将不再支持内嵌汇编代码了,所以我们很难在Swift中通过汇编来写一些跳板程序了。

因为Swift具有比较强的静态语言的特性,外加上函数调用的规则特点使得我们很难在运行时进行对象方法行为的改变。还有一个非常大的因素是当编译链接优化开关打开时,上述的对象方法调用规则还将进一步被打破,这样就导致我们在运行时进行对象方法行为的替换变得几乎不可能或者不可行。

编译链接优化开启后的Swift方法定义和调用

一个不幸的事实是,当我们开启了编译链接的优化选项后,Swift的对象方法的调用机制做了非常大的改进。最主要的就是进一步弱化了通过虚函数表来进行间接方法调用的实现,而是大量的改用了一些内联的方式来处理方法函数调用。同时对多态的支持也采用了一些别的策略。具体用了如下一些策略:

1、大量的将函数实现换成了内联函数模式,也就是对于大部分类中定义的源代码比较少的方法函数都统一换成内联。这样对象方法的调用将不再通过虚函数表来间接调用,而是简单粗暴的将函数的调用改为直接将内联函数生成的机器码进行拷贝处理。这样的一个好处就是由于没有函数调用的跳转指令,而是直接执行方法中定义的指令,从而极大的加速了程序的运行速度。另外一个就是使得整个程序更加安全,因为此时函数的实现逻辑已经散布到各处了,除非恶意修改者改动了所有的指令,否则都只会影响局部程序的运行。内联的一个的缺点就是使得整个程序的体积会增大很多。比如下面的类代码在优化模式下的Swift语言源代码和C语言伪代码实现:

////////Swift源代码

//类定义
class CA {
open func foo(_ a:Int, _ b:Int) ->Int {
return a + b
}

func main() {
let obj = CA()
let a = obj.foo(10,20)
let b = obj.foo(a, 40)
}
////////C伪代码


//...........................................运行时定义部分


//Swift类描述。
struct swift_class {
... //其他的属性,因为这里不关心就不列出了
//这里也没有虚表的信息。
};

//...........................................源代码中类的定义和方法的定义和实现部分


//类定义
struct CA {
struct swift_class *isa;
};

//这里没有方法实现,因为短方法被内联了。

struct swift_class classCA;


//...........................................源代码中程序运行的部分


void main() {
CA *obj = CA.__allocating_init(classCA);
obj->isa = &classCA;
int a = 10 + 20; //代码被内联优化
int b = a + 40; //代码被内联优化
}

2、就是对多态的支持,也可能不是通过虚函数来处理了,而是通过类型判断采用条件语句来实现方法的调用。就比如下面Swift语言源代码和C语言伪代码:

////////Swift源代码

//基类
class CA{
@inline(never)
open func foo(){}
}

//派生类
class CB:CA{
@inline(never)
override open func foo(){}
}

//全局函数接收对象作为参数
@inline(never)
func testfunc(_ obj:CA){
obj.foo()
}


func main() {
//对象的创建以及方法调用
let objA = CA()
let objB = CB()
testfunc(objA)
testfunc(objB)
}
////////C伪代码

//...........................................运行时定义部分


//Swift类描述
struct swift_class {
... //其他的属性,因为这里不关心就不列出了
//这里也没有虚表的信息。
};


//...........................................源代码中类的定义和方法的定义和实现部分

//类定义
struct CA {
struct swift_class *isa;
};

struct CB {
struct swift_class *isa;
};

//Swift类的方法的实现
//基类CA的foo方法实现
void fooForA(){}
//派生类CB的foo方法实现
void fooForB(){}
//全局函数方法的实现
void testfunc(CA *obj)
{
//这里并不是通过虚表来进行间接调用而实现多态,而是直接硬编码通过类型判断来进行函数调用从而实现多态的能力。
asm("mov x20, obj");
if (obj->isa == &classCA)
fooForA();
else if (obj->isa == &classCB)
fooForB();
}

//类的描述信息构建,这些都是在编译代码时就明确了并且保存在数据段中。
struct swift_class classCA;
struct swift_class classCB;

//...........................................源代码中程序运行的部分

void main() {
//对象实例创建以及方法调用的代码。
CA *objA = CA.__allocating_init(classCA);
objA->isa = &classCA;
CB *objB = CB.__allocating_init(classCB);
objB->isa = &classCB;
testfunc(objA);
testfunc(objB);
}

也许你会觉得这不是一个最优的解决方案,而且如果当再次出现一个派生类时,还会继续增加条件分支的判断。 这是一个多么低级的优化啊!但是为什么还是要这么做呢?个人觉得还是性能和包大小的问题。对于性能来说如果我们通过间接调用的形式可能需要增加更多的指令以及进行间接的寻址处理和指令跳转,而如果采用简单的类型判断则只需要更少的指令就可以解决多态调用的问题了,这样性能就会得到提升。至于第二个包大小的问题这里有必要重点说一下。

编译链接优化的一个非常重要的能力就是减少程序的体积,其中一个点即是链接时如果发现某个一个函数没有被任何地方调用或者引用,链接器就会把这个函数的实现代码整体删除掉。这也是符合逻辑以及正确的优化方式。回过头来Swift函数调用的虚函数表方式,因为根据虚函数表的定义需要把一个类的所有方法函数地址都存放到类的虚函数表中,而不管类中的函数是否有被调用或者使用。而通过虚函数表的形式间接调用时是无法在编译链接时明确哪个函数是否会被调用的,所以当采用虚函数表时就不得不把类中的所有方法的实现都链接到可执行程序中去,这样就有可能无形中增加了程序的体积。而前面提供的当编译链接优化打开后,系统尽可能的对对象的方法调用改为内联,同时对多态的支持改为根据类型来进行条件判断处理,这样就可以减少对虚函数表的使用,一者加快了程序运行速度,二者删除了程序中那些永远不会调用的代码从而减少程序包的体积。但是这种减少包体积的行为又因为内联的引入也许反而增加了程序包的体积。而这二者之间的平衡对于链接优化器是如何决策的我们就不得而知了。

综上所述,在编译器优化模式下虚函数调用的间接模式改变为直接模式了,所以我们几乎很难在运行时通过修改虚表来实现方法调用的替换。而且Swift本身又不再支持运行时从方法名到方法实现地址的映射处理,所有的机制都是在编译时静态决定了。正是因为Swift语言的特性,使得原本在OC中可以做的很多事情在Swift中都难以实现,尤其是一些公司的无痕埋点日志系统的建设,APM的建设,以及各种监控系统的建设,以及模拟系统的建设都将失效,或者说需要寻找另外一些途径去做这些事情。对于这些来说,您准备好了吗?

链接:https://www.jianshu.com/p/158574ab8809

收起阅读 »

iOS的异步处理神器——Promises

前言你是否因为多任务的依赖而头疼?你是否被一个个嵌套的block回调弄得晕头转向?快来投入Promises的怀抱吧。正文回调任务是很正常的现象,比如说购买一个商品,需要下单,然后等后台返回。单一任务,通常只需要一个block,非常清晰;以上面的下单为例,传给网...
继续阅读 »

前言

你是否因为多任务的依赖而头疼?你是否被一个个嵌套的block回调弄得晕头转向?
快来投入Promises的怀抱吧。

正文

回调任务是很正常的现象,比如说购买一个商品,需要下单,然后等后台返回。
单一任务,通常只需要一个block,非常清晰;
以上面的下单为例,传给网络层一个block,购买完成之后回调即可。

但是出现多个任务的时候,逻辑就开始有分支,同样以购买商品为例,在下单完成后,需要和SDK发起支付,然后根据支付结果再进行一些提示:
任务1是下单,执行完回调error指针(或者状态码)表示完成状态,同时待会下单信息,此时产生一个分支,成功继续下一步,失败执行错误block;
然后是执行任务2购买,执行异步的支付,根据支付结果又会产生一个分支。

当连续的任务超过2个之后,分支会导致代码逻辑非常混乱。


简单画一个流程图来分析,上述的逻辑变得复杂的原因是因为每一级的block需要处理下一级block的失败情况,导致逻辑分支的增多。

其实所有的失败处理都是类似的:打日志、提示用户,可以放在一起统一处理。
然后把任务一、任务二等串行执行,流程就非常清晰。


Promises就是用来辅助实现这样设计的库。
实现的代码效果如下:

- (void)workflow {
[[[[self order:@"order_id"] then:^id _Nullable(NSString * _Nullable value) {
return [self pay:value];
}] then:^id _Nullable(id _Nullable value) {
return [self check:value];
}] catch:^(NSError * _Nonnull error) {
NSLog(@"error: %@", error);
}];
}

Promises的使用

Promises库的引入非常简单,可以使用CocoaPod,Podfile如下:

pod 'PromisesObjC'

也可以到GitHub手动下载。

按照Promise设计模式的规范,每一个Promise应该有三种状态:pending(等待)、fulfilled(完成)、rejected(失败);
对应到Promises分别是:

[FBLPromise pendingPromise]; // pending等待
[FBLPromise resolvedWith:@"anyString"]; // fulfilled完成
[FBLPromise resolvedWith:[NSError new]]; // rejected失败

实际使用中,我们更多使用的Promises库已经提供好的便捷函数:

启动一个异步任务 :

[FBLPromise onQueue:dispatch_get_main_queue()
async:^(FBLPromiseFulfillBlock fulfill,
FBLPromiseRejectBlock reject) {
BOOL success = arc4random() % 2;
if (success) {
fulfill(@"success");
}
else {
reject([NSError errorWithDomain:@"learn_promises_error" code:-1 userInfo:nil]);
}
}];

或者简单使用do方法:

[FBLPromise do:^id _Nullable{
BOOL success = random() % 2;
if (success) {
return @"success";
}
else {
return [NSError errorWithDomain:@"learn_promises_error" code:-1 userInfo:nil];
}
}];

不管是async方法还是do方法,他们的返回值都是创建一个Promise对象,可以在Promise对象后面挂一个then方法,表示这个Promise执行完毕之后,要继续执行的任务:

[[[FBLPromise do:^id _Nullable{
BOOL success = arc4random() % 2;
return success ? @"do_success" : [NSError errorWithDomain:@"learn_promises_do_error" code:-1 userInfo:nil];
}] then:^id _Nullable(id _Nullable value) {
BOOL success = arc4random() % 2;
return success ? @"then_success" : [NSError errorWithDomain:@"learn_promises_then_error" code:-1 userInfo:nil];
}] catch:^(NSError * _Nonnull error) {
NSLog(@"error: %@", error);
}];

上面的catch方法表示统一的error处理。
promise在完成任务之后,如果满足下面的条件会调用then的方法:
1、直接调用fulfill;
2、在do方法中返回一个值(不能为error);
3、在then方法中返回一个值;

调用reject方法或者返回一个NSError对象,都会转到catch方法处理。

用上面的do、then、catch方法组合,就完成多个异步任务的依赖执行:

- (void)workflow {
[[[[self order:@"order_id"] then:^id _Nullable(NSString * _Nullable value) {
return [self pay:value];
}] then:^id _Nullable(id _Nullable value) {
return [self check:value];
}] catch:^(NSError * _Nonnull error) {
NSLog(@"error: %@", error);
}];
}

- (FBLPromise<NSString *> *)order:(NSString *)orderParam {
return [FBLPromise do:^id _Nullable{
return @"order_success";
}];
}

- (FBLPromise<NSString *> *)pay:(NSString *)payParam {
return [FBLPromise do:^id _Nullable{
BOOL success = arc4random() % 2;
return success ? @"pay_success" : [NSError errorWithDomain:@"pay_error" code:-1 userInfo:nil];
}];
}

- (FBLPromise<NSString *> *)check:(NSString *)checkParam {
return [FBLPromise do:^id _Nullable{
return @"check success";
}];
}

Promises还提供了很多附加特性,以All和Any为例:
All是所有Promise都fulfill才算完成;
Any是任何一个Promise完成都会执行fulfill;

- (void)testAllAndAny {
NSMutableArray *arr = [NSMutableArray new];
[arr addObject:[self work1]];
[arr addObject:[self work2]];

[[[FBLPromise all:arr] then:^id _Nullable(NSArray * _Nullable value) {
NSLog(@"then, value:%@", value);
return value;
}] catch:^(NSError * _Nonnull error) {
NSLog(@"all error:%@", error);
}];

[[[FBLPromise any:arr] then:^id _Nullable(NSArray * _Nullable value) {
NSLog(@"then, value:%@", value);
return value;
}] catch:^(NSError * _Nonnull error) {
NSLog(@"any error:%@", error);
}];
}

- (FBLPromise<NSString *> *)work1 {
return [FBLPromise do:^id _Nullable{
BOOL success = arc4random() % 2;
return success ? @"work1 success" : [NSError errorWithDomain:@"work1_error" code:-1 userInfo:nil];
}];
}

- (FBLPromise<NSNumber *> *)work2 {
return [FBLPromise do:^id _Nullable{
BOOL success = arc4random() % 2;
return success ? @"work2 success" : [NSError errorWithDomain:@"work2_error" code:-1 userInfo:nil];
}];
}

Promises原理解析

Promises库的设计很简单,基于Promise设计模式和iOS的GCD来实现。
整个库由Promise.m/.h和他的Catagory组成。Catagory都是附加特性,基于Promise.m/.h提供的方法做扩展,所以这里重点解析下Promise.m/h。
Promise类public头文件只有寥寥数个方法:

// 静态方法
[FBLPromise pendingPromise]; // pending等待
[FBLPromise resolvedWith:@"anyString"]; // fulfilled完成
[FBLPromise resolvedWith:[NSError new]]; // rejected失败
// 实例方法
- (void)fulfill:(nullable Value)value; // 完成一个promise
- (void)reject:(NSError *)error;// rejected一个promise

重点在于private.h提供的两个方法:

/**
对一个promise添加fulfill和reject的回调
*/
- (void)observeOnQueue:(dispatch_queue_t)queue
fulfill:(FBLPromiseOnFulfillBlock)onFulfill
reject:(FBLPromiseOnRejectBlock)onReject NS_SWIFT_UNAVAILABLE("");

/**
创建一个promise,并设置fulfill、reject方法为传进来的block
*/
- (FBLPromise *)chainOnQueue:(dispatch_queue_t)queue
chainedFulfill:(FBLPromiseChainedFulfillBlock)chainedFulfill
chainedReject:(FBLPromiseChainedRejectBlock)chainedReject NS_SWIFT_UNAVAILABLE("");

observeOnQueue方法是promise的实例方法,根据promise当前的状态,如果是fulfilled或者rejected状态则会dispatch_group_async到下一次执行对应的onFulfill和onReject回调;如果是pending状态则会创建_observers数组,往_observers数组中添加一个block回调,当promise执行完毕的时候,根据state选择onFulfill或者onReject回调。

chainOnQueue方法同样是promise的实例方法,返回的是一个FBLPromise的对象(状态是pending)。
方法首先创建的是promise对象,接着创建了resolver的回调,然后调用observeOnQueue方法。
当self(也是一个promise)执行完毕后,会根据fulfill、reject回调类型接着执行chainedFulfill、chainedReject;
最后将结果抛给resolver执行,resolver会根据返回值value进行判断,如果仍是promise则递归执行,否则直接调用fulfill方法。
fulfill方法则会判断value是否为NSError,如果是NSError则转为reject,否则将状态改为Fulfilled,并且通知observer数组。

- (FBLPromise *)chainOnQueue:(dispatch_queue_t)queue
chainedFulfill:(FBLPromiseChainedFulfillBlock)chainedFulfill
chainedReject:(FBLPromiseChainedRejectBlock)chainedReject {
NSParameterAssert(queue);

FBLPromise *promise = [[FBLPromise alloc] initPending];
__auto_type resolver = ^(id __nullable value) {
if ([value isKindOfClass:[FBLPromise class]]) {
[(FBLPromise *)value observeOnQueue:queue
fulfill:^(id __nullable value) {
[promise fulfill:value];
}
reject:^(NSError *error) {
[promise reject:error];
}];
} else {
[promise fulfill:value];
}
};
[self observeOnQueue:queue
fulfill:^(id __nullable value) {
value = chainedFulfill ? chainedFulfill(value) : value;
resolver(value);
}
reject:^(NSError *error) {
id value = chainedReject ? chainedReject(error) : error;
resolver(value);
}];
return promise;
}

Promises中的dispatch_group_enter() 和 dispatch_group_leave() 是成对使用,但是和平时使用GCD不同,这里并没有用到dispath_group_notify方法。
在刚开始看Promises源码时,产生过一个疑问,为什么所有Promises的操作要放在同一个group内?

+ (dispatch_group_t)dispatchGroup {
static dispatch_group_t gDispatchGroup;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
gDispatchGroup = dispatch_group_create();
});
return gDispatchGroup;
}

直到发现FBLWaitForPromisesWithTimeout方法,里面有一个dispatch_group_wait方法(等待group中所有block执行完毕,或者在指定时间结束后回调)。
dispatch_group_wait方法与dispath_group_notify方法类似,只是多了一个超时时间,如果调用dispatch_group_wait(DISPATCH_TIME_FOREVER)则和dispath_group_notify方法一样。

总结

附加的特性有很多,类似Retry、Delay等,但实际使用中Promise用do、then、catch、async等少数几个已经可以满足需求。
能够实现Promise设计模式的库比较多,Promises是性能和接口调用清晰度都比较不错的。
使用设计模式可以简化逻辑代码,同时也使得代码的健壮性更强。

链接:https://www.jianshu.com/p/d62ef7bec77e

收起阅读 »

在Swift中自定义Codable类型

大多数现代应用程序的共同点是,它们需要对各种形式的数据进行编码或解码。无论是通过网络下载的JSON数据,还是本地存储的模型的某种形式的序列化表示 - 能够可靠地编码和解码不同的数据对于或多或少的任何Swift代码库都是必不可少的。这是Swift的Codable...
继续阅读 »

大多数现代应用程序的共同点是,它们需要对各种形式的数据进行编码或解码。无论是通过网络下载的JSON数据,还是本地存储的模型的某种形式的序列化表示 - 能够可靠地编码和解码不同的数据对于或多或少的任何Swift代码库都是必不可少的。

这是Swift的Codable API在作为Swift 4.0的一部分引入时如此重要的新功能的一个重要原因 - 从那时起它已经发展成为几种不同类型的编码和解码的标准,强大的机制 - 在Apple的平台,以及服务器端Swift。

使Codable如此出色的原因在于它与Swift工具链紧密集成,使编译器能够自动合成编码和解码各种值所需的大量代码。但是,有时我们确实需要自定义序列化时我们的值的表示方式 - 所以本周,我们来看看我们可以通过几种不同的方式调整我们的Codable实现来做到这一点。

改变钥匙

让我们从一种基本方法开始,我们可以自定义类型的编码和解码方式 - 通过修改用作序列化表示的一部分的键。假设我们正在开发一个用于阅读文章的应用程序,我们的核心数据模型之一如下所示:

struct Article: Codable {
var url: URL
var title: String
var body: String
}

我们的模型当前使用完全自动合成的Codable实现,这意味着它的所有序列化键都将匹配其属性的名称。但是,我们将解码Article值的数据(例如从服务器下载的JSON)可能使用稍微不同的命名约定,导致默认解码失败。

谢天谢地,这很容易修复。我们需要做的就是自定义Codable在解码(或编码)我们Article类型的实例时将使用的键是在其中定义CodingKeys枚举 - 并将自定义原始值分配给匹配我们希望自定义的键的案例 - 像这样:

extension Article {
enum CodingKeys: String, CodingKey {
case url = "source_link"
case title = "content_name"
case body
}
}

执行上述操作后,我们可以继续利用编译器生成的默认实现进行实际的编码工作,同时仍然允许我们更改将用于序列化的键的名称。

虽然上述技术非常适合我们想要使用完全自定义的键名称,但如果我们只想让Codable使用snake_case我们的属性名称版本(例如backgroundColor转入background_color) - 那么我们可以简单地改变我们的JSON解码器keyDecodingStrategy:

var decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

上述两个API的优点在于它们使我们能够解决Swift模型与用于表示它们的数据之间的不匹配问题,而无需我们修改属性的名称。

忽略键

虽然能够自定义编码密钥的名称非常有用,但有时我们可能希望完全忽略某些密钥。例如,我们现在说我们正在制作一个笔记记录应用程序 - 并且我们允许用户将各种笔记组合在一起形成一个NoteCollection,其中可以包括本地草稿:

struct NoteCollection: Codable {
var name: String
var notes: [Note]
var localDrafts = [Note]()
}

然而,尽管成为localDrafts我们NoteCollection模型的一部分真的很方便- 但是我们说在序列化或反序列化这样的集合时我们不希望包含这些草稿。这样做的原因可能是每次启动应用程序时给用户一个干净的名单,或者因为我们的服务器不支持草稿。

幸运的是,这也可以轻松完成,而无需更改实际的Codable实现NoteCollection。如果我们CodingKeys像之前一样定义枚举,并且只是省略localDrafts- 那么在编码或解码NoteCollection值时不会考虑该属性:

extension NoteCollection {
enum CodingKeys: CodingKey {
case name
case notes
}
}

为了使上述工作,我们省略的属性必须具有默认值 - localDrafts在这种情况下已经具有。

创建匹配结构

到目前为止,我们只调整了一个类型的编码键 - 虽然我们通常可以做到这一点,但有时我们需要在Codable自定义方面更进一步。

假设我们正在构建一个包含货币转换功能的应用程序,并且我们将给定货币的当前汇率作为JSON数据下载,如下所示:

{
"currency": "PLN",
"rates": {
"USD": 3.76,
"EUR": 4.24,
"SEK": 0.41
}
}

在我们的Swift代码中,我们希望将这些JSON响应转换为CurrencyConversion实例 - 每个实例包含一个ExchangeRate条目数组- 每种货币对应一个:

struct CurrencyConversion {
var currency: Currency
var exchangeRates: [ExchangeRate]
}

struct ExchangeRate {
let currency: Currency
let rate: Double
}

但是,如果我们只是继续使上述两个模型都符合Codable,我们再次得出我们的Swift代码和我们想要解码的JSON数据之间的不匹配。但这一次,它不仅仅是关键名称的问题 - 结构上存在根本区别。

当然,我们可以修改我们的Swift模型的结构以完全匹配我们的JSON数据的结构 - 但这并不总是实用的。虽然拥有正确的序列化代码很重要,但拥有适合我们实际代码库的模型结构同样重要。

相反,让我们创建一个新的专用类型 - 它将充当我们的JSON数据中使用的格式与Swift代码结构之间的桥梁。在该类型中,我们将能够封装将汇率的JSON字典转换为ExchangeRate模型数组所需的所有逻辑- 如下所示:

private extension ExchangeRate {
struct List: Decodable {
let values: [ExchangeRate]

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let dictionary = try container.decode([String : Double].self)

values = dictionary.map { key, value in
ExchangeRate(currency: Currency(key), rate: value)
}
}
}
}

使用上面的类型,我们现在可以定义一个私有属性,该属性与用于其数据的JSON密钥匹配 - 并且我们的exchangeRates属性只是充当该私有属性的面向公众的代理:

struct CurrencyConversion: Decodable {
var currency: Currency
var exchangeRates: [ExchangeRate] {
return rates.values
}

private var rates: ExchangeRate.List
}

上述工作的原因是因为在编码或解码值时从不考虑计算属性。

当我们想要使Swift代码与使用非常不同结构的JSON API兼容时,上述技术可以成为一个很好的工具 - 再次无需Codable从头开始实现。

转变价值观

在解码时,尤其是在使用我们无法控制的外部JSON API时,一个非常常见的问题是,类型的编码方式与Swift的严格类型系统不兼容。例如,我们要解码的JSON数据可能使用字符串来表示整数或其他类型的数字。

让我们看看一种可以让我们处理这些值的方法,再次以一种自包含的方式,不需要我们编写完全自定义的Codable实现。

我们在这里要做的就是将字符串值转换为另一种类型 - 让我们Int以此为例。我们首先定义一个协议,让我们将任何类型标记为StringRepresentable- 意味着它可以从字符串表示转换为字符串表示:

protocol StringRepresentable: CustomStringConvertible {
init?(_ string: String)
}

extension Int: StringRepresentable {}

我们将上述协议基于CustomStringConvertible标准库,因为它已经包含了将值描述为字符串的属性要求。有关将协议定义为其他协议的特殊方法的更多信息,请查看“Swift中的专业协议”。

接下来,让我们创建另一个专用类型 - 这次是任何可以由字符串支持的值- 并且它包含解码和编码字符串值所需的所有代码:

struct StringBacked: Codable {
var value: Value

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)

guard let value = Value(string) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: """
Failed to convert an instance of \(Value.self) from "\(string)"
"""
)
}

self.value = value
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value.description)
}
}

就像我们之前为我们的JSON兼容的底层存储创建私有属性一样,我们现在可以对编码时由字符串后端的任何属性执行相同的操作 - 同时仍然将该数据暴露给我们的其余Swift代码类型。这是一个为Video类型的numberOfLikes属性做这样的例子:

struct Video: Codable {
var title: String
var description: String
var url: URL
var thumbnailImageURL: URL

var numberOfLikes: Int {
get { return likes.value }
set { likes.value = newValue }
}

private var likes: StringBacked
}

在必须为属性手动定义setter和getter的复杂性以及必须回退到完全自定义Codable实现的复杂性之间肯定存在权衡- 但对于类似上述Video结构的类型,其仅具有需要的一个属性使用私有支持属性进行自定义可能是一个很好的选择。

结论

虽然编译器能够自动合成所有Codable不需要任何形式定制的一致性,但真正太棒了- 我们能够在需要时自定义事物同样非常棒。

更妙的是,这样做往往并不真正需要我们赞成手工执行的彻底抛弃自动生成的代码-这是很多次可能只是稍微调整一个类型的编码或解码的方式,同时还让编译器做大部分繁重的工作。

链接:https://www.jianshu.com/p/62162d01d1df

收起阅读 »

CocoaPod知识整理

前言Pod库是很重要的组成部分,大部分第三方库都是通过CocoaPod的方式引入和管理,同时项目中的部分功能也可以用Pod库来做模块化。本文是对CocoaPod的一些探究。XS项目中的Pod库是很重要的组成部分,目前阅读器模块正在进行SDK化,需要用Pod库来...
继续阅读 »

前言

Pod库是很重要的组成部分,大部分第三方库都是通过CocoaPod的方式引入和管理,同时项目中的部分功能也可以用Pod库来做模块化。
本文是对CocoaPod的一些探究。
XS项目中的Pod库是很重要的组成部分,目前阅读器模块正在进行SDK化,需要用Pod库来管理,同时未来会做一些模块化的功能,同样需要用Pod库来处理。
本文对CocoaPods的一些内容进行探究。

正文

CocoaPods是为iOS工程提供第三方依赖库管理的工具,用CocoaPods可以更方便地管理第三方库:把依赖库统一放在Pods工程中,同时让主工程依赖Pods工程。Pods工程的target是libPods-targetName.a静态库,主工程会依赖这个.a静态库。 (下面会详细剖析这个处理过程)

CocoaPods相比手动引入framework或者子工程依赖的方式,有两个便捷之处:

所有Pod库集中管理,版本更新只需Podfile配置文件;
依赖关系的自动解析;
同时CocoaPods的使用流程很简单:(假设已经安装CocoaPods)
1、在xcodeproj所在目录下,新建Podfile文件;

2、描述依赖信息,以demo为例,有AFNetworking和SDWebImage两个第三方库:

target 'LearnPod' do
pod 'AFNetworking'
pod 'SDWebImage'
end

3、打开命令行,执行pod install ;

4、打开生成xcworkspace,就可以继续开发;

一、Podfile的写法

1、普通的写法;
pod 'AFNetworking' 或者 pod 'AFNetworking', '3.2.1',前者是下载最新版本,后者是下载指定版本。

2、指向本地的代码分支;

pod 'AFNetworking', :path => '/Users/loyinglin/Documents/Learn/AFNetworking'

指向的本地目录要带有podspec文件。


3、指定远端的代码分支;

pod 'AFNetworking', :git => 'https://github.com/AFNetworking/AFNetworking.git', :branch => 'master'

指向的repo仓库要带有podspec文件。


4、针对特定的configurations用不同的依赖库

`pod 'AFNetworking', :configurations => ['Release']`

如上,只有Release的configurations生效;(同理,可以设置Debug)


5、一些其他的feature

优化pod install速度,可以进行依赖打平:将pod库的依赖库明确的写在Podfile,主端已经提供对应的工具。

`require "bd_pod_extentions"`

`bytedanceAnalyzeSpeed(true)`

`bd_use_app('toutiao','thirdParty','public')`

post install的脚本,修改安装后的Pod库工程中的target设置;同理,可以修改其他属性的设置。

post_install do |installer_representation|
installer_representation.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['ONLY_ACTIVE_ARCH'] = 'NO'`
end
end
end

类似的还有pre_install的脚本;(但是install之前可能都没有pods_project,所以用处也比较少;具体的参数意义可自查,以pods_project为例)

puts只有在添加--verbose参数可以看到,Pod::UI.puts则是全文可见。

pre_install do |installer|
puts "pre install hook"
Pod::UI.puts "pre install hook puts"
end

Podfile还可以设置一些警告提示的去除,第一行是去掉pod install时候的警告信息,第二行是去掉build时候的警告信息。

# 去掉pod install时候的警告信息
install! 'cocoapods', :warn_for_multiple_pod_sources => false
inhibit_all_warnings

二、Pods目录

Pods目录是pod install之后CocoaPod生成的目录。


目录的组成部分:

1、Pods.xcodeproj,Pods库的工程;每个Pod库会对应其中某个target,每个target都会打包出来一个.a文件;
2、依赖库的文件目录;以SDWebImage为例,会有个SDWebImage目录存放文件;
3、manifest.lock,Pods目录中的Pod库版本信息;每次pod install的时候会检查manifest.lock和Podfile.lock的版本是否一致,不一致的则会更新;
4、Target Support Files、Headers、Local Podspecs目录等;Target Support Files里面是一些target的工程设置xcconifg以及脚本等,Headers里面有Public和Private的头文件目录,Local Podspecs是存放从本地Pod库install时的podspec;

三、CocoaPods的其他重要部分

1.Podfile.lock文件

pod install会解析依赖并生成Podfile.lock文件;如果Podfile.lock存在时执行pod install,则不会修改已经install的pod库。(注意,pod update则会忽视Podfile.lock进行依赖解析,最后重新install所有的Pod库,生成新的Podfile.lock)
在多人开发的项目中,Pods目录由于体积较大,往往不会放在Git仓库中,Podfile.lock文件则建议添加到Git仓库。当其他人修改Podfile时,pod install生成新的Podfile.lock文件也会同步到Git。这样能保证拉下来的版本库是其他人一致的。

实际开发中,也会通过依赖打平来避免多人协作的Pod版本不一致问题。
pod install的时候,Pods目录下生成一个Manifest.lock文件,内容与.lock文件完全一致;在每次build工程的时候,会检查这两个文件是否一致。


2、Pod库的podspec文件
在每个Pod库的仓库中,都会有一个podspec文件,描述Pod库的版本、依赖等信息。
如下,是一个普通的Pod库的podspec:


3、Pod库依赖解析

CocoaPod的依赖管理相对第三方库手动管理更加便捷。
在手动管理第三方库中,如果库A集成了库F,库B也集成了库F ,就会遇到库F符号冲突的问题,需要将库A/B和库F的代码分开,手动添加库F;后续如果库A/B版本有更新,也需要手动去处理。
而在CocoaPod依赖解析中,可以把每个Pod库都看成一个节点,Pod库的依赖是它的子节点; 依赖解析的过程,就是在一个有向图中找到一个拓扑序列。
一个合法的Podfile描述的应该是一个有向无环图,可以通过拓扑排序的方式,得到一个AOV网。
按照这个拓扑序列中的顶点次序,可以依次install所有的Pod库并且保证其依赖的库已经install。

有时候会陷入循环依赖的怪圈,就是因为在有向图中出现环,则无法通过算法得到一个拓扑排序。

四、Pods工程和主工程的关系

在实际的开发过程,容易知道Pods工程是先编译,编译完再执行主工程的编译;因为主工程的Linked Libraries里面有libPods-LearnPod.a的文件。(LearnPod是target的名字,下面的示例图都是用LearnPod作为target名)


那么Pod库中的target编译顺序是如何决定?
打开workspace,选择Pods工程。从上图分析我们知道,主工程最终需要的是libPods-LearnPod.a这一个静态库文件。
我们通常打包,最终名字都是target的名字;而静态库通常会在前面加上lib的前缀。所以libPods-LearnPod.a这个静态库的target名字应该是Pods-LearnPod。
从下图我们也可以确定,确实是在前面添加了lib的前缀。


看看Pods-LearnPod的Build Phases选项,从target依赖中可以看到其他两个target。


分析至此,我们可以知道这里的编译顺序是AFNetworking、SDWebImage、Pods-LearnPod、LeanPod(主工程target)。
接下来我们分析编译过程。AFNetworking因为没有依赖,所以编译的时候只需要知道自己的.h/.m文件。


对于Pods-LearnPod,其有两个依赖,分别是AFNetworking和SDWebImage;所以在Header Search Paths中需要设置这两个库的Public头文件地址。


编译的结果是3个.a文件(libPods-LearnPod.a、libAFNetworking.a、libSDWebImage.a),只有libPods-LearnPod.a是主工程的编译依赖。那么libPods-LearnPod.a是否为多个.a文件的集合?


从libPods-LearnPod.a的大小,我们可以知道libPods-LearnPod不是多个.a的集合,仅仅是作为主工程的一个依赖,使得Pod库工程能先于主工程编译。
那么,主工程编译的时候如何去找到AFNetworking的头文件和.a文件?
从主工程的Search Paths我们可以看到,Header是有说明具体的位置;
同时Library也有相对应的Paths,在对应的位置放着libAFNetworking.a文件;


这些信息是CocoaPod生成的一份xcconfig,里面的HEADER_SEARCH_PATHS和LIBRARY_SEARCH_PATHS会指明这两个地址。


对于资源文件,CocoaPods 提供了一个名为 Pods-resources.sh 的 bash 脚本,该脚本在每次项目编译的时候都会执行,将第三方库的各种资源文件复制到目标目录中。
CocoaPods 通过一个名为 Pods.xcconfig 的文件来在编译时设置所有的依赖和参数。
在编译之前会检查pod的版本是否发生变化(manifest和.lock文件对比),以及执行一些自定义的脚本。
Pod库的子target在指定armv7和arm64两个架构的时候,会分别编译生成armv7和arm64的.a文件;然后再进行一次合并操作,得到一个.a文件。
编译完成后进行链接,在armv7和arm64都指定时,会分别进行链接,最后合并得到可执行文件。
得到可执行文件后,会进行asset、storyboard等资源文件的处理;还会执行pod的脚本,把pod的资源复制过来。
全部准备就绪,就会生成符号表,包括.a文件里面的符号。
最后进行签名、校验,得到.app文件。

五、常用Pod指令

pod install,最常用的指令;
pod update,更新repo并重新解析依赖;
pod install --repo-update,类似pod update;
pod install --no-repo-update,忽略Pod库更新,直接用本地repo进行install;
pod update --no-repo-update,类似pod install;
pod update AFNetworking,更新指定库;

以上所有指令都可以添加 --verbose ,查看更详细的信息;
xcconfig在新增configuration之后,需要重新pod install,并修改xcconfig。

转自:https://www.jianshu.com/p/07ddbd829efc

收起阅读 »

可变共享结构(第二部分)

我们改进了新数据类型的观察能力。在上一章中,我们构建了一个名为的struct / class混合类型 Var。今天我们将继续实验。Var类包含一个结构,我们可以利用关键路径寻找到的结构。如果我们有一个people内部的阵列Var,我们希望采取先Person出数...
继续阅读 »

我们改进了新数据类型的观察能力。

在上一章中,我们构建了一个名为的struct / class混合类型 Var。今天我们将继续实验。

Var类包含一个结构,我们可以利用关键路径寻找到的结构。如果我们有一个people内部的阵列Var,我们希望采取先Person出数组,那么我们得到另一个Var与 Person。更新它Person会修改原始数组,这样我们就会给出Var引用语义。但是如果我们需要的话,我们仍然可以获得结构的复制行为:我们可以将结构值取出Var并具有本地副本。

我们也深入观察。只要有任何变化,根变量就会知道它。我们仍然有一个有点笨拙的API,因为我们Var使用observe闭包初始化,这意味着我们只能在初始化时在根级别添加一个观察者。我们想用一种addObserver方法改进这个API,并且如果我们想要观察根结构或任何其他属性,请使用它。

添加观察者

我们从初始化程序中删除观察者闭包并设置一个新addObserver方法。因为我们将大量使用观察者闭包,所以我们可以为它创建一个类型别名:

final class Var {
// ...
init(initialValue: A) {
var value: A = initialValue {
didSet {

}
}
_get = { value }
_set = { newValue in value = newValue }
}

typealias Observer = (A) -> ()
func addObserver(_ observer: @escaping Observer) {

}
// ... }

以前,我们将一个观察者闭包连接到初始化器中的struct值,但现在我们无法访问那里的观察者。我们需要将所有观察者存储在一个地方,从一个空数组开始,然后连接观察者和结构值:

final class Var {
// ...
init(initialValue: A) {
var observers: [Observer] = []
var value: A = initialValue {
didSet {
for o in observers {
o(value)
}
}
}
_get = { value }
_set = { newValue in value = newValue }
}
// ... }

现在我们仍然需要一种方法来向数组中添加一个观察者。我们重申我们做与技巧get,并set与转addObserver成一个属性,而不是一个方法:

final class Var {
let addObserver: (_ observer: @escaping Observer) -> ()

// ...

init(initialValue: A) {
var observers: [Observer] = []
var value: A = initialValue {
didSet {
for o in observers {
o(value)
}
}
}
_get = { value }
_set = { newValue in value = newValue }
addObserver = { observer in observers.append(observer) }
}
// ... }

在我们可以使用之前addObserver,我们必须将它设置在我们的其他私有初始化程序中。为此,我们将从外部传入一个闭包,以便我们可以在下标实现中定义闭包:

fileprivate init(get: @escaping () -> A, set: @escaping (A) -> (), addObserver: @escaping (@escaping Observer) -> ()) {
_get = get
_set = set
self.addObserver = addObserver
}

在键路径下标中,我们现在必须定义一个addObserver 闭包,它接受一个观察者并用类型的值调用这个观察者 B。我们只有类型的值A,但我们也可以self在这个闭包中观察并使用关键路径来获取B收到的A:

subscript(keyPath: WritableKeyPath) -> Var {
return Var(get: {
self.value[keyPath: keyPath]
}, set: { newValue in
self.value[keyPath: keyPath] = newValue
}, addObserver: { observer in
self.addObserver { newValue in
observer(newValue[keyPath: keyPath])
}
})
}

无论我们嵌套我们Var的深度多少,观察者总是被添加到根Var,因为一个孩子Var通过观察者直到它到达observers根的数组Var。这意味着只要根值发生变化就会调用观察者 - 换句话说:即使属性本身未更改,也可能会调用特定属性的观察者。

我们还在addObserver集合的下标中传递了一个类似的闭包:

extension Var where A: MutableCollection {
subscript(index: A.Index) -> Var {
return Var(get: {
self.value[index]
}, set: { newValue in
self.value[index] = newValue
}, addObserver: { observer in
self.addObserver { newValue in
observer(newValue[index])
}
})
}
}

让我们看看这是如何工作的:

let peopleVar: Var<[Person]> = Var(initialValue: people)
peopleVar.addObserver { p in
print("peoplevar changed: \(p)")
}
let vc = PersonViewController(person: peopleVar[0])
vc.update()

这会将peopleVar更改打印到控制台。但是,我们现在也可以添加一个观察者到Var的PersonViewController,这将打印与新的一个额外的行Person值:

final class PersonViewController {
let person: Var

init(person: Var) {
self.person = person
self.person.addObserver { newPerson in
print(newPerson)
}
}

func update() {
person.value.last = "changed"
}
}

删除观察者

我们现在可以添加观察者,但我们无法删除它们。如果视图控制器因为其观察者仍然在那里而消失,这就成了问题。

我们可以采取类似于反应性图书馆工作方式的方法。添加观察者时,将返回不透明对象。通过保持对该对象的引用,我们保持观察者活着。当我们丢弃对象时,观察者将被删除。

我们使用一个名为的辅助类,Disposable它接受一个在对象取消时调用的dispose函数:

final class Disposable {
private let dispose: () -> ()
init(_ dispose: @escaping () -> ()) {
self.dispose = dispose
}
deinit {
dispose()
}
}

我们更新签名addObserver返回Disposable:

final class Var {
private let _get: () -> A
private let _set: (A) -> ()
let addObserver: (_ observer: @escaping Observer) -> Disposable

// ... }

如果我们想要删除观察者,我们必须改变观察者商店的数据结构。数组不再有效,因为无法比较函数以找到要删除的数组。相反,我们可以使用由唯一整数键入的字典:

final class Var {
// ...

init(initialValue: A) {
var observers: [Int:Observer] = [:]
// ...
}
// ... }

生成这些整数的一种很酷的方法是使用Swift的惰性集合。我们创建一个范围从0到无穷大的迭代器,每次我们需要一个id时,我们可以调用next()这个迭代器。这返回一个可选项,但是我们可以强制解包它,因为我们知道它不能是nil:

final class Var {
// ...

init(initialValue: A) {
var observers: [Int:Observer] = [:]
// ...
var freshInt = (0...).makeIterator()
addObserver = { observer in
let id = freshInt.next()!
observers[id] = observer
// ...
}
}
// ... }

我们现在将观察者存储在字典中。当我们不再使用它们时,剩下要做的就是丢弃观察者。我们返回一个Disposable 带有dispose函数的函数,该函数从字典中删除观察者:

final class Var {
// ...

init(initialValue: A) {
var observers: [Int:Observer] = [:]
// ...
var freshInt = (0...).makeIterator()
addObserver = { observer in
let id = freshInt.next()!
observers[id] = observer
return Disposable { observers[id] = nil }
}
}
// ... }

最后,我们必须addObserver在私有初始化程序中修复签名,它仍然声明返回void而不是Disposable:

fileprivate init(get: @escaping () -> A, set: @escaping (A) -> (), addObserver: @escaping (@escaping Observer) -> Disposable) { /*...*/ }

现在我们的代码再次编译,但是我们确实得到了一个编译器警告我们忽略Disposable了视图控制器中返回的事实。这就解释了为什么我们不再使用更改的Person值获取print语句,因为我们应该保留对观察者的引用以 Disposable使其保持活动状态:

final class PersonViewController {
let person: Var
let disposable: Any?

init(person: Var) {
self.person = person
disposable = self.person.addObserver { newPerson in
print(newPerson)
}
}

func update() {
person.value.last = "changed"
}
}

我们现在保留观察者,我们在更新后得到了print语句Person。重申这里发生的事情:视图控制器被释放的那一刻,它的属性被清除,Disposabledeinits,并且这通过将其id设置为来调用将观察者带出字典的代码nil。

注意:如果我们想self在观察者中使用,我们必须使它成为弱引用,以避免创建引用循环。

比较新旧价值观

我们实现的一个重要方面是观察者不仅在观察到的Var变化时触发,而且在整个数据结构发生任何变化时触发。

如果PersonViewController想要确定它已经Person 改变了,它应该能够将新值与旧值进行比较。因此,我们将更改Observer类型别名以提供新值和旧值

typealias Observer = (A, A) -> ()

这意味着使用新版本和旧版本调用观察者 A。为了明确这一点,我们应该将值包装在一个结构中,并在两个字段中描述它们是什么,但我们正在跳过该部分。

我们在里面调用观察者的地方Var,我们现在也必须传递旧值:

init(initialValue: A) {
// ...
var value: A = initialValue {
didSet {
for o in observers.values {
o(value, oldValue)
}
}
}
// ... }

subscript(keyPath: WritableKeyPath) -> Var {
return Var(get: {
self.value[keyPath: keyPath]
}, set: { newValue in
self.value[keyPath: keyPath] = newValue
}, addObserver: { observer in
self.addObserver { newValue, oldValue in
observer(newValue[keyPath: keyPath], oldValue[keyPath: keyPath])
}
})
}

而在MutableCollection标,我们也应该通过旧值观察员:

extension Var where A: MutableCollection {
subscript(index: A.Index) -> Var {
return Var(get: {
self.value[index]
}, set: { newValue in
self.value[index] = newValue
}, addObserver: { observer in
self.addObserver { newValue, oldValue in
observer(newValue[index], oldValue[index])
}
})
}
}

观察者PersonViewController可以比较新旧版本,看看它的模型是否确实改变了:

final class PersonViewController {
// ...
init(person: Var) {
self.person = person
disposable = self.person.addObserver { newPerson, oldPerson in
guard newPerson != oldPerson else { return }
print(newPerson)
}
}
// ... }

最后,我们需要修复观察者peopleVar:

peopleVar.addObserver { newPeople, oldPeople in
print("peoplevar changed: \(newPeople)")
}

通过对视图控制器中的人进行更改,我们测试视图控制器的观察者忽略它:

peopleVar[1].value.first = "Test"

讨论

我们将反应式编程与观察变化的能力和面向对象的编程结合起来。

在这段代码中还有一个令人惊讶的等待。我们将第一个Person从数组移交给视图控制器。如果我们然后删除people数组的第一个元素,视图控制器突然有一个不同的 Person:

peopleVar.value.removeFirst()

的第一个元素是从阵列中删除,但Var在 PersonViewController仍然指向peopleVar[0]因为我们使用一个动态评估标。在大多数情况下,这是不希望的行为。一个可以改善这种行为的例子是有一个first(where:)允许我们通过标识符选择元素的方法。

到目前为止,我们对我们的建设感到兴奋。也许它可能改变我们编写应用程序的方式。或者,它可能仍然太实验性:我们设法编译代码,但我们不确定该技术将在何处以及如何破解。

即使我们不在Var实践中使用,我们也结合了很多有趣的功能,这些功能可以很好地展示Swift的强大功能:泛型,关键路径,闭包,变量捕获,协议和扩展。

将来,尝试只部分应用方面可能会很酷Var。假设我们有一个数据库接口,它从数据库中读取一个人模型并将其作为a返回Var。我们可以使用它自动将结构的更改保存回数据库。似乎会有像这样的例子,其中Var技术可能是有用的。

转自:https://www.jianshu.com/p/20030b35e11c
收起阅读 »

被忽视了的NSDataDetector

keywordsNSDataDetector NSRegularExpression NSTextCheckingResult在日常开发场景中经常会遇到,在一段文本中检测一些半结构化的信息,比如:日期、地址段、链接、电话号码、交通信息、航班号、奇怪的格式化了的...
继续阅读 »

keywords

NSDataDetector NSRegularExpression NSTextCheckingResult

在日常开发场景中经常会遇到,在一段文本中检测一些半结构化的信息,比如:日期、地址段、链接、电话号码、交通信息、航班号、奇怪的格式化了的数字、甚至是相对的指示语等等。

如果这些需求在一个项目中出现,在不知道NSDataDetector这个类之前,可能要头皮发麻,之后开始自己编制一些正则,再加上国际化的需求,可能对编制好的正则需要大量的单元测试用例的介入。(估计好多小盆友要被这些东西整自闭了...)

幸运的是,对于 Cocoa 开发者来说,有一个简单的解决方案:NSDataDetector。

关于NSDataDetector

NSDataDetector 是 NSRegularExpression 的子类,而不只是一个 ICU 的模式匹配,它可以检测半结构化的信息:日期,地址,链接,电话号码和交通信息。

它以惊人的准确度完成这一切。NSDataDetector 可以匹配航班号,地址段,奇怪的格式化了的数字,甚至是相对的指示语,如 “下周六五点”。

你可以把它看成是一个有着复杂的令人难以置信的正则表达式匹配,可以从自然语言提取信息(尽管实际的实现细节可能比这个复杂得多)。

NSDataDetector 对象用一个需要检查的信息的位掩码类型来初始化,然后传入一个需要匹配的字符串。像 NSRegularExpression 一样,在一个字符串中找到的每个匹配是用 NSTextCheckingResult 来表示的,它有诸如字符范围和匹配类型的详细信息。然而,NSDataDetector 的特定类型也可以包含元数据,如地址或日期组件。


当然你也可以结合 YYKit 中的YYLabel进行文本的高亮展示,并且添加点击事件(以下是我项目中需要匹配文本中的手机号码):


当初始化 NSDataDetector 的时候,确保只指定你感兴趣的类型。每当增加一个需要检查的类型,随着而来的是不小的性能损失为代价。

数据检测器匹配类型

NSDataDetector 的各种 NSTextCheckingTypes 匹配,及其相关属性表:


其他的一些注意事项可以自行参考 Mattt 的文章NSDataDetector自行进行查阅。

好了,大家可以进行尝试一下,在你的应用程序里充分利用 NSDataDetector 解锁那些已经隐藏在众目睽睽下的结构化信息吧。

参考自: https://developer.apple.com/documentation/foundation/nsregularexpression

https://developer.apple.com/documentation/foundation/nstextcheckingresult

https://nshipster.com/nsdatadetector

转自:https://www.jianshu.com/p/91daa300da26

收起阅读 »

iOS完整文件拉流解析解码同步渲染音视频流

需求解析文件中的音视频流以解码同步并将视频渲染到屏幕上,音频通过扬声器输出.对于仅仅需要单纯播放一个视频文件可直接使用AVFoundation中上层播放器,这里是用最底层的方式实现,可获取原始音视频帧数据.实现原理本文主要分为三大块,解析模块使用FFmpeg ...
继续阅读 »

需求

解析文件中的音视频流以解码同步并将视频渲染到屏幕上,音频通过扬声器输出.对于仅仅需要单纯播放一个视频文件可直接使用AVFoundation中上层播放器,这里是用最底层的方式实现,可获取原始音视频帧数据.

实现原理

本文主要分为三大块,解析模块使用FFmpeg parse文件中的音视频流,解码模块使用FFmpeg或苹果原生解码器解码音视频,渲染模块使用OpenGL将视频流渲染到屏幕,使用Audio Queue Player将音频以扬声器形式输出.


本文以解码一个.MOV媒体文件为例, 该文件中包含H.264编码的视频数据, AAC编码的音频数据,首先要通过FFmpeg去parse文件中的音视频流信息,parse出来的结果保存在AVPacket结构体中,然后分别提取音视频帧数据,音频帧通过FFmpeg解码器或苹果原生框架中的Audio Converter进行解码,视频通过FFmpeg或苹果原生框架VideoToolbox中的解码器可将数据解码,解码后的音频数据格式为PCM,解码后的视频数据格式为YUV原始数据,根据时间戳对音视频数据进行同步,最后将PCM数据音频传给Audio Queue以实现音频的播放,将YUV视频原始数据封装为CMSampleBufferRef数据结构并传给OpenGL以将视频渲染到屏幕上,至此一个完整拉取文件视频流的操作完成.

注意: 通过网址拉取一个RTMP流进行解码播放的流程与拉取文件流基本相同, 只是需要通过socket接收音视频数据后再完成解码及后续流程.

简易流程

Parse

  • 创建AVFormatContext上下文对象: AVFormatContext *avformat_alloc_context(void);

  • 从文件中获取上下文对象并赋值给指定对象: int avformat_open_input(AVFormatContext **ps, const char *url, AVInputFormat *fmt, AVDictionary **options)

  • 读取文件中的流信息: int avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options);

  • 获取文件中音视频流: m_formatContext->streams[audio/video index]e

  • 开始parse以获取文件中视频帧帧: int av_read_frame(AVFormatContext *s, AVPacket *pkt);

  • 如果是视频帧通过av_bitstream_filter_filter生成sps,pps等关键信息.

  • 读取到的AVPacket即包含文件中所有的音视频压缩数据.

解码

通过FFmpeg解码

  • 获取文件流的解码器上下文: formatContext->streams[a/v index]->codec;

  • 通过解码器上下文找到解码器: AVCodec *avcodec_find_decoder(enum AVCodecID id);

  • 打开解码器: int avcodec_open2(AVCodecContext *avctx, const AVCodec *codec, AVDictionary **options);

  • 将文件中音视频数据发送给解码器: int avcodec_send_packet(AVCodecContext *avctx, const AVPacket *avpkt);

  • 循环接收解码后的音视频数据: int avcodec_receive_frame(AVCodecContext *avctx, AVFrame *frame);

  • 如果是音频数据可能需要重新采样以便转成设备支持的格式播放.(借助SwrContext)

通过VideoToolbox解码视频

  • 将从FFmpeg中parse到的extra data中分离提取中NALU头关键信息sps,pps等

  • 通过上面提取的关键信息创建视频描述信息:CMVideoFormatDescriptionRef, CMVideoFormatDescriptionCreateFromH264ParameterSets / CMVideoFormatDescriptionCreateFromHEVCParameterSets

  • 创建解码器:VTDecompressionSessionCreate,并指定一系列相关参数.

  • 将压缩数据放入CMBlockBufferRef中:CMBlockBufferCreateWithMemoryBlock

  • 开始解码: VTDecompressionSessionDecodeFrame

  • 在回调中接收解码后的视频数据

通过AudioConvert解码音频

  • 通过原始数据与解码后数据格式的ASBD结构体创建解码器: AudioConverterNewSpecific

  • 指定解码器类型AudioClassDescription

  • 开始解码: AudioConverterFillComplexBuffer

  • 注意: 解码的前提是每次需要有1024个采样点才能完成一次解码操作.

同步

因为这里解码的是本地文件中的音视频, 也就是说只要本地文件中音视频的时间戳打的完全正确,我们解码出来的数据是可以直接播放以实现同步的效果.而我们要做的仅仅是保证音视频解码后同时渲染.

注意: 比如通过一个RTMP地址拉取的流因为存在网络原因可能造成某个时间段数据丢失,造成音视频不同步,所以需要有一套机制来纠正时间戳.大体机制即为视频追赶音频,后面会有文件专门介绍,这里不作过多说明.

渲染

通过上面的步骤获取到的视频原始数据即可通过封装好的OpenGL ES直接渲染到屏幕上,苹果原生框架中也有GLKViewController可以完成屏幕渲染.音频这里通过Audio Queue接收音频帧数据以完成播放.

文件结构


快速使用

使用FFmpeg解码

首先根据文件地址初始化FFmpeg以实现parse音视频流.然后利用FFmpeg中的解码器解码音视频数据,这里需要注意的是,我们将从读取到的第一个I帧开始作为起点,以实现音视频同步.解码后的音频要先装入传输队列中,因为audio queue player设计模式是不断从传输队列中取数据以实现播放.视频数据即可直接进行渲染.

- (void)startRenderAVByFFmpegWithFileName:(NSString *)fileName {
NSString *path = [[NSBundle mainBundle] pathForResource:fileName ofType:@"MOV"];

XDXAVParseHandler *parseHandler = [[XDXAVParseHandler alloc] initWithPath:path];

XDXFFmpegVideoDecoder *videoDecoder = [[XDXFFmpegVideoDecoder alloc] initWithFormatContext:[parseHandler getFormatContext] videoStreamIndex:[parseHandler getVideoStreamIndex]];
videoDecoder.delegate = self;

XDXFFmpegAudioDecoder *audioDecoder = [[XDXFFmpegAudioDecoder alloc] initWithFormatContext:[parseHandler getFormatContext] audioStreamIndex:[parseHandler getAudioStreamIndex]];
audioDecoder.delegate = self;

static BOOL isFindIDR = NO;

[parseHandler startParseGetAVPackeWithCompletionHandler:^(BOOL isVideoFrame, BOOL isFinish, AVPacket packet) {
if (isFinish) {
isFindIDR = NO;
[videoDecoder stopDecoder];
[audioDecoder stopDecoder];
dispatch_async(dispatch_get_main_queue(), ^{
self.startWorkBtn.hidden = NO;
});
return;
}

if (isVideoFrame) { // Video
if (packet.flags == 1 && isFindIDR == NO) {
isFindIDR = YES;
}

if (!isFindIDR) {
return;
}

[videoDecoder startDecodeVideoDataWithAVPacket:packet];
}else { // Audio
[audioDecoder startDecodeAudioDataWithAVPacket:packet];
}
}];
}

-(void)getDecodeVideoDataByFFmpeg:(CMSampleBufferRef)sampleBuffer {
CVPixelBufferRef pix = CMSampleBufferGetImageBuffer(sampleBuffer);
[self.previewView displayPixelBuffer:pix];
}

- (void)getDecodeAudioDataByFFmpeg:(void *)data size:(int)size pts:(int64_t)pts isFirstFrame:(BOOL)isFirstFrame {
// NSLog(@"demon test - %d",size);
// Put audio data from audio file into audio data queue
[self addBufferToWorkQueueWithAudioData:data size:size pts:pts];

// control rate
usleep(14.5*1000);
}

使用原生框架解码

首先根据文件地址初始化FFmpeg以实现parse音视频流.这里首先根据文件中实际的音频流数据构造ASBD结构体以初始化音频解码器,然后将解码后的音视频数据分别渲染即可.这里需要注意的是,如果要拉取的文件视频是H.265编码格式的,解码出来的数据的因为含有B帧所以时间戳是乱序的,我们需要借助一个链表对其排序,然后再将排序后的数据渲染到屏幕上.

- (void)startRenderAVByOriginWithFileName:(NSString *)fileName {
NSString *path = [[NSBundle mainBundle] pathForResource:fileName ofType:@"MOV"];
XDXAVParseHandler *parseHandler = [[XDXAVParseHandler alloc] initWithPath:path];

XDXVideoDecoder *videoDecoder = [[XDXVideoDecoder alloc] init];
videoDecoder.delegate = self;

// Origin file aac format
AudioStreamBasicDescription audioFormat = {
.mSampleRate = 48000,
.mFormatID = kAudioFormatMPEG4AAC,
.mChannelsPerFrame = 2,
.mFramesPerPacket = 1024,
};

XDXAduioDecoder *audioDecoder = [[XDXAduioDecoder alloc] initWithSourceFormat:audioFormat
destFormatID:kAudioFormatLinearPCM
sampleRate:48000
isUseHardwareDecode:YES];

[parseHandler startParseWithCompletionHandler:^(BOOL isVideoFrame, BOOL isFinish, struct XDXParseVideoDataInfo *videoInfo, struct XDXParseAudioDataInfo *audioInfo) {
if (isFinish) {
[videoDecoder stopDecoder];
[audioDecoder freeDecoder];

dispatch_async(dispatch_get_main_queue(), ^{
self.startWorkBtn.hidden = NO;
});
return;
}

if (isVideoFrame) {
[videoDecoder startDecodeVideoData:videoInfo];
}else {
[audioDecoder decodeAudioWithSourceBuffer:audioInfo->data
sourceBufferSize:audioInfo->dataSize
completeHandler:^(AudioBufferList * _Nonnull destBufferList, UInt32 outputPackets, AudioStreamPacketDescription * _Nonnull outputPacketDescriptions) {
// Put audio data from audio file into audio data queue
[self addBufferToWorkQueueWithAudioData:destBufferList->mBuffers->mData size:destBufferList->mBuffers->mDataByteSize pts:audioInfo->pts];

// control rate
usleep(16.8*1000);
}];
}
}];
}

- (void)getVideoDecodeDataCallback:(CMSampleBufferRef)sampleBuffer isFirstFrame:(BOOL)isFirstFrame {
if (self.hasBFrame) {
// Note : the first frame not need to sort.
if (isFirstFrame) {
CVPixelBufferRef pix = CMSampleBufferGetImageBuffer(sampleBuffer);
[self.previewView displayPixelBuffer:pix];
return;
}

[self.sortHandler addDataToLinkList:sampleBuffer];
}else {
CVPixelBufferRef pix = CMSampleBufferGetImageBuffer(sampleBuffer);
[self.previewView displayPixelBuffer:pix];
}
}

#pragma mark - Sort Callback
- (void)getSortedVideoNode:(CMSampleBufferRef)sampleBuffer {
int64_t pts = (int64_t)(CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) * 1000);
static int64_t lastpts = 0;
// NSLog(@"Test marigin - %lld",pts - lastpts);
lastpts = pts;

[self.previewView displayPixelBuffer:CMSampleBufferGetImageBuffer(sampleBuffer)];
}

具体实现

本文中每一部分的具体实现均有详细介绍, 如需帮助请参考阅读前提中附带的链接地址.

注意

因为不同文件中压缩的音视频数据格式不同,这里仅仅兼容部分格式,可自定义进行扩展.

转自:https://www.jianshu.com/p/854a1bb47173

收起阅读 »

前端必须要了解的一些知识 (七)

创建对象又几种方法//第一种:字面量 var o1 = {name: 'o1'}; var o2 = new Object({name: 'o2'});//第二种 通过构造函数 var M = function (name) { this.name = na...
继续阅读 »
创建对象又几种方法
//第一种:字面量
var o1 = {name: 'o1'};
var o2 = new Object({name: 'o2'});
//第二种 通过构造函数
var M = function (name) { this.name = name; };
var o3 = new M('o3');
//第三种 Object.create
var p = {name: 'p'};
var o4 = Object.create(p);



o4.__proto__===p//true

原型 构造函数 实例 原型链



instanceof的原理


严谨来判断用constructor
instanceof 并不是很严谨


new运算符背后的原理
原理如下
测试如下

----
面向对象
类与实例
类的声明
1,构造函数
fn Animal(){
this.name = 'name'
}
2,ES6class的声明
class Animal2 {
//↓↓构造函数
constructor(){
this.name=name
}
}
实例化类
console.log(new Animal(),new Animal2())



类与继承
继承的本质就是原型链
继承有几种形式各种的优缺点不同点
1借助构造函数实现继承
fn parent1(){
this.name= 'parent1'
}
parent1.prototype.say(){
console.log('hello')
}
fn child1() {
parent1.call(this)
this.type='child1'
}


缺点 继承后parent1原型链上的东西 继承不say 并没有被继承 只能实现部分继承
如果方法都在构造函数上就能继承
2借助原型链实现继承
fn parent2(){
this.name= 'parent2'
this.play=[1,2,3]
}
fn child2() {
this.type='child2'
}
child2.prototype = new parent2()
var s1 = new Child2();
var s2 = new Child2();
s1.play.push(4);


缺点:如下 引用类型 不同的实例会全部变

3组合方式
前两种的方式的结合
fn parent3(){
this.name= 'parent3'
this.play=[1,2,3]
}
fn child3() {
parent3.call(this)
this.type='child2'
}
child3.prototype = new parent3()
var s3 = new Child3();
var s4 = new Child3();
s3.play.push(4);
console.log(s3.play,s4.play)


缺点:实例化的时候父构造函数执行了两次
//优化方式
fn parent4(){
this.name= 'parent4'
this.play=[1,2,3]
}
fn child4() {
parent4.call(this)
this.type='child4'


}
child4.prototype = parent4.prototype
var s5 = new Child4();
var s6 = new Child4();
console.log(s5 instance child4,s5 instance parent4)//true true
console.log(s5.constructor)//parent4
缺点:区分不了s5是child4还是parent4的实例
//优化方式
fn parent5(){
this.name= 'parent5'
this.play=[1,2,3]
}
fn child5() {
parent5.call(this)
this.type='child5'
}
child5.prototype = Object.creat(parent5.prototype)
//此时还是找不到 创建一下constructor可解决
child5.prototype.constructor = child5
var s7 = new Child5();
console.log(s7 instance child4,s7 instance parent4)//true true
console.log(s7.constructor)//child5


收起阅读 »

前端必须要了解的一些知识 (六)

DOM事件的级别DOM0element.onclick=function(){}DOM1未制定事件相关的标准DOM2element.add('click',fn,false)/ie . atenchDOM3el.add('keyup',fn,false)增加了...
继续阅读 »

DOM事件的级别

DOM0

element.onclick=function(){}


DOM1

未制定事件相关的标准


DOM2

element.add('click',fn,false)/ie . atench

DOM3

el.add('keyup',fn,false)增加了其他事件除了click


DOM事件的模型:捕获和冒泡



DOM事件流

三个j阶段

捕获 。 目标阶段 。 冒泡阶段



事件捕获的具体流程

window=>document=>html=>body=>.....目标


冒泡则相反


event对象的常见应用

event.preventDefalut . 阻止默认行为

event.stopPropagation . 阻止冒泡

event.stoplmmediatePropagation . 事件响应优先级

事件代理

event.currentTarget 当前绑定的事件的对象

event.target 返回触发事件的元素


currentTarget在事件流的捕获,冒泡阶段。只有当事件流处在冒泡阶段的时候,两个的指向才是一样的, 而当处于捕获和冒泡阶段的时候,

target指向被单击的对象

currentTarget指向当前事件活动的对象(一般为父级)。



自定义事件

let eve = new Event('eveName')/new CustomEvent可以加参数Obj

//注册

ev.addEventListener('eveName',fn)

//触发

ev.dispatchEvent(eve)


HTTP

http协议包括 :通用头域、请求消息、响应消息和主体信息。

特点

简单快速

每个资源得url是固定得

灵活


无连接

连接一次就会断掉

无状态

服务端不记录客户端连接得身份


报文得组成部分

请求报文

请求行

http方法

页面地址

http协议以及http版本

请求头

key value值告诉服务端我要哪些内容

空行

隔断

请求体

数据

响应报文

状态行

协议 状态吗

响应头

key value

空行

隔断

相应体

数据

http方法

get 获取资源

post 传输资源

put 更新资源

delete 删除资源

HEAD 获取报文首部

POST和GET区别(记住以下三个以上1,3,4,6,9)


HTTP状态码



持久链接

http1.1版本支持

管线化


  1. 管线化得特点和原理
  2. 请求和响应打包返回
  3. 持续连接完成后进行的且需要1.1版本的支持
  4. 管线化只有get和head可以进行 post有限制
  5. 管线化默认chrome和firefox默认不开启,初次连接的时候可能不支持,需要服务端的支持


收起阅读 »

前端必须要了解的一些知识 (五)

盒模型标准模型和IE模型标准模型和IE模型的区别1计算宽度和高度的不同ie中content的宽度包括padding和border这两个属性css是如何设置这两种模型的border-box 是·ie默认 content-boxjs如何获取盒模型的宽和高四种方法1...
继续阅读 »

盒模型
标准模型和IE模型


标准模型和IE模型的区别
1计算宽度和高度的不同
ie中content的宽度包括padding和border这两个属性

css是如何设置这两种模型的
border-box 是·ie
默认 content-box

js如何获取盒模型的宽和高
四种方法
1.dom.style.width/height 只能获取行内样式
2.dom.currentStyle.width/height只适合ie,兼容性问题
3.window.getComputedStyle(dom).width/height可以准确获取//兼容性最好
4.dom.getBoundingClientRect().width/height
getBoundingClientRect()可以返回一个包含几个参数的对象,left,top,width,height.等。。盒模型距离viewport 左上角的距离。



拔高
解释边距重叠
margin边距重叠取最大值


引出BFC和IFC
IFC在行内格式化上下文中,框(boxes)一个接一个地水平排列,起点是包含块的顶部。水平方向上的 marginborder和 padding在框之间得到保留。框在垂直方向上可以以不同的方式对齐:它们的顶部或底部对齐,或根据其中文字的基线对齐。包含那些框的长方形区域,会形成一行,叫做行框。

BFC的使用场景
 BFC:块级格式化上下文,它是指一个独立的块级渲染区域,只有Block-level BOX参与,该区域拥有一套渲染规则来约束块级盒子的布局,且与区域外部无关。
BFC的生成
既然上文提到BFC是一块渲染区域,那这块渲染区域到底在哪,它又是有多大,这些由生成BFC的元素决定,CSS2.1中规定满足下列CSS声明之一的元素便会生成BFC。
  • 根元素
  • float的值不为none
  • overflow的值不为visible
  • display的值为inline-block、table-cell、table-caption
  • position的值为absolute或fixed
  看到有道友文章中把display:table也认为可以生成BFC,其实这里的主要原因在于Table会默认生成一个匿名的table-cell,正是这个匿名的table-ccell生成了BFC
BFC的约束规
  1. 内部的Box会在垂直方向上一个接一个的放置
  2. 垂直方向上的距离由margin决定。(完整的说法是:属于同一个BFC的两个相邻Box的margin会发生重叠,与方向无关。)
  3. 每个元素的左外边距与包含块的左边界相接触(从左向右),即使浮动元素也是如此。(这说明BFC中子元素不会超出他的包含块,而position为absolute的元素可以超出他的包含块边界)
  4. BFC的区域不会与float的元素区域重叠
  5. 计算BFC的高度时,浮动子元素也参与计算
  6. BFC就是页面上的一个隔离的独立容器,容器里面的子元素不会影响到外面元素,反之亦然

清除浮动的四种方式及其原理理解
 利用clear样式

 父元素结束标签之前插入清除浮动的块级元素

利用伪元素(clearfix)

利用overflow清除浮动
收起阅读 »

前端必须要了解的一些知识 (四)

基础方法1:浮动(延伸BFC)清除浮动后 浮动元素周边的元素处理的好的话 。 兼容性比较好2:绝对定位配合js的话 快捷缺点:脱离文档流3:flex比较完美的方案 。 解决以上的缺点4:表格布局兼容性特别好 ie缺点:。。5:网格布局 gride新的标准代码最...
继续阅读 »



基础方法

1:浮动(延伸BFC)

清除浮动后 浮动元素周边的元素处理的好的话 。 兼容性比较好

2:绝对定位

配合js的话 快捷

缺点:脱离文档流

3:flex

比较完美的方案 。 解决以上的缺点

4:表格布局

兼容性特别好 ie

缺点:。。

5:网格布局 gride

新的标准

代码最简化哈


拔高延续

1:如过去掉高度已知 。 哪个不在好用?

第三和第四能用

2:竖起来

3:两栏布局






收起阅读 »

前端必须要了解的一些知识 (三)

你在下单时,要给后台发请求,后台通过拿到的参数请求微信后台去生成订单并同时返给你一个路径mweb_url,这个路径就是用来调起微信应用发起支付操作的。(这里要说明的就是由于h5支付不能主动回调,所以需要个主动查询的操作,这时会需要你又一个确认界面来进行主动查询...
继续阅读 »

你在下单时,要给后台发请求,后台通过拿到的参数请求微信后台去生成订单并同时返给你一个路径mweb_url,这个路径就是用来调起微信应用发起支付操作的。(这里要说明的就是由于h5支付不能主动回调,所以需要个主动查询的操作,这时会需要你又一个确认界面来进行主动查询订单状态。这里是个坑一会儿再说),调起支付界面之后进行支付操作,期间你什么都不用管,因为这都是微信的事。你需要的就是在你付完钱之后查看你的钱买你要的东西到底有没有成功(你要是不加的话,谁知道成功没,估计顾客会打死你,付完钱就茫然了,不知道到底钱到哪去了→_→)


普通函数中的this:

1. this总是代表它的直接调用者, 例如 obj.func ,那么func中的this就是obj

2.在默认情况(非严格模式下,未使用 'use strict'),没找到直接调用者,则this指的是 window

3.在严格模式下,没有直接调用者的函数中的this是 undefined

4.使用call,apply,bind(ES5新增)绑定的,this指的是 绑定的对象

箭头函数中的this

默认指向在定义它时,它所处的对象,而不是执行时的对象, 定义它的时候,可能环境是window(即继承父级的this);

下面通过一些例子来研究一下 this的一些使用场景


call

call(null, arr[0], arr[1], arr[2], arr[3], arr[4])//89


1 dom有元素 页面不渲染

首页 scoped 不加 导致引入的tab分类无法加载图片

原因未知 此处感觉不太球对 瞎吉儿改的



2:懒加载问题



3.vue router

repalce push go



4css使图片置灰

-webkit-filter: grayscale(100%); -moz-filter: grayscale(100%); -ms-filter: grayscale(100%); -o-filter: grayscale(100%); filter: grayscale(100%); filter: gray;





收起阅读 »

前端必须要了解的一些知识 (二)

获取字符串长度 str.length分割字符串 str.split()拼接字符串 str1+str2 或 str1.concat(str2)替换字符串 str.replace(“玩游戏”,”好好学习”)提取子字符串 str.slice(start, end)或...
继续阅读 »

获取字符串长度 str.length

分割字符串 str.split()

拼接字符串 str1+str2 或 str1.concat(str2)

替换字符串 str.replace(“玩游戏”,”好好学习”)

提取子字符串 str.slice(start, end)或str.substring(start,end)或myStr.substr(start,length)

切换字符串大小写 str.toLowerCase()和str.toUpperCase()

比较字符串 str1.localeCompare(str2)

匹配字符串 str.match(pattern)或pattern.exec(str)或str.search(pattern)

根据位置查字符 str.charAt(index)

根据位置字符Unicode编码 str.charCodeAt(index)

根据字符查位置 str.indexOf(“you”)从左,myStr.lastIndexOf(“you”)从尾 或str.search(‘you’)

原始数据类型转字符串 String(数据) 或利用加号

字符串转原始数据类型 数字Number(”) // 0 布尔Boolean(”) // 0

自己构建属性和方法 String.prototype.属性或方法= function(参数){代码}

----------

箭头函数需要注意的地方

*当要求动态上下文的时候,就不能够使用箭头函数,也就是this的固定化。

1、在使用=>定义函数的时候,this的指向是定义时所在的对象,而不是使用时所在的对象;

2、不能够用作构造函数,这就是说,不能够使用new命令,否则就会抛出一个错误;

3、不能够使用arguments对象;

4、不能使用yield命令;


-------------------------

let和const

 *let是更完美的var,不是全局变量,具有块级函数作用域,大多数情况不会发生变量提升。const定义常量值,不能够重新赋值,如果值是一个对象,可以改变对象里边的属性值。

1、let声明的变量具有块级作用域

2、let声明的变量不能通过window.变量名进行访问

3、形如for(let x..)的循环是每次迭代都为x创建新的绑定


依次输出的问题

1:立即执行函数

2:闭包

3:let


--------------------------------

Set数据结构

*es6方法,Set本身是一个构造函数,它类似于数组,但是成员值都是唯一的。

--------------------------------

-------------------------------------

promise 案例较多 。 建议看代码

http://www.cnblogs.com/fengxiongZz/p/8191503.html

收起阅读 »

前端必须要了解的一些知识 (一)

常用apimoveTo(x0,y0):把当前画笔(ictx)移动到(x0,y0)这个位置。lineTo(x1,y1):从当前位置(x0,y0)处到(x1,y1)画一条直线。beginPath():开启一条路径或者重置当前路径。closePath():从当前点回...
继续阅读 »

常用api

moveTo(x0,y0):把当前画笔(ictx)移动到(x0,y0)这个位置。

lineTo(x1,y1):从当前位置(x0,y0)处到(x1,y1)画一条直线。

beginPath():开启一条路径或者重置当前路径。

closePath():从当前点回到路径起始点,也就是上一个beginPath的位置,回避和路径。

stroke():绘制。必须加了这个函数才会画图,所以这个一定要放在最后。


绘制一个圆形

/获取Canvas对象(画布)

var canvas = document.getElementById("myCanvas");

//简单地检测当前浏览器是否支持Canvas对象,以免在一些不支持html5的浏览器中提示语法错误

if(canvas.getContext){

//获取对应的CanvasRenderingContext2D对象(画笔)

var ctx = canvas.getContext("2d");

//开始一个新的绘制路径

ctx.beginPath();

//设置弧线的颜色为蓝色

ctx.strokeStyle = "blue";

var circle = {

x : 100, //圆心的x轴坐标值

y : 100, //圆心的y轴坐标值

r : 50 //圆的半径

};

//沿着坐标点(100,100)为圆心、半径为50px的圆的顺时针方向绘制弧线

ctx.arc(circle.x, circle.y, circle.r, 0, Math.PI / 2, false);

//按照指定的路径绘制弧线

ctx.stroke();

}

------

深拷贝

深拷贝就是指完全的拷贝一个对象,即使嵌套了对象,两者也相互分离,修改一个对象的属性,也不会影响另一个

1:不仅可拷贝数组还能拷贝对象(但不能拷贝函数)

var arr = ['old', 1, true, ['old1', 'old2'], {old: 1}] var new_arr = JSON.parse(JSON.stringify(arr)) console.log(new_arr);

2:下面是深拷贝一个通用方法,实现思路:拷贝的时候判断属性值的类型,如果是对象,继续递归调用深拷贝函数

var deepCopy = function(obj) {

// 只拷贝对象

if (typeof obj !== 'object') return;

// 根据obj的类型判断是新建一个数组还是一个对象

var newObj = obj instanceof Array ? [] : {};

for (var key in obj) {

// 遍历obj,并且判断是obj的属性才拷贝

if (obj.hasOwnProperty(key)) {

// 判断属性值的类型,如果是对象递归调用深拷贝

newObj[key] = typeof obj[key] === 'object' ? deepCopy(obj[key]) : obj[key];

}

}

return newObj;

}





浅拷贝

数组的浅拷贝,可用concat、slice返回一个新数组的特性来实现拷贝for in

var arr = ['old', 1, true, null, undefined];

var new_arr = arr.concat(); // 或者var new_arr = arr.slice()也是一样的效果;

new_arr[0] = 'new';

console.log(arr); // ["old", 1, true, null, undefined]

console.log(new_arr); // ["new", 1, true, null, undefined]

-------------------------------------------

数组常用的方法

map此方法是将数组中的每个元素调用一个提供的函数,结果作为一个新的数组返回,并没有改变原来的数组

let arr = [1, 2, 3, 4, 5]

    let newArr = arr.map(x => x*2)

    //arr= [1, 2, 3, 4, 5]   原数组保持不变

    //newArr = [2, 4, 6, 8, 10] 返回新数组

forEach此方法是将数组中的每个元素执行传进提供的函数,没有返回值,直接改变原数组,注意和map方法区分

let arr = [1, 2, 3, 4, 5]

   num.forEach(x => x*2)

    // arr = [2, 4, 6, 8, 10]  数组改变,注意和map区分

filter()此方法是将所有元素进行判断,将满足条件的元素作为一个新的数组返回

let arr = [1, 2, 3, 4, 5]

     const isBigEnough => value => value >= 3

     let newArr = arr.filter(isBigEnough )

     //newNum = [3, 4, 5] 满足条件的元素返回为一个新的数组


reduce()此方法是所有元素调用返回函数,返回值为最后结果,传入的值必须是函数类型:

let arr = [1, 2, 3, 4, 5]

    const add = (a, b) => a + b

    let sum = arr.reduce(add)

    //sum = 15  相当于累加的效果

    与之相对应的还有一个 Array.reduceRight() 方法,区别是这个是从右向左操作的


push/pop

push:数组后面添加新元素,改变数组的长度

pop:数组删除最后一个元素 。 也改变长度


shift/unshift

shift:删除第一个元素 。 改变数组的长度

unshift:将一个或多个添加到数组开头 。 返回数组长度


isArray:返回bool

cancat:合并数组



toString:数组转字符串

join("--"):数组转字符串 。 间隔可以设置


splice(开始位置,删除个数,元素)万能方法 增删改

------------------------------------------------

判断是不是数组的方法

var arr = [1,2,3,1];

var arr2 = [{ abac : 1, abc : 2 }];

function isArrayFn(value){

if (typeof Array.isArray === "function") {

//判断是否支持isArray ie8之前不支持

return Array.isArray(value);

}else{

return Object.prototype.toString.call(value) === "[object Array]";

}

}

alert(isArrayFn(arr));// true

alert(isArrayFn(arr2));// true


收起阅读 »

SDWebImage加载多张高分辨图片crash

项目中有一个控制器里的图片服务器那边没有进行压缩 所以使用SDWebImage显示在collectionView/tableView的时候有时会crash(及时没有反复进几次就会crash了)。网上查了很多资料,大致总结有一下几种方法:1、每次加载高清图片时清...
继续阅读 »

项目中有一个控制器里的图片服务器那边没有进行压缩 所以使用SDWebImage显示在collectionView/tableView的时候有时会crash(及时没有反复进几次就会crash了)。网上查了很多资料,大致总结有一下几种方法:

1、每次加载高清图片时清空memcache

[[SDImageCache sharedImageCache] setValue:nil forKey:@"memCache"];

但是这种方法会产生一个效果:当滑动tableView的时候 cell消失在屏幕中再滑回来图片会从新加载。

2.取消解压缩

[SDImageCache sharedImageCache].shouldDecompressImages = NO
[SDWebImageDownloader sharedDownloader].shouldDecompressImages = NO;

之所以产生crash的原因,是因为在SDWebImage里的这个方法decodedImageWithImage在加载高清图片是占用了大量内存。所以上面的两行代码就禁止调用了这个方法,那么问题来了,那这个方法存在的意义又是什么呢?

因为我们对图片的展示大部分是在tableviews/collectionview里 其实decodedImageWithImage方法是对图片进行解压缩并且缓存起来,以提高流畅度。但是加载高分辨率的图片就会起到适得其反的效果。所以在加载高分辨率图片的地方调用以上两个方法,其他地方仍然保持为YES就可以了。如果再限制图片内存缓存最高限制就更安全了


3.对图片进行等比例压缩(需修改源码)


这里面对图片的处理是直接按照原大小进行的,如果分辨率很大这里导致占用了大量内存。所以我们需要在这里对图片做一次等比的压缩。

在UIImage+MultiFormat这个类里面添加如下压缩方法

+(UIImage *)compressImageWith:(UIImage *)image{
float imageWidth = image.size.width;
float imageHeight = image.size.height;
float width = 640;
float height = image.size.height/(image.size.width/width);
float widthScale = imageWidth /width;
float heightScale = imageHeight /height;
// 创建一个bitmap的context
// 并把它设置成为当前正在使用的context
UIGraphicsBeginImageContext(CGSizeMake(width, height));
if (widthScale > heightScale) {
[image drawInRect:CGRectMake(0, 0, imageWidth /heightScale , height)];
}
else {
image drawInRect:CGRectMake(0, 0, width , imageHeight /widthScale)];
}
// 从当前context中创建一个改变大小后的图片
UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();
// 使当前的context出堆栈
UIGraphicsEndImageContext();
return newImage;
}

在上图箭头位置这样调用

image = [[UIImage alloc] initWithData:data];
if (data.length/1024 > 128) {
image = [self compressImageWith:image];
}

到了这里还需要进行最后一步。就是在SDWebImageDownloaderOperation的connectionDidFinishLoading方法里面的:

UIImage *image = [UIImage sd_imageWithData:self.imageData];
//将等比压缩过的image在赋在转成data赋给self.imageData
NSData *data = UIImageJPEGRepresentation(image, 1);
self.imageData = [NSMutableData dataWithData:data];

但是我在尝试这个方法的时候只这样操作的话还是会crash,所以还是要配合下面这个方法使用,所以那个郁闷啊!!!!大家也可以尝试一下

[[SDImageCache sharedImageCache] setValue:nil forKey:@"memCache"];
最终我是选择了第二种。欢迎补充!

链接:https://www.jianshu.com/p/7013919c03eb

收起阅读 »

集成环信IM后,iOS没有通知栏提示?

通知栏分本地通知和apns推送通知 情况1:App进入后台没有超过150秒左右,接收消息没有通知栏提示,这种情况要配置本地通知,先注册本地通知(系统方法),然后再看下文档介绍: 如何实现本地通知 情况2:不是本地通知,App退到后台超过150秒左右,接收消息没...
继续阅读 »


通知栏分本地通知apns推送通知


情况1:App进入后台没有超过150秒左右,接收消息没有通知栏提示,这种情况要配置本地通知,先注册本地通知(系统方法),然后再看下文档介绍: 如何实现本地通知


情况2:不是本地通知,App退到后台超过150秒左右,接收消息没有通知栏提示,这个情况属于apns推送。


如果没有配置apns推送,先按照文档配置: APNS推送配置
如果配置过了apns推送,那么在初始化SDK方法之后调用下这个方法试下:

- (void)applicationDidEnterBackground:(UIApplication *)application
{
[[EMClient sharedClient] applicationDidEnterBackground:application];
}

- (void)applicationWillEnterForeground:(UIApplication *)application
{
[[EMClient sharedClient] applicationWillEnterForeground:application];
}




收起阅读 »

LinkedList源码解析(手把手带你熟悉链表)

前言链表是常见的数据结构之一,但是很多同学只听说过链表,并不知道什么是链表,所以本文将会带领各位同学手写一个LinkedList,源码跟官方会有点不一样,不过思路是大概相同的,最后再带领大家读官方源码为了降低源码难度简化泛型代码,手写的LinkedList只能...
继续阅读 »
  • 前言

链表是常见的数据结构之一,但是很多同学只听说过链表,并不知道什么是链表,所以本文将会带领各位同学手写一个LinkedList,源码跟官方会有点不一样,不过思路是大概相同的,最后再带领大家读官方源码

为了降低源码难度简化泛型代码,手写的LinkedList只能添加String类型数据

  • 什么是链表?

可以理解为,把一些数据按照顺序排好,手拉手 每一个数据就是一个节点,所有节点连在一起,就组成了链表 在这里插入图片描述

  • LinkedList的节点定义

LinkedList是双链表,所以有左节点和右节点,我们先定义一个实体类,这个实体类就可以理解为节点

    /**
* 节点实体类
*/

private static class NodeBean {
NodeBean leftNode; //左节点
String value; //节点的值
NodeBean rightNode; //右节点
}
复制代码
图片名称
  • 节点怎么连接?

在这里插入图片描述

看完上面的图片,应该大概知道两个节点的连接方法了,我们只需要:

节点1.右节点 = 节点2
节点2.左节点 = 节点1
复制代码

我们先实现链表的add方法,add方法其实就是将两个节点连接:

    /**
* 添加值
*/

public void add(String value) {
//先获取尾节点(上一个节点)
NodeBean lastNode = this.lastNode;
//创建一个新节点
NodeBean newNode = new NodeBean();
//为节点赋值
newNode.value = value;
//左节点为最后一个节点(尾节点)
newNode.leftNode = lastNode;
//由于是添加节点,所以右节点为null,可以不写
newNode.rightNode = null;
//将成员变量的最后一个节点改为当前新节点
this.lastNode = newNode;
//判断头节点是否为空
if (this.firstNode == null) {
//如果为空说明当前是第一个节点,需要把头结点也设为当前节点
this.firstNode = newNode;
}else{
//如果不为空,需要把前一个节点的右节点指向当前节点
//两个节点相连接的条件是:
// 1. 前一个节点的右节点指向当前节点
// 2. 当前节点的左节点指向上一个节点
lastNode.rightNode = newNode;
}
//链表长度+1
size++;
}
复制代码
  • 节点怎么断开?

两个节点断开只需要将自己的上一个节点的右节点指向自己的下一个节点左节点,同时自己的下一个节点的左节点,指向上一个节点的右节点

注意看下图箭头方向,这样节点2就可以直接断开,节点1和节点3直接连接,这里也可以很明显的看出,链表增删很快,只需要断开前后节点就可以

在这里插入图片描述

先看一下断开节点的大概代码思路:

节点2.左节点 = null
节点2.右节点 = null
节点1.右节点 = 节点3
节点3.左节点 = 节点1

这样就可以断开当前节点,并且将链表重新连接起来
复制代码

现在我们来实现remove(index)方法,根据索引删除指定节点:

    /**
* 删除值
*/

public void remove(int index){
这里代码通过索引查找节点,为了简化代码,请忽略这里的代码
通过索引查找节点,下面会写到
...
***indexNode就是我们通过索引拿到的节点***

indexNode = 通过索引查找节点(index)

//拿到该节点的左节点、右节点以及值
NodeBean leftNode = indexNode.leftNode;
NodeBean rightNode = indexNode.rightNode;

//判断左节点是否为空,如果为空说明当前节点为(头结点)第一个节点
if (leftNode == null) {
this.firstNode = indexNode;
}else{
//左节点不为空,需要断开自己的左节点
indexNode.leftNode = null;
//将上一个节点的右节点连接到下一个节点
leftNode.rightNode = rightNode;
}

//判断右节点是否为空,如果为空说明当前为(尾节点)最后一个节点
if (rightNode == null) {
this.lastNode = indexNode;
}else{
//右节点不为空,需要断开自己的右节点
indexNode.rightNode = null;
//将下一个节点的左节点连接到上一个节点
rightNode.leftNode = leftNode;
}

//当前节点值置空
indexNode.value = null;
size--;
}
复制代码
  • 通过索引查找节点

1. 先拿到头节点
2. 拿到当前要查找的索引index
3. 循环index的次数
4. 每循环一次,就从头结点开始往后移动一个节点
复制代码

现在我们来实现以下get(index)方法,通过索引获取置顶节点:

    /**
* 通过索引获取节点的值
*/

public String get(int index){
//由于链表没有索引,所以只能一个一个遍历查找
//先拿到链表的第一个节点(头节点)
NodeBean firstNode = this.firstNode;
for (int i = 0; i < index; i++) {
//每次循环就从头结点往后挪动一个节点
firstNode = firstNode.rightNode;
}
//为了简化代码便于理解,这里不考虑tempNode为null的情况
return firstNode.value;
}
复制代码
  • 总结

  1. 链表的每个节点之间都有连接,如果新增节点只需要直接插入就行,所以==链表新增快==
  2. 链表断开节点只需要将自己的前后节点重新连接就可以,所以链表==删除快==
  3. 链表没有索引,查找需要循环整个链表,所以==查询慢==
  • MyLinkedList完整代码:

public class MyLinkedList {
private int size; //当前链表的长度
private NodeBean firstNode; //头节点
private NodeBean lastNode; //尾节点

/**
* 添加值
*/

public void add(String value) {
//先获取尾节点
NodeBean lastNode = this.lastNode;
//创建一个新节点
NodeBean newNode = new NodeBean();
//为节点赋值
newNode.value = value;
//左节点为最后一个节点(尾节点)
newNode.leftNode = lastNode;
//由于是添加节点,所以右节点为null,可以不写
newNode.rightNode = null;
//将成员变量的最后一个节点改为当前新节点
this.lastNode = newNode;
//判断头节点是否为空
if (this.firstNode == null) {
//如果为空说明当前是第一个节点,需要把头结点也设为当前节点
this.firstNode = newNode;
}else{
//如果不为空,需要把前一个节点的右节点指向当前节点
//两个节点相连接的条件是:
// 1. 前一个节点的右节点指向当前节点
// 2. 当前节点的左节点指向上一个节点
lastNode.rightNode = newNode;
}
//链表长度+1
size++;
}

/**
* 删除值
*/

public void remove(int index){
//先找到当前索引对应的节点
//由于链表没有索引,所以只能一个一个遍历查找
//先拿到链表的第一个节点(头节点)
NodeBean indexNode = this.firstNode;
for (int i = 0; i < index; i++) {
//每次循环就从头结点往后挪动一个节点
indexNode = indexNode.rightNode;
}
//拿到该节点的左节点、右节点以及值
NodeBean leftNode = indexNode.leftNode;
NodeBean rightNode = indexNode.rightNode;

//判断左节点是否为空,如果为空说明当前节点为(头结点)第一个节点
if (leftNode == null) {
this.firstNode = indexNode;
}else{
//左节点不为空,需要断开自己的左节点
indexNode.leftNode = null;
//将上一个节点的右节点连接到下一个节点
leftNode.rightNode = rightNode;
}

//判断右节点是否为空,如果为空说明当前为(尾节点)最后一个节点
if (rightNode == null) {
this.lastNode = indexNode;
}else{
//右节点不为空,需要断开自己的右节点
indexNode.rightNode = null;
//将下一个节点的左节点连接到上一个节点
rightNode.leftNode = leftNode;
}

//当前节点值置空
indexNode.value = null;
size--;
}

/**
* 通过索引获取节点的值
*/

public String get(int index){
//由于链表没有索引,所以只能一个一个遍历查找
//先拿到链表的第一个节点(头节点)
NodeBean firstNode = this.firstNode;
for (int i = 0; i < index; i++) {
//每次循环就从头结点往后挪动一个节点
firstNode = firstNode.rightNode;
}
//为了简化代码便于理解,这里不考虑tempNode为null的情况
return firstNode.value;
}

/**
* 获取链表长度
*/

public int getSize(){
return this.size;
}

/**
* 节点实体类
*/

private static class NodeBean {
NodeBean leftNode; //左节点
String value; //节点的值
NodeBean rightNode; //右节点
}
}

复制代码
  • 重点

  • 如果你看完上面的增删查方法, 可以完全看懂了,就可以继续往下看了
  • 如果你没看懂,请复制上面的完整代码到编辑器,自己断点研究一下

==如果上面的代码理解了,恭喜你! 现在你应该已经可以看懂官方源码了==

  • 官方代码解析

  • 节点实体类

    private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;

Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
复制代码

代码对比 在这里插入图片描述

  • 插入节点

    void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
复制代码

代码对比 在这里插入图片描述

  • 断开节点

    E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;

if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}

if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}

x.item = null;
size--;
modCount++;
return element;
}
复制代码

代码对比 在这里插入图片描述

  • 最后总结

  • 如果现在你可以看懂官方的这三个方法了,那可以尝试自己去读剩下的部分方法,比如==unlinkFirst()和linkFirst()==
  • 读源码并不可怕,只要理解源码的思路,顿时豁然开朗
收起阅读 »

Android 动态化多语言框架,支持语言包的动态下发、升级、删除,一处安装,到处使用

MLang 动态化多语言框架MLang 是 MultiLanguage 的简写,是一款动态化的多语言框架。设计优雅 语言包存储格式为 xml 格式,和 res 下的 strings.xml 一致 零依赖,完全使用系统 api 和系统的 xml 解析器 不持有 ...
继续阅读 »


MLang 动态化多语言框架

MLang 是 MultiLanguage 的简写,是一款动态化的多语言框架。

设计优雅

  •  语言包存储格式为 xml 格式,和 res 下的 strings.xml 一致
  •  零依赖,完全使用系统 api 和系统的 xml 解析器
  •  不持有 context,无内存泄漏
  •  静态方法 + 单例模式,一处安装,到处使用

动态化语言包

  •  动态下发语言包
  •  语言包的增加、升级、删除
  •  语言包内部任意字符串的增加、升级、删除
  •  自定义语言包的存储路径

完全兼容

  •  跟随系统语言
  •  时间格式跟随系统的 24 小时制
  •  处理各种语言的时区、时间格式化问题
  •  处理各种语言的复数格式化问题
  •  处理各种语言的阅读顺序问题(从左到右、从右到左)

1. 使用

使用字符串

// 本地和云端都存在的字符串
MyLang.getString("local_string", R.string.local_string)

// 云端存在 remote_string_only
// 但本地没有 R.string.remote_string_only,用 R.string.fallback_string 代替
MyLang.getString("remote_string_only", R.string.fallback_string)

使用语言包

//应用一种语言(这里自动处理了语言包的升级、语言包内部字符串的升级)
MyLang.getInstance().applyLanguage(Context, LocaleInfo, force=true, init=false);

//删除一种语言
MyLang.getInstance().deleteLanguage(Context, LocaleInfo);

LocaleInfo 可以在以下地方找到

//1. 所有云端的语言包
MyLang.getInstance().remoteLanguages

//2. 所有下载到本地、可用的语言包
MyLang.getInstance().languages

//3. 所有非官方的语言包
MyLang.getInstance().unofficialLanguages

//4. 除内置支持的语言外,另外安装的云端的语言包
MyLang.getInstance().otherLanguages

2. 安装

2.1. 引入

//build.gradle
allprojects {
repositories {
google()
jcenter()
maven { url "https://github.com/LinXueyuanStdio/MLang/raw/main/dist/" }
}
}

//app/build.gradle
implementation 'com.timecat.component:MLang:2.0.2'

2.2. 在 Application 中初始化,并监听系统语言的更改(如果跟随系统语言的话):

public class MyApplication extends Application {
@SuppressLint("StaticFieldLeak")
public static volatile Context applicationContext;
public static volatile Handler applicationHandler;

@Override
public void onCreate() {
super.onCreate();
applicationContext = this;
applicationHandler = new Handler(applicationContext.getMainLooper());
MyLang.init(applicationContext);
}

@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
MyLang.onConfigurationChanged(newConfig);
}
}

其中建议自己新建一个静态类 MyLang 来代理 MLang。 这样有两个好处:

  1. 隔绝 MLang 的 api 变化,提高兼容性和稳定性。
  2. 使用更简洁。MLang 不持有 context,但每次获取字符串为空时,需要 context 来兜底,获取本地的字符串。在自己的 MyLang 默认提供 application Context,可以不用到处提供 context,更简洁。
public class MyLang {
private static File filesDir;
private static LangAction action;
public static void init(@NonNull Context applicationContext) {
filesDir = applicationContext.getCacheDir();
action = new MyLangAction();
getInstance();
}
public static MLang getInstance() {
return MLang.getInstance(MyApplication.applicationContext, filesDir, action);
}
public static void onConfigurationChanged(@NonNull Configuration newConfig) {
getInstance().onDeviceConfigurationChange(getContext(), newConfig);
}
}

3. 设计

3.1. 单例模式接收 3 个参数,context,fileDir,action

  1. context:MLang 内部不持有该 context。该 context 用于注册时区广播(根据时区来格式化字符串中的时间)、 判断系统当前时间是否 24 小时制等等。
  2. filesDir:持久化语言包文件的存储地址。语言包文件是 xml 格式,和 res 下的 strings.xml 一样。
  3. action:action 包含了应用语言包、切换语言等等需要的所有回调,即 LangAction 接口。
MLang.getInstance(context, filesDir, action);

3.2. LangAction 接口定义了 2 个东西

  1. 当前语言的设置存储。 MLang 根据语言 id (string) 来识别当前语言。语言 id 需要持久化。 所以设计了下面两个方法,可以自行决定持久化的方式(SharedPreferences、MMKV、SQLite等等)。
    void saveLanguageKeyInLocal(String language);
    @Nullable String loadLanguageKeyInLocal();
  2. 必要的网络接口。
    void langpack_getDifference(String lang_pack, String lang_code, int from_version, @NonNull final LangAction.GetDifferenceCallback callback)
    void langpack_getLanguages(@NonNull final LangAction.GetLanguagesCallback callback)
    void langpack_getLangPack(String lang_code, @NonNull final LangAction.GetLangPackCallback callback)

LangAction 的注释如下:

public interface LangAction {
/**
* SharedPreferences preferences = Utilities.getGlobalMainSettings();
* SharedPreferences.Editor editor = preferences.edit();
* editor.putString("language", language);
* editor.commit();
* @param language localeInfo.getKey() 语言 id
*/
void saveLanguageKeyInLocal(String language);

/**
* SharedPreferences preferences = Utilities.getGlobalMainSettings();
* String lang = preferences.getString("language", null);
* @return @Nullable lang 语言 id
*/
String loadLanguageKeyInLocal();

/**
* 在其他线程网络请求,在主线程或UI线程调用callback
* 这里设计成这样,是因为这个方法里支持异步执行
* 您需要在合适的时机手动调用 callback,且只能调用一次
* @param lang_pack 语言包名字
* @param lang_code 语言包版本名称
* @param from_version 语言包版本号
* @param callback @NonNull 在主线程或UI线程调用
*/
void langpack_getDifference(String lang_pack, String lang_code, int from_version, GetDifferenceCallback callback);

/**
* 在其他线程网络请求,在主线程或UI线程调用callback
* 这里设计成这样,是因为这个方法里支持异步执行
* 您需要在合适的时机手动调用 callback,且只能调用一次
* @param callback @NonNull 在主线程或UI线程调用
*/
void langpack_getLanguages(GetLanguagesCallback callback);

/**
* 在其他线程网络请求,在主线程或UI线程调用callback
* 这里设计成这样,是因为这个方法里支持异步执行
* 您需要在合适的时机手动调用 callback,且只能调用一次
* @param lang_code 语言包版本名称
* @param callback @NonNull 在主线程或UI线程调用
*/
void langpack_getLangPack(String lang_code, GetLangPackCallback callback);

interface GetLanguagesCallback {
/**
* 必须在UI线程或者主线程调用
* 所有可用的语言包
* @param languageList 语言包列表
*/
void onLoad(List<LangPackLanguage> languageList);
}

interface GetDifferenceCallback {
/**
* 必须在UI线程或者主线程调用
* 如果服务端没有实现增量分发的功能,可以用完整的语言包代替
* @param languageList 增量的语言包
*/
void onLoad(LangPackDifference languageList);
}

interface GetLangPackCallback {
/**
* 必须在UI线程或者主线程调用
* @param languageList 完整的语言包
*/
void onLoad(LangPackDifference languageList);
}

}

实现LangAction的一个示例如下:

public class MyLangAction implements LangAction {
@Override
public static void saveLanguageKeyInLocal(String language) {
SharedPreferences preferences = getContext().getSharedPreferences("language_locale", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.putString("language", language);
editor.apply();
}

@Override
@Nullable
public static String loadLanguageKeyInLocal() {
SharedPreferences preferences = getContext().getSharedPreferences("language_locale", Context.MODE_PRIVATE);
return preferences.getString("language", null);
}
@Override
public void langpack_getDifference(String lang_pack, String lang_code, int from_version, @NonNull final LangAction.GetDifferenceCallback callback) {
Server.request_langpack_getDifference(lang_pack, lang_code, from_version, new Server.GetDifferenceCallback() {
@Override
public void onNext(final LangPackDifference difference) {
callback.onLoad(difference);
}
});
}

@Override
public void langpack_getLanguages(@NonNull final LangAction.GetLanguagesCallback callback) {
Server.request_langpack_getLanguages(new Server.GetLanguagesCallback() {
@Override
public void onNext(final List<LangPackLanguage> languageList) {
callback.onLoad(languageList);
}
});
}

@Override
public void langpack_getLangPack(String lang_code, @NonNull final LangAction.GetLangPackCallback callback) {
Server.request_langpack_getLangPack(lang_code, new Server.GetLangPackCallback() {
@Override
public void onNext(final LangPackDifference difference) {
callback.onLoad(difference);
}
});
}
}

3.3. 服务器语言包的结构

模拟的服务器数据

语言包实体

  • LangPackLanguage(name, version, ...)

语言包的数据

  • LangPackDifference(name, version, List, ...)
  • LangPackString(key: String, value: String)
public class Server {
public static LangPackLanguage chineseLanguage() {
LangPackLanguage langPackLanguage = new LangPackLanguage();
langPackLanguage.name = "chinese";
langPackLanguage.native_name = "简体中文";
langPackLanguage.lang_code = "zh";
langPackLanguage.base_lang_code = "zh";
return langPackLanguage;
}
public static LangPackDifference chinesePackDifference() {
LangPackDifference difference = new LangPackDifference();
difference.lang_code = "zh";
difference.from_version = 0;
difference.version = 1;
difference.strings = chineseStrings();
return difference;
}
public static ArrayList<LangPackString> chineseStrings() {
ArrayList<LangPackString> list = new ArrayList<>();
list.add(new LangPackString("LanguageName", "中文简体"));
list.add(new LangPackString("LanguageNameInEnglish", "Chinese"));
list.add(new LangPackString("local_string", "中文的云端字符串"));
list.add(new LangPackString("remote_string_only", "本地缺失,云端存在的字符串"));
return list;
}
}

4. 进阶配置

MLang.isRTL = false; //是否从右到左阅读(默认 false)
MLang.is24HourFormat = false; //是否 24 小时制(默认 false) 

MLang.USE_CLOUD_STRINGS = true; //是否使用云端字符串(默认 true)

代码下载:MLang-main.zip


收起阅读 »

一个模仿即刻App用户头像拖动效果的工具类

SnakeViewMakerSnakeViewMaker 是一个模仿即刻App里用户头像拖动效果的工具类。调用方法:1.创建 SnakeViewMaker; SnakeViewMaker snakeViewMaker = new SnakeViewMak...
继续阅读 »

SnakeViewMaker

SnakeViewMaker 是一个模仿即刻App里用户头像拖动效果的工具类。

image

调用方法:

1.创建 SnakeViewMaker;

    SnakeViewMaker snakeViewMaker = new SnakeViewMaker(MainActivity.this);

2.绑定

    snakeViewMaker
.addTargetView(imageView) // 绑定目标View
.attachToRootLayout((ViewGroup) findViewById(R.id.root)); // 绑定Activity/Fragment的根布局

3.其他相关API

    snakeViewMaker.detachSnake();                // 解除绑定
snakeViewMaker.updateSnakeImage(); // 当目标View的视图发生变化时,调用此方法用以更新Snake视图状态
snakeViewMaker.interceptTouchEvent(true); // Snake拖动过程中是否需要屏蔽其他onTouch事件,默认屏蔽
snakeViewMaker.setVisibility(View.VISIBLE); // 控制可见性
snakeViewMaker.setClickable(true); // 控制可点击
snakeViewMaker.setEnabled(true); // 控制可触摸

注意事项

1.目前不支持LinearLayout根布局

2.加载本地图片可直接调用。网络图片需要在图片加载完成后才能调用,不然可能出现绑定不成功的情况

例如,用glide加载网络图片时,调用时机如下:

    snakeViewMaker = new SnakeViewMaker(MainActivity.this);
Glide.with(this).load(url).asBitmap()
.into(new BitmapImageViewTarget(imageView) {
@Override
protected void setResource(Bitmap resource) {
super.setResource(resource);
snakeViewMaker.addTargetView(imageView)
.attachToRootLayout((ViewGroup) findViewById(R.id.root));
}
});


代码下载:SnakeViewMaker-master.zip

收起阅读 »

Android修炼系列(十二),自定义一个超顺滑的回弹RecyclerView

前面写了一个嵌套滑动框架和分析了ViewDragHelper的事件分发,本节主要自定义一个带有回弹效果的RecyclerView,看看事件和动画的配合,这在各大App中都比较常见了,效果如下: 实现 这是定义的回弹类:OverScrollRecycl...
继续阅读 »

前面写了一个嵌套滑动框架和分析了ViewDragHelper的事件分发,本节主要自定义一个带有回弹效果的RecyclerView,看看事件和动画的配合,这在各大App中都比较常见了,效果如下:





实现


这是定义的回弹类:OverScrollRecyclerView,其是RecyclerView的子类,并实现了OnTouchListener方法:


public class OverScrollRecyclerView extends RecyclerView implements View.OnTouchListener {

public OverScrollRecyclerView(Context context) {
this(context, null);
}

public OverScrollRecyclerView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public OverScrollRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initParams();
}
}
复制代码

随后会定义一些必要的属性,其中DEFAULT_TOUCH_DRAG_MOVE_RATIO表示滑动的像素数与实际view偏移量的比例,减速系数和时间也都是根据实际效果不断调整的。


```java
public class OverScrollRecyclerView extends RecyclerView implements View.OnTouchListener {

// 下拉与上拉,move px / view Translation
private static final float DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD = 2f;
private static final float DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK = 1f;
// 默认减速系数
private static final float DEFAULT_DECELERATE_FACTOR = -2f;
// 最大反弹时间
private static final int MAX_BOUNCE_BACK_DURATION_MS = 800;
private static final int MIN_BOUNCE_BACK_DURATION_MS = 200;

// 初始状态,滑动状态,回弹状态
private IDecoratorState mCurrentState;
private IdleState mIdleState;
private OverScrollingState mOverScrollingState;
private BounceBackState mBounceBackState;

private final OverScrollStartAttributes mStartAttr = new OverScrollStartAttributes();
private float mVelocity;
private final RecyclerView mRecyclerView = this;
...
public OverScrollRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initParams();
}
...
}

复制代码

这是我们的状态接口IDecoratorState,其提供了3个方法,IdleState、OverScrollingState、BounceBackState都是它的具体实现类,符合状态模式的思想:


    protected interface IDecoratorState {
// 处理move事件
boolean handleMoveTouchEvent(MotionEvent event);
// 处理up事件
boolean handleUpTouchEvent(MotionEvent event);
// 事件结束后的动画处理
void handleTransitionAnim(IDecoratorState fromState);
}
复制代码

初始化我们定义的变量,没有什么特殊的操作,只是一些各自属性的赋值,具体见下文:


    private void initParams() {
mBounceBackState = new BounceBackState();
mOverScrollingState = new OverScrollingState();
mCurrentState = mIdleState = new IdleState();
attach();
}
复制代码

这是我们的attach,添加触摸监听,并去掉滚动到边缘的光晕效果:


    @SuppressLint("ClickableViewAccessibility")
public void attach() {
mRecyclerView.setOnTouchListener(this);
mRecyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
}
复制代码

核心代码就是事件的监听了,需要我们处理onTouch事件,当手指按下滑动时,此时mCurrentState还处于初始状态,其会执行相应的handleMoveTouchEvent方法:


    @Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
return mCurrentState.handleMoveTouchEvent(event);
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
return mCurrentState.handleUpOrCancelTouchEvent(event);
}
return false;
}
复制代码

这是初始状态IdleState处理move的逻辑,主要做些校验工作,如果移动不满足要求,就将事件透出去,具体见下:


    @Override
public boolean handleMoveTouchEvent(MotionEvent event) {
// 是否符合move要求,不符合不拦截事件
if (!initMotionAttributes(mRecyclerView, mMoveAttr, event)) {
return false;
}
// 在RecyclerView顶部但不能下拉 或 在RecyclerView底部但不能上拉
if (!((isInAbsoluteStart(mRecyclerView) && mMoveAttr.mDir) ||
(isInAbsoluteEnd(mRecyclerView) && !mMoveAttr.mDir))) {
return false;
}
// 保存当前Motion信息
mStartAttr.mPointerId = event.getPointerId(0);
mStartAttr.mAbsOffset = mMoveAttr.mAbsOffset;
mStartAttr.mDir = mMoveAttr.mDir;
// 初始状态->滑动状态
issueStateTransition(mOverScrollingState);
return mOverScrollingState.handleMoveTouchEvent(event);
}
复制代码

这是initMotionAttributes方法,会计算Y方向偏移量,如果满足要求,则为MotionAttributes赋值:


    private boolean initMotionAttributes(View view, MotionAttributes attributes, MotionEvent event) {
if (event.getHistorySize() == 0) {
return false;
}
// 像素偏移量
final float dy = event.getY(0) - event.getHistoricalY(0, 0);
final float dx = event.getX(0) - event.getHistoricalX(0, 0);
if (Math.abs(dy) < Math.abs(dx)) {
return false;
}
attributes.mAbsOffset = view.getTranslationY();
attributes.mDeltaOffset = dy;
attributes.mDir = attributes.mDeltaOffset > 0;
return true;
}
复制代码

这里的isInAbsoluteStart方法用来判断,当前RecyclerView是否不能向下滑动,另一个isInAbsoluteEnd是否不能向上滑动,代码就不展示了:


    private boolean isInAbsoluteStart(View view) {
return !view.canScrollVertically(-1);
}
复制代码

当move事件通过初始状态的校验,则改变状态为滑动态OverScrollingState,正式处理滑动逻辑,其方法见下:


    @Override
public boolean handleMoveTouchEvent(MotionEvent event) {
final OverScrollStartAttributes startAttr = mStartAttr;
// 不是一个触摸点事件,则直接切到回弹状态
if (startAttr.mPointerId != event.getPointerId(0)) {
issueStateTransition(mBounceBackState);
return true;
}

final View view = mRecyclerView;

// 是否符合move要求
if (!initMotionAttributes(view, mMoveAttr, event)) {
return true;
}

// mDeltaOffset: 实际要移动的像素,可以为下拉和上拉设置不同移动比
float deltaOffset = mMoveAttr.mDeltaOffset / (mMoveAttr.mDir == startAttr.mDir
? mTouchDragRatioFwd : mTouchDragRatioBck);
// 计算偏移
float newOffset = mMoveAttr.mAbsOffset + deltaOffset;

// 上拉下拉状态与滑动方向不符,则回到初始状态,并将视图归位
if ((startAttr.mDir && !mMoveAttr.mDir && (newOffset <= startAttr.mAbsOffset)) ||
(!startAttr.mDir && mMoveAttr.mDir && (newOffset >= startAttr.mAbsOffset))) {
translateViewAndEvent(view, startAttr.mAbsOffset, event);
issueStateTransition(mIdleState);
return true;
}

// 不让父类截获move事件
if (view.getParent() != null) {
view.getParent().requestDisallowInterceptTouchEvent(true);
}

// 计算速度
long dt = event.getEventTime() - event.getHistoricalEventTime(0);
if (dt > 0) {
mVelocity = deltaOffset / dt;
}

// 改变控件位置
translateView(view, newOffset);
return true;
}
复制代码

这是translateView方法,改变view相对父布局的偏移量:


    private void translateView(View view, float offset) {
view.setTranslationY(offset);
}
复制代码

当滑动事件结束,手指抬起时,会将状态由滑动状态切换为回弹状态:


    @Override
public boolean handleUpTouchEvent(MotionEvent event) {
// 事件up切换状态,有滑动态-回弹态
issueStateTransition(mBounceBackState);
return false;
}
复制代码

上文提到的issueStateTransition方法,只是说切换了状态,但实际上它还会执行handleTransitionAnim的操作,只不过初始状态和滑动状态此接口都是空实现,只有回弹状态才会去处理动画效果罢了:


    protected void issueStateTransition(IDecoratorState state) {
IDecoratorState oldState = mCurrentState;
mCurrentState = state;
// 处理回弹动画效果
mCurrentState.handleTransitionAnim(oldState);
}
复制代码

这是我们处理动画效果的方法,核心方法createAnimator具体看下,之后添加了动画监听,并开启动画:


    @Override
public void handleTransitionAnim(IDecoratorState fromState) {
Animator bounceBackAnim = createAnimator();
bounceBackAnim.addListener(this);
bounceBackAnim.start();
}
复制代码

这是动画创建的核心类,使用了属性动画,先由当前速度mVelocity->0,随后回弹slowdownEndOffset->mStartAttr.mAbsOffset,具体代码见下:


    private Animator createAnimator() {
initAnimationAttributes(view, mAnimAttributes);

// 速度为0了或手势记录的状态与mDir不符合,直接回弹
if (mVelocity == 0f || (mVelocity < 0 && mStartAttr.mDir) || (mVelocity > 0 && !mStartAttr.mDir)) {
return createBounceBackAnimator(mAnimAttributes.mAbsOffset);
}

// 速度减到0,即到达最大距离时,需要的动画事件
float slowdownDuration = (0 - mVelocity) / mDecelerateFactor;
slowdownDuration = (slowdownDuration < 0 ? 0 : slowdownDuration);

// 速度减到0,动画的距离,dx = (Vt^2 - Vo^2) / 2a
float slowdownDistance = -mVelocity * mVelocity / mDoubleDecelerateFactor;
float slowdownEndOffset = mAnimAttributes.mAbsOffset + slowdownDistance;

// 开始动画,减速->回弹
ObjectAnimator slowdownAnim = createSlowdownAnimator(view, (int) slowdownDuration, slowdownEndOffset);
ObjectAnimator bounceBackAnim = createBounceBackAnimator(slowdownEndOffset);
AnimatorSet wholeAnim = new AnimatorSet();
wholeAnim.playSequentially(slowdownAnim, bounceBackAnim);
return wholeAnim;
}
复制代码

这是具体的减速动画方法,设置时间和差值器,就不细说了,不是本文的重点,直接见代码吧:


    private ObjectAnimator createSlowdownAnimator(View view, int slowdownDuration, float slowdownEndOffset) {
ObjectAnimator slowdownAnim = ObjectAnimator.ofFloat(view, mAnimAttributes.mProperty, slowdownEndOffset);
slowdownAnim.setDuration(slowdownDuration);
slowdownAnim.setInterpolator(mBounceBackInterpolator);
slowdownAnim.addUpdateListener(this);
return slowdownAnim;
}
复制代码

同样这是回弹动画的方法,设置时间和差值器,添加监听等,代码见下:


    private ObjectAnimator createBounceBackAnimator(float startOffset) {
float bounceBackDuration = (Math.abs(startOffset) / mAnimAttributes.mMaxOffset) * MAX_BOUNCE_BACK_DURATION_MS;
ObjectAnimator bounceBackAnim = ObjectAnimator.ofFloat(view, mAnimAttributes.mProperty, mStartAttr.mAbsOffset);
bounceBackAnim.setDuration(Math.max((int) bounceBackDuration, MIN_BOUNCE_BACK_DURATION_MS));
bounceBackAnim.setInterpolator(mBounceBackInterpolator);
bounceBackAnim.addUpdateListener(this);
return bounceBackAnim;
}
复制代码

当动画结束的时候,会将状态由回弹模式切换为初始状态,代码见下:


    @Override
public void onAnimationEnd(Animator animation) {
// 动画结束改变状态
issueStateTransition(mIdleState);
}
复制代码

好了,到这里核心逻辑就结束啦,应该不难理解吧。如果讲的不好,博客的栗子我都上传到了gitHub上,感兴趣的可以直接下载看下。



本文到这里,关于回弹效果的实现就结束了。如果本文对你有用,来点个赞吧,大家的肯定也是阿呆i坚持写作的动力。


作者:Battler
链接:https://juejin.cn/post/6953640372467662879
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android修炼系列(十一),强大的可拖拽工具类ViewDragHelper

demo实现效果图见下,可自由拖拽的view,还在自己造轮子吗?使用系统androidx包(原v4)下的ViewDragHelper 几行代码即可搞定.. 实现 ViewDragHelper是用于编写自定义ViewGroup的工具类。它提供了许多有用...
继续阅读 »

demo实现效果图见下,可自由拖拽的view,还在自己造轮子吗?使用系统androidx包(原v4)下的ViewDragHelper 几行代码即可搞定..





实现


ViewDragHelper是用于编写自定义ViewGroup的工具类。它提供了许多有用的操作和状态跟踪,以允许用户在其父级ViewGroup中拖动和重新放置视图,具体可见 官网API。好,那我们就开始自定义一个简单的ViewGroup,并创建ViewDragHelper,代码见下:


public class DragViewGroup extends RelativeLayout {

ViewDragHelper mDragHelper;

public DragViewGroup(Context context) {
this(context, null);
}

public DragViewGroup(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public DragViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragCallback());
}
...
}
复制代码

其中ViewDragCallback是我自己创建的内部类,继承自ViewDragHelper.Callback实现类。


private static class ViewDragCallback extends ViewDragHelper.Callback {
@Override
public boolean tryCaptureView(@NonNull View child, int pointerId) {
// 决定child是否可以被拖拽,具体见下文源码分析
return true;
}

@Override
public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
// 可决定child横向的偏移计算,见下文
return left;
}

@Override
public int clampViewPositionVertical(@NonNull View child, int top, int dy) {
// 可决定child竖向的偏移计算,见下文
return top;
}
}
复制代码

重写DragViewGroup的方法onInterceptHoverEvent和onTouchEvent方法:


public class DragViewGroup extends RelativeLayout {
...
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
return mDragHelper.shouldInterceptTouchEvent(event);
}

@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
return true;
}
...
}
复制代码

这是我们的layout文件,其中DragViewGroup是我们上面定义的ViewGroup,TextView就是待拖拽的child view。


<com.blog.a.drag.DragViewGroup
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<TextView
android:layout_width="70dp"
android:layout_height="70dp"
android:text="可拖拽"
android:gravity="center"
android:textColor="#fff"
android:background="#6495ED"
/>
</com.blog.a.drag.DragViewGroup>
复制代码

是不是非常省事,博客的栗子我都上传到了gitHub上,感兴趣的可以下载看下。


源码


本篇文章主要分析下,当触摸事件开始到结束,processTouchEvent的处理过程:


    public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
return true;
}
复制代码

MotionEvent.ACTION_DOWN


当手指刚接触屏幕时,会触发ACTION_DOWN 事件,通过MotionEvent我们能获取到点击事件发生的 x, y 坐标,注意这里的getX/getY的坐标是相对于当前view而言的。Pointer是触摸点的概念,一个MotionEvent可能会包含多个Pointer触摸点的信息,而每个Pointer触摸点都会有一个自己的id和index。具体往下看。


    case MotionEvent.ACTION_DOWN: {
final float x = ev.getX();
final float y = ev.getY();
final int pointerId = ev.getPointerId(0);
final View toCapture = findTopChildUnder((int) x, (int) y);

saveInitialMotion(x, y, pointerId);

tryCaptureViewForDrag(toCapture, pointerId);
// mTrackingEdges默认是0,可通过ViewDragHelper#setEdgeTrackingEnabled(int)
// 来设置,用来控制触碰边缘回调onEdgeTouched
final int edgesTouched = mInitialEdgesTouched[pointerId];
if ((edgesTouched & mTrackingEdges) != 0) {
mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
}
break;
}
复制代码

这里的findTopChildUnder方法是用来获取当前x, y坐标点所在的view,默认是最上层的,当然我们也可以通过callback#getOrderedChildIndex(int) 接口来自定义view遍历顺序,代码见下:


    public View findTopChildUnder(int x, int y) {
final int childCount = mParentView.getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
final View child = mParentView.getChildAt(mCallback.getOrderedChildIndex(i));
if (x >= child.getLeft() && x < child.getRight()
&& y >= child.getTop() && y < child.getBottom()) {
return child;
}
}
return null;
}
复制代码

这里的saveInitialMotion方法是用来保存当前触摸位置信息,其中getEdgesTouched方法用来判断x, y是否位于此viewGroup边缘之外,并返回保存相应result结果。todo:下篇准备写一下关于位运算符的文章,很有意思。


    private void saveInitialMotion(float x, float y, int pointerId) {
ensureMotionHistorySizeForId(pointerId);
mInitialMotionX[pointerId] = mLastMotionX[pointerId] = x;
mInitialMotionY[pointerId] = mLastMotionY[pointerId] = y;
mInitialEdgesTouched[pointerId] = getEdgesTouched((int) x, (int) y);
mPointersDown |= 1 << pointerId;
}
复制代码

其中tryCaptureViewForDrag方法内,mCapturedView是当前触摸的视图view,如果相同则直接返回,否则会进行mCallback#tryCaptureView(View, int)判断,这个是不是很眼熟,我们可以重写这个回调来控制toCapture这个view能否被捕获,即能否被拖拽操作。


    boolean tryCaptureViewForDrag(View toCapture, int pointerId) {
if (toCapture == mCapturedView && mActivePointerId == pointerId) {
// Already done!
return true;
}
if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {
mActivePointerId = pointerId;
captureChildView(toCapture, pointerId);
return true;
}
return false;
}
复制代码

这里的captureChildView方法用来保存信息,并设置拖拽状态。能注意到,这里还有个捕获view是否是child view的判断。


    public void captureChildView(@NonNull View childView, int activePointerId) {
if (childView.getParent() != mParentView) {
throw new IllegalArgumentException("captureChildView: parameter must be a descendant "
+ "of the ViewDragHelper's tracked parent view (" + mParentView + ")");
}

mCapturedView = childView;
mActivePointerId = activePointerId;
mCallback.onViewCaptured(childView, activePointerId);
setDragState(STATE_DRAGGING);
}
复制代码

MotionEvent.ACTION_POINTER_DOWN


当用户又使用一个手指接触屏幕时,会触发ACTION_POINTER_DOWN 事件,与上面的ACTION_DOWN 相似,就不细展开了。由于ViewDragHelper一次只能操作一个视图,所以这里会先进行状态判断,如果视图还未被捕获拖动,则逻辑与上面的ACTION_POINTER_DOWN一致,反之,会判断触摸点是否在当前视图内,如果符合条件,则更新Pointer,这里很重要,体现在ui效果上就是,一个手指按住view,另一个手指仍然可以拖拽此view。


    case MotionEvent.ACTION_POINTER_DOWN: {
final int pointerId = ev.getPointerId(actionIndex);
final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);

saveInitialMotion(x, y, pointerId);
// A ViewDragHelper can only manipulate one view at a time.
if (mDragState == STATE_IDLE) {
// If we're idle we can do anything! Treat it like a normal down event.
final View toCapture = findTopChildUnder((int) x, (int) y);
tryCaptureViewForDrag(toCapture, pointerId);

final int edgesTouched = mInitialEdgesTouched[pointerId];
if ((edgesTouched & mTrackingEdges) != 0) {
mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
}
} else if (isCapturedViewUnder((int) x, (int) y)) {
tryCaptureViewForDrag(mCapturedView, pointerId);
}
break;
}
复制代码

MotionEvent.ACTION_MOVE


当手指在屏幕移动时,如果视图正在被拖动,则会先判断当前mActivePointerId是否有效,无效则跳过当前move事件。随后获取当前x, y并计算与上次x, y移动距离。之后触发dragTo拖动逻辑,最后保存保存这次的位置。核心方法dragTo分析见下文:


    case MotionEvent.ACTION_MOVE: {
if (mDragState == STATE_DRAGGING) {
// If pointer is invalid then skip the ACTION_MOVE.
if (!isValidPointerForActionMove(mActivePointerId)) break;

final int index = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(index);
final float y = ev.getY(index);
final int idx = (int) (x - mLastMotionX[mActivePointerId]);
final int idy = (int) (y - mLastMotionY[mActivePointerId]);

dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);

saveLastMotion(ev);
} else {
// Check to see if any pointer is now over a draggable view.
...
}
break;
}
复制代码

在move过程中,通过dragTo方法来传入目标x, y 和横向和竖向的偏移量,并通过callback回调来通知开发者,开发者可重写clampViewPositionHorizontal与clampViewPositionVertical这两个回调方法,来自定义clampedX,clampedY目标位置。随后使用offsetLeftAndRight和offsetTopAndBottom 方法分别在相应的方向偏移(clampedX - oldLeft)和(clampedY - oldTo)的像素。最后触发onViewPositionChanged位置修改的回调。


    private void dragTo(int left, int top, int dx, int dy) {
int clampedX = left;
int clampedY = top;
final int oldLeft = mCapturedView.getLeft();
final int oldTop = mCapturedView.getTop();
if (dx != 0) {
clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
ViewCompat.offsetLeftAndRight(mCapturedView, clampedX - oldLeft);
}
if (dy != 0) {
clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
ViewCompat.offsetTopAndBottom(mCapturedView, clampedY - oldTop);
}

if (dx != 0 || dy != 0) {
final int clampedDx = clampedX - oldLeft;
final int clampedDy = clampedY - oldTop;
mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY,
clampedDx, clampedDy);
}
}
复制代码

如果当手指在屏幕移动时,发现视图未处于拖动状态呢?首先会去检查是否有其他Pointer是否有效。随后触发边缘拖动回调,随后再进行状态检查,应该是为了避免此时状态由未拖动->拖动状态了,如:smoothSlideViewTo方法就有这个能力。如果此时mDragState处于未拖动状态,则会重新获取x,y 所在视图view并重新设置拖拽状态,这个逻辑与down逻辑一样。


    case MotionEvent.ACTION_MOVE: {
if (mDragState == STATE_DRAGGING) {
// If pointer is invalid then skip the ACTION_MOVE.
...
} else {
// Check to see if any pointer is now over a draggable view.
final int pointerCount = ev.getPointerCount();
for (int i = 0; i < pointerCount; i++) {
final int pointerId = ev.getPointerId(i);

// If pointer is invalid then skip the ACTION_MOVE.
if (!isValidPointerForActionMove(pointerId)) continue;

final float x = ev.getX(i);
final float y = ev.getY(i);
final float dx = x - mInitialMotionX[pointerId];
final float dy = y - mInitialMotionY[pointerId];

reportNewEdgeDrags(dx, dy, pointerId);
if (mDragState == STATE_DRAGGING) {
// Callback might have started an edge drag.
break;
}

final View toCapture = findTopChildUnder((int) x, (int) y);
if (checkTouchSlop(toCapture, dx, dy)
&& tryCaptureViewForDrag(toCapture, pointerId)) {
break;
}
}
saveLastMotion(ev);
}
break;
}
复制代码

MotionEvent.ACTION_POINTER_UP


当处于多触摸点时,当一手指从屏幕上松开时,首先判断正在拖动视图的触摸点是否是当前触摸点,如果是,则再去检查视图上是否还有其他有效的触摸点,如果没有则释放,此时view就惯性停住了。如果还有,则清理当前up掉的触摸点数据。


    case MotionEvent.ACTION_POINTER_UP: {
final int pointerId = ev.getPointerId(actionIndex);
// 判断当前触摸点是否是正在拖动视图的触摸点
if (mDragState == STATE_DRAGGING && pointerId == mActivePointerId) {
// 检查是否有其他有效触摸点
int newActivePointer = INVALID_POINTER;
final int pointerCount = ev.getPointerCount();
// 遍历ev内触摸点
for (int i = 0; i < pointerCount; i++) {
final int id = ev.getPointerId(i);
if (id == mActivePointerId) {
// This one's going away, skip.
continue;
}

final float x = ev.getX(i);
final float y = ev.getY(i);
// 如果在视图上,并且可拖动,则标记找到了
if (findTopChildUnder((int) x, (int) y) == mCapturedView
&& tryCaptureViewForDrag(mCapturedView, id)) {
newActivePointer = mActivePointerId;
break;
}
}

if (newActivePointer == INVALID_POINTER) {
// 如果没有发现其他触摸点在拖拽视图view,则释放掉就可以了
releaseViewForPointerUp();
}
}
// 清理当前up掉的触摸点数据
clearMotionHistory(pointerId);
break;
}
复制代码

MotionEvent.ACTION_UP


当手指从屏幕上离开时,会先判断当前状态,如果此时mDragState处于拖动状态,则释放,view惯性停住。通过cancel方法改变状态,清空当前触摸点数据并接触速度检测mVelocityTracker。


    case MotionEvent.ACTION_UP: {
if (mDragState == STATE_DRAGGING) {
releaseViewForPointerUp();
}
cancel();
break;
}
复制代码

好了,本文到这里,关于ViewDrafHHelper的介绍就结束了。如果本文对你有用,来点个赞吧,大家的肯定也是阿呆i坚持写作的动力。


作者:Battler
链接:https://juejin.cn/post/6952062595767468039
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android修炼系列(十),事件分发从手写一个嵌套滑动框架开始

先放了一张效果图,是一个嵌套滑动的效果。博客的栗子我都上传到了gitHub上,感兴趣的可以下载看下。 在说代码之前,可以先看下最终的NestedViewGroup XML结构,NestedViewGroup内部包含顶部地图 MapView和滑动布局L...
继续阅读 »

先放了一张效果图,是一个嵌套滑动的效果。博客的栗子我都上传到了gitHub上,感兴趣的可以下载看下。





在说代码之前,可以先看下最终的NestedViewGroup XML结构,NestedViewGroup内部包含顶部地图 MapView和滑动布局LinearLayout,而LinearLayout布局的内部即我们常用的滑动控件 RecyclerView,在这里为何还要加层LinearLayout呢?这样做的好处是,我们可以更好的适配不同滑动控件,而不仅仅是将NestedViewGroup与RecyclerView 耦合住。


    <com.blog.a.nested.NestedViewGroup
android:id="@+id/dd_view_group"
android:layout_width="match_parent"
android:layout_height="match_parent"
didi:header_id="@+id/t_map_view"
didi:target_id="@+id/target_layout"
didi:inn_id="@+id/inner_rv"
didi:header_init_top="0"
didi:target_init_bottom="250">

<com.tencent.tencentmap.mapsdk.maps.MapView
android:id="@+id/t_map_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<LinearLayout
android:id="@+id/target_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="#fff">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/inner_rv"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>

</LinearLayout>

</com.mjzuo.views.nested.NestedViewGroup>
复制代码

实现


在attrs.xml文件下为NestedViewGroup添加自定义属性,其中header_id对应顶部地图 MapView,target_id对应滑动布局LinearLayout,inn_id对应滑动控件RecyclerView。


<resources>
<declare-styleable name="CompNsViewGroup">
<attr name="header_id"/>
<attr name="target_id"/>
<attr name="inn_id"/>
<attr name="header_init_top" format="integer"/>
<attr name="target_init_bottom" format="integer"/>
</declare-styleable>
</resources>
复制代码

我们根据attrs.xml中的属性,获取XML中NestedViewGroup中的View ID。


        // 获取配置参数
final TypedArray array = context.getTheme().obtainStyledAttributes(attrs
, R.styleable.CompNsViewGroup
, defStyleAttr, 0);
mHeaderResId = array.getResourceId
(R.styleable.CompNsViewGroup_header_id, -1);
mTargetResId = array.getResourceId
(R.styleable.CompNsViewGroup_target_id, -1);
mInnerScrollId = array.getResourceId
(R.styleable.CompNsViewGroup_inn_id, -1);
if (mHeaderResId == -1 || mTargetResId == -1
|| mInnerScrollId == -1)
throw new RuntimeException("VIEW ID is null");
复制代码

我们根据attrs.xml中的属性,来初始化View的高度、距离等,计算高度时,需要考虑到状态栏因素:


        mHeaderInitTop = Utils.dip2px(getContext()
, array.getInt(R.styleable.CompNsViewGroup_header_init_top, 0));
mHeaderCurrTop = mHeaderInitTop;
// 屏幕高度 - 底部距离 - 状态栏高度
mTargetInitBottom = Utils.dip2px(getContext()
, array.getInt(R.styleable.CompNsViewGroup_target_init_bottom, 0));
// 注意:当前activity默认去掉了标题栏
mTargetInitTop = Utils.getScreenHeight(getContext()) - mTargetInitBottom
- Utils.getStatusBarHeight(getContext().getApplicationContext());
mTargetCurrTop = mTargetInitTop;
复制代码

通过上面获取到的View ID,我们能够直接引用到XML中的相关View实例,而后续的滑动,本质上就是针对该View所进行的一系列判断处理。


    @Override
protected void onFinishInflate() {
super.onFinishInflate();
mHeaderView = findViewById(mHeaderResId);
mTargetView = findViewById(mTargetResId);
mInnerScrollView = findViewById(mInnerScrollId);
}
复制代码

我们重写onMeasure方法,其不仅是给childView传入测量值和测量模式,还将我们自己测量的尺寸提供给父ViewGroup让其给我们提供期望大小的区域。


    @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

measureChildren(widthMeasureSpec, heightMeasureSpec);

int widthModle = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightModle = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

....

setMeasuredDimension(widthSize, heightSize);
}
复制代码

我们重写onLayout方法,给childView确定位置。需要注意的是,原始bottom不是height高度,而是又向下挪了mTargetInitTop,我们可以想象成,我们一直将mTargetView挪动到了屏幕下方看不到的地方。


    @Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int childCount = getChildCount();
if (childCount == 0)
return;
final int width = getMeasuredWidth();
final int height = getMeasuredHeight();

// 注意:原始bottom不是height高度,而是又向下挪了mTargetInitTop
mTargetView.layout(getPaddingLeft()
, getPaddingTop() + mTargetCurrTop
, width - getPaddingRight()
, height + mTargetCurrTop
+ getPaddingTop() + getPaddingBottom());

int headerWidth = mHeaderView.getMeasuredWidth();
int headerHeight = mHeaderView.getMeasuredHeight();
mHeaderView.layout((width - headerWidth)/2
, mHeaderCurrTop + getPaddingTop()
, (width + headerWidth)/2
, headerHeight + mHeaderCurrTop + getPaddingTop());
}
复制代码

此功能实现的核心即事件的分发和拦截了。在接收到事件时,如果上次滚动还未结束,则先停下。随后判断TargetView内的RecyclerView能否向下滑动,如果还能滑动,则不拦截事件,将事件传递给 TargetView。如果点击在Header区域,则不拦截事件,将事件传递给地图MapView。


    @Override
public boolean onInterceptTouchEvent(MotionEvent event) {

// 如果上次滚动还未结束,则先停下
if (!mScroller.isFinished())
mScroller.forceFinished(true);

// 不拦截事件,将事件传递给TargetView
if (canChildScrollDown())
return false;

int action = event.getAction();

switch (action) {
case MotionEvent.ACTION_DOWN:
mDownY = event.getY();
mIsDragging = false;
// 如果点击在Header区域,则不拦截事件
isDownInTop = mDownY <= mTargetCurrTop - mTouchSlop;
break;

case MotionEvent.ACTION_MOVE:
final float y = event.getY();
if (isDownInTop) {
return false;
} else {
startDragging(y);
}

break;

case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mIsDragging = false;
break;
}

return mIsDragging;
}
复制代码

当NestedViewGroup拦截事件后,会调用自身的onTouchEvent方法,逻辑与 onInterceptTouchEvent 类似,这里需要注意的是,当事件在ViewGroup内,我们要怎么手动分发给TargetView呢?代码见下:


    @Override
public boolean onTouchEvent(MotionEvent event) {
if (canChildScrollDown())
return false;

// 添加速度监听
acquireVelocityTracker(event);
int action = event.getAction();

switch (action) {
case MotionEvent.ACTION_DOWN:
mIsDragging = false;
break;

case MotionEvent.ACTION_MOVE:
...
break;

case MotionEvent.ACTION_UP:
if (mIsDragging) {
mIsDragging = false;
mVelocityTracker.computeCurrentVelocity(500, maxFlingVelocity);
final float vy = mVelocityTracker.getYVelocity();
// 滚动的像素数太大了,这里只滚动像素数的0.1
vyPxCount = (int)(vy/3);
finishDrag(vyPxCount);
}
releaseVelocityTracker();
return false;

case MotionEvent.ACTION_CANCEL:
// 回收滑动监听
releaseVelocityTracker();
return false;
}

return mIsDragging;
}
复制代码

这是我们手指移动ACTION_MOVE 时的逻辑:


    final float y = event.getY();
startDragging(y);

if (mIsDragging) {
float dy = y - mLastMotionY;
if (dy >= 0) {
moveTargetView(dy);
} else if (mTargetCurrTop + dy <= 0) {
/**
* 此时,事件在ViewGroup内,
* 需手动分发给TargetView
*/
moveTargetView(dy);
int oldAction = event.getAction();
event.setAction(MotionEvent.ACTION_DOWN);
dispatchTouchEvent(event);
event.setAction(oldAction);
} else {
moveTargetView(dy);
}
mLastMotionY = y;
}
复制代码

通过canChildScrollDown方法,我们能够判断RecyclerView是否能够向下滑动。这里后续会抽出一个adapter类,来处理不同的滑动控件。这里通过canScrollVertically来判断当前视图是否可以继续滚动,其中正数表示实际是判断手指能否向上滑动,负数表示实际是判断手指能否向下滑动:


    public boolean canChildScrollDown() {
RecyclerView rv;
// 当前只做了RecyclerView的适配
if (mInnerScrollView instanceof RecyclerView) {
rv = (RecyclerView) mInnerScrollView;
return rv.canScrollVertically(-1);
}
return false;
}
复制代码

获取向上能够滑动的距离顶部距离,如果Item数量太少,导致rv不能占满一屏时,注意向上滑动的距离。


    public int toTopMaxOffset() {
final RecyclerView rv;
if (mInnerScrollView instanceof RecyclerView) {
rv = (RecyclerView) mInnerScrollView;
if (android.os.Build.VERSION.SDK_INT >= 18) {

return Math.max(0, mTargetInitTop -
(rv.computeVerticalScrollRange() - mTargetInitBottom));
}
}
return 0;
}
复制代码

手指向下滑动或TargetView距离顶部距离 > 0,则ViewGroup拦截事件。


    private void startDragging(float y) {
if (y > mDownY || mTargetCurrTop > toTopMaxOffset()) {
final float yDiff = Math.abs(y - mDownY);
if (yDiff > mTouchSlop && !mIsDragging) {
mLastMotionY = mDownY + mTouchSlop;
mIsDragging = true;
}
}
}
复制代码

这是获取TargetView和HeaderView顶部距离的方法,我们通过不断刷新顶部距离来实现滑动的效果,并在这里添加距离监听。


    private void moveTargetViewTo(int target) {
target = Math.max(target, toTopMaxOffset());
if (target >= mTargetInitTop)
target = mTargetInitTop;
// TargetView的top、bottom两个方向都是加上offsetY
ViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrTop);
// 更新当前TargetView距离顶部高度H
mTargetCurrTop = target;

int headerTarget;
// 下拉超过定值H
if (mTargetCurrTop >= mTargetInitTop) {
headerTarget = mHeaderInitTop;
} else if (mTargetCurrTop <= 0) {
headerTarget = 0;
} else {
// 滑动比例
float percent = mTargetCurrTop * 1.0f / mTargetInitTop;
headerTarget = (int) (percent * mHeaderInitTop);
}
// HeaderView的top、bottom两个方向都是加上offsetY
ViewCompat.offsetTopAndBottom(mHeaderView, headerTarget - mHeaderCurrTop);
mHeaderCurrTop = headerTarget;

if (mListener != null) {
mListener.onTargetToTopDistance(mTargetCurrTop);
mListener.onHeaderToTopDistance(mHeaderCurrTop);
}
}
复制代码

这是mScroller弹性滑动时的一些阈值判断。startScroll本身并没有做任何滑动相关的事,而是通过invalidate方法来实现View重绘,在View的draw方法中会调用computeScroll方法,但本例中并没有在computeScroll中配合scrollTo来实现滑动。注意这里的滑动,是指内容的滑动,而非View本身位置的滑动。


    private void finishDrag(int vyPxCount) {
if ((vyPxCount >= 0 && vyPxCount <= minFlingVelocity)
|| (vyPxCount <= 0 && vyPxCount >= -minFlingVelocity))
return;


if (vyPxCount > 0) {
// 速度 > 0,说明正向下滚动
// 防止超出临界值
if (mTargetCurrTop < mTargetInitTop) {
mScroller.startScroll(0, mTargetCurrTop, 0,
Math.min(vyPxCount, mTargetInitTop - mTargetCurrTop)
, 500);
invalidate();
}
} else if (vyPxCount < 0) {
// 速度 < 0,说明正向上滚动

if (mTargetCurrTop <= 0 && mScroller.getCurrVelocity() > 0) {
// todo: inner scroll 接着滚动
}

mScroller.startScroll(0, mTargetCurrTop
, 0, Math.max(vyPxCount, -mTargetCurrTop)
, 500);
invalidate();
}
}
复制代码

在View重绘后,computeScroll方法就会被调用,这里通过更新此时TargetView和HeaderView的顶部距离,来实现滑动到新的位置的目的。


    @Override
public void computeScroll() {
// 判断是否完成滚动,true:未结束
if (mScroller.computeScrollOffset()) {
moveTargetViewTo(mScroller.getCurrY());
invalidate();
}
}
复制代码


好了,本文到这里,关于嵌套滑动的demo就结束了,当然可优化的点还很多。如果本文对你有用,来点个赞吧,大家的肯定也是阿呆i坚持写作的动力。



作者:Battler
链接:https://juejin.cn/post/6951069336412880933
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

技术协同 · 生态共赢 | 环信 IM here 开发者沙龙第3期

01活动介绍云通讯行业爆发新机遇。供给侧:全球通信设施、5G建设逐步推进,高速率、低延时、高质量的特性将解锁下一代社交网络、VR 和 AR 等新场景新业务。需求侧:远程社交、居家办公成为常态,在线教育、游戏社交、远程视频等需求激增...
继续阅读 »

01

活动介绍


云通讯行业爆发新机遇。供给侧:全球通信设施、5G建设逐步推进,高速率、低延时、高质量的特性将解锁下一代社交网络、VR 和 AR 等新场景新业务。需求侧:远程社交、居家办公成为常态,在线教育、游戏社交、远程视频等需求激增,行业蓝海空间无限。

环信,国内即时通讯行业的领行者,诚邀您参与环信IM here 开发者沙龙。

在这里,分享技术亮点、汇聚技术创新、畅聊行业趋势。

在这里,与环信专家1V1,现场给开发者解决集成BUG和问题!

在这里,与伙伴同行,深度交流,渠道资源共享,共塑行业新生态。


02

时间地点

沙龙时间:5月22日下午

沙龙地点:北京海淀区中关村南大街2号数码大厦A座31层

面向人群:环信生态开发者、IM客户、渠道合作伙伴


03

日程安排

14:00-14:30   签到

14:30-14:35   5分钟活动流程介绍

14:35-15:00   环信IM+ Telco产品+MQTT产品介绍

15:00-15:30   环信开发者面对面(现场技术答疑)

15:30-15:40   茶歇

15:40-16:20   环信生态渠道+伙伴交流

16:20-16:30   抽奖+彩蛋


05

温馨提示


1、现场代码演示建议自带电脑,伙伴产品交流请准备好产品ppt。

2、活动仅限20人,报名后会与您联系确认。


06

关于主办方

环信成立于2013年,是国内领先的企业级软件服务提供商,荣膺“Gartner Cool Vendor”。旗下主要产品线包括国内上线最早规模最大的即时通讯能力PaaS平台——环信即时通讯云,国内领先的全场景音视频PaaS平台——环信实时音视频云,全媒体智能客服SaaS平台——环信客服云,以及企业级人工智能服务能力平台——环信机器人。是国内最早覆盖云通讯、云客服、智能机器人的一体化产品技术储备企服公司。



(扫码报名吧~)

收起阅读 »

CocoaAsyncSocket源码Write(总结篇 二)

if (hasNewDataToWrite) { //拿到buffer偏移位置 const uint8_t *buffer = (const uint8_t *)[curr...
继续阅读 »
  • 下面是写入的三种方式

    • CFStreamForTLS


  • SSL写的方式


if (hasNewDataToWrite)
{
//拿到buffer偏移位置
const uint8_t *buffer = (const uint8_t *)[currentWrite->buffer bytes]
+ currentWrite->bytesDone
+ bytesWritten;

//得到需要读的长度
NSUInteger bytesToWrite = [currentWrite->buffer length] - currentWrite->bytesDone - bytesWritten;
//如果大于最大值,就等于最大值
if (bytesToWrite > SIZE_MAX) // NSUInteger may be bigger than size_t (write param 3)
{
bytesToWrite = SIZE_MAX;
}

size_t bytesRemaining = bytesToWrite;

//循环值
BOOL keepLooping = YES;
while (keepLooping)
{
//最大写的字节数?
const size_t sslMaxBytesToWrite = 32768;
//得到二者小的,得到需要写的字节数
size_t sslBytesToWrite = MIN(bytesRemaining, sslMaxBytesToWrite);
//已写字节数
size_t sslBytesWritten = 0;

//将结果从buffer中写到socket上(经由了这个函数,数据就加密了)
result = SSLWrite(sslContext, buffer, sslBytesToWrite, &sslBytesWritten);

//如果写成功
if (result == noErr)
{
//buffer指针偏移
buffer += sslBytesWritten;
//加上些的数量
bytesWritten += sslBytesWritten;
//减去仍需写的数量
bytesRemaining -= sslBytesWritten;
//判断是否需要继续循环
keepLooping = (bytesRemaining > 0);
}
else
{
//IO阻塞
if (result == errSSLWouldBlock)
{
waiting = YES;
//得到缓存的大小(后续长度会被自己写到SSL缓存去)
sslWriteCachedLength = sslBytesToWrite;
}
else
{
error = [self sslError:result];
}

//跳出循环
keepLooping = NO;
}

} // while (keepLooping)


这里还有对残余数据的处理:是通过指针buffer获取我们的keepLooping循环值,循环进行写入

 //将结果从buffer中写到socket上(经由了这个函数,数据就加密了)
result = SSLWrite(sslContext, buffer, sslBytesToWrite, &sslBytesWritten);
  • 普通socket写入



  • 也做了完成判断
//判断是否完成
BOOL done = NO;
//判断已写大小
if (bytesWritten > 0)
{
// Update total amount read for the current write
//更新当前总共写的大小
currentWrite->bytesDone += bytesWritten;
LogVerbose(@"currentWrite->bytesDone = %lu", (unsigned long)currentWrite->bytesDone);

// Is packet done?
//判断当前写包是否写完
done = (currentWrite->bytesDone == [currentWrite->buffer length]);
}

同样为的也是三种数据包:一次性包,粘包,断包

  //如果完成了
if (done)
{
//完成操作
[self completeCurrentWrite];

if (!error)
{
dispatch_async(socketQueue, ^{ @autoreleasepool{
//开始下一次的读取任务
[self maybeDequeueWrite];
}});
}
}
//未完成
else
{
// We were unable to finish writing the data,
// so we're waiting for another callback to notify us of available space in the lower-level output buffer.
//如果不是等待 而且没有出错
if (!waiting && !error)
{
// This would be the case if our write was able to accept some data, but not all of it.
//这是我们写了一部分数据的情况。

//去掉可接受数据的标记
flags &= ~kSocketCanAcceptBytes;
//再去等读source触发
if (![self usingCFStreamForTLS])
{
[self resumeWriteSource];
}
}

//如果已写大于0
if (bytesWritten > 0)
{
// We're not done with the entire write, but we have written some bytes

__strong id theDelegate = delegate;

//调用写的进度代理
if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didWritePartialDataOfLength:tag:)])
{
long theWriteTag = currentWrite->tag;

dispatch_async(delegateQueue, ^{ @autoreleasepool {

[theDelegate socket:self didWritePartialDataOfLength:bytesWritten tag:theWriteTag];
}});
}
}
}



那么整个 CocoaAsyncSocket Wirte的解析就到这里完成了,当你读完前面几篇,再来看这篇就跟喝水一样,故:知识在于积累


由于该框架源码篇幅过大,且有大部分相对抽象的数据操作逻辑,尽管楼主竭力想要简单的去陈述相关内容,但是阅读起来仍会有一定的难度


作者:Cooci
链接:https://www.jianshu.com/p/dfacaf629571


收起阅读 »

CocoaAsyncSocket源码Write(总结篇)

我们切入口//写数据对外方法 - (void)writeData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag { if ([data length] == 0) re...
继续阅读 »



我们切入口

//写数据对外方法
- (void)writeData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag
{
if ([data length] == 0) return;

//初始化写包
GCDAsyncWritePacket *packet = [[GCDAsyncWritePacket alloc] initWithData:data timeout:timeout tag:tag];

dispatch_async(socketQueue, ^{ @autoreleasepool {

LogTrace();

if ((flags & kSocketStarted) && !(flags & kForbidReadsWrites))
{
[writeQueue addObject:packet];
//离队执行
[self maybeDequeueWrite];
}
}});

// Do not rely on the block being run in order to release the packet,
// as the queue might get released without the block completing.
}



写法类似Read

  • 初始化写包 :GCDAsyncWritePacket
  • 写入包放入我们的写入队列(数组)[writeQueue addObject:packet];
  • 离队执行 [self maybeDequeueWrite];

写入包,添加队列没什么讲的了,

下面重点解析maybeDequeueWrite

- (void)maybeDequeueWrite
{
LogTrace();
NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");


// If we're not currently processing a write AND we have an available write stream
if ((currentWrite == nil) && (flags & kConnected))
{
if ([writeQueue count] > 0)
{
// Dequeue the next object in the write queue
currentWrite = [writeQueue objectAtIndex:0];
[writeQueue removeObjectAtIndex:0];

//TLS
if ([currentWrite isKindOfClass:[GCDAsyncSpecialPacket class]])
{
LogVerbose(@"Dequeued GCDAsyncSpecialPacket");

// Attempt to start TLS
flags |= kStartingWriteTLS;

// This method won't do anything unless both kStartingReadTLS and kStartingWriteTLS are set
[self maybeStartTLS];
}
else
{
LogVerbose(@"Dequeued GCDAsyncWritePacket");

// Setup write timer (if needed)
[self setupWriteTimerWithTimeout:currentWrite->timeout];

// Immediately write, if possible
[self doWriteData];
}
}
//写超时导致的错误
else if (flags & kDisconnectAfterWrites)
{
//如果没有可读任务,直接关闭socket
if (flags & kDisconnectAfterReads)
{
if (([readQueue count] == 0) && (currentRead == nil))
{
[self closeWithError:nil];
}
}
else
{
[self closeWithError:nil];
}
}
}
}
  • 我们首先做了一些是否连接,写入队列任务是否大于0等等一些判断
  • 接着我们从全局的writeQueue中,拿到第一条任务,去做读取,我们来判断这个任务的类型,如果是GCDAsyncSpecialPacket类型的,我们将开启TLS认证
  • 如果是是我们之前加入队列中的GCDAsyncWritePacket类型,我们则开始读取操作,调用doWriteData
  • 如果没有可读任务,直接关闭socket

其中 maybeStartTLS我们解析过了,我们就只要来看看核心写入方法:doWriteData

- (void)doWriteData
{
LogTrace();

// This method is called by the writeSource via the socketQueue

//错误,不写
if ((currentWrite == nil) || (flags & kWritesPaused))
{
LogVerbose(@"No currentWrite or kWritesPaused");

// Unable to write at this time

//
if ([self usingCFStreamForTLS])
{
// CFWriteStream only fires once when there is available data.
// It won't fire again until we've invoked CFWriteStreamWrite.
}
else
{
// If the writeSource is firing, we need to pause it
// or else it will continue to fire over and over again.

//如果socket中可接受写数据,防止反复触发写source,挂起
if (flags & kSocketCanAcceptBytes)
{
[self suspendWriteSource];
}
}
return;
}

//如果当前socket无法在写数据了
if (!(flags & kSocketCanAcceptBytes))
{
LogVerbose(@"No space available to write...");

// No space available to write.

//如果不是cfstream
if (![self usingCFStreamForTLS])
{
// Need to wait for writeSource to fire and notify us of
// available space in the socket's internal write buffer.
//则恢复写source,当有空间去写的时候,会触发回来
[self resumeWriteSource];
}
return;
}

//如果正在进行TLS认证
if (flags & kStartingWriteTLS)
{
LogVerbose(@"Waiting for SSL/TLS handshake to complete");

// The writeQueue is waiting for SSL/TLS handshake to complete.

if (flags & kStartingReadTLS)
{
//如果是安全通道,并且I/O阻塞,那么重新去握手
if ([self usingSecureTransportForTLS] && lastSSLHandshakeError == errSSLWouldBlock)
{
// We are in the process of a SSL Handshake.
// We were waiting for available space in the socket's internal OS buffer to continue writing.

[self ssl_continueSSLHandshake];
}
}
//说明不走`TLS`了,因为只支持写的TLS
else
{
// We are still waiting for the readQueue to drain and start the SSL/TLS process.
// We now know we can write to the socket.

//挂起写source
if (![self usingCFStreamForTLS])
{
// Suspend the write source or else it will continue to fire nonstop.
[self suspendWriteSource];
}
}

return;
}

// Note: This method is not called if currentWrite is a GCDAsyncSpecialPacket (startTLS packet)

//开始写数据

BOOL waiting = NO;
NSError *error = nil;
size_t bytesWritten = 0;

//安全连接
if (flags & kSocketSecure)
{
//CFStreamForTLS
if ([self usingCFStreamForTLS])
{
#if TARGET_OS_IPHONE

//
// Writing data using CFStream (over internal TLS)
//

const uint8_t *buffer = (const uint8_t *)[currentWrite->buffer bytes] + currentWrite->bytesDone;

//写的长度为buffer长度-已写长度
NSUInteger bytesToWrite = [currentWrite->buffer length] - currentWrite->bytesDone;

if (bytesToWrite > SIZE_MAX) // NSUInteger may be bigger than size_t (write param 3)
{
bytesToWrite = SIZE_MAX;
}
//往writeStream中写入数据, bytesToWrite写入的长度
CFIndex result = CFWriteStreamWrite(writeStream, buffer, (CFIndex)bytesToWrite);
LogVerbose(@"CFWriteStreamWrite(%lu) = %li", (unsigned long)bytesToWrite, result);

//写错误
if (result < 0)
{
error = (__bridge_transfer NSError *)CFWriteStreamCopyError(writeStream);
}
else
{
//拿到已写字节数
bytesWritten = (size_t)result;

// We always set waiting to true in this scenario.
//我们经常设置等待来信任这个方案
// CFStream may have altered our underlying socket to non-blocking.
//CFStream很可能修改socket为非阻塞
// Thus if we attempt to write without a callback, we may end up blocking our queue.
//因此,我们尝试去写,而不用回调。 我们可能终止我们的队列。
waiting = YES;
}

#endif
}
//SSL写的方式
else
{
// We're going to use the SSLWrite function.
//
// OSStatus SSLWrite(SSLContextRef context, const void *data, size_t dataLength, size_t *processed)
//
// Parameters:
// context - An SSL session context reference.
// data - A pointer to the buffer of data to write.
// dataLength - The amount, in bytes, of data to write.
// processed - On return, the length, in bytes, of the data actually written.
//
// It sounds pretty straight-forward,
//看起来相当直观,但是这里警告你应注意。
// but there are a few caveats you should be aware of.
//
// The SSLWrite method operates in a non-obvious (and rather annoying) manner.
// According to the documentation:
// 这个SSLWrite方法使用着一个不明显的方法(相当讨厌)导致了下面这些事。
// Because you may configure the underlying connection to operate in a non-blocking manner,
//因为你要辨别出下层连接 操纵 非阻塞的方法,一个写的操作将返回errSSLWouldBlock,表明需要写的数据少了。
// a write operation might return errSSLWouldBlock, indicating that less data than requested
// was actually transferred. In this case, you should repeat the call to SSLWrite until some
//在这种情况下你应该重复调用SSLWrite,直到一些其他结果被返回
// other result is returned.
// This sounds perfect, but when our SSLWriteFunction returns errSSLWouldBlock,
//这样听起来很完美,但是当SSLWriteFunction返回errSSLWouldBlock,SSLWrite返回但是却设置了进度长度?
// then the SSLWrite method returns (with the proper errSSLWouldBlock return value),
// but it sets processed to dataLength !!
//
// In other words, if the SSLWrite function doesn't completely write all the data we tell it to,
//另外,SSLWrite方法没有完整的写完我们给的所有数据,因此它没有告诉我们到底写了多少数据,
// then it doesn't tell us how many bytes were actually written. So, for example, if we tell it to
//因此。举个例子,如果我们告诉它去写256个字节,它可能只写了128个字节,但是告诉我们写了0个字节
// write 256 bytes then it might actually write 128 bytes, but then report 0 bytes written.
//
// You might be wondering:
//你可能会觉得奇怪,如果这个方法不告诉我们写了多少字节,那么该如何去更新参数来应对下一次的SSLWrite?
// If the SSLWrite function doesn't tell us how many bytes were written,
// then how in the world are we supposed to update our parameters (buffer & bytesToWrite)
// for the next time we invoke SSLWrite?
//
// The answer is that SSLWrite cached all the data we told it to write,
//答案就是,SSLWrite缓存了所有的数据我们要它写的。并且拉出这些数据,只要我们下次调用SSLWrite。
// and it will push out that data next time we call SSLWrite.

// If we call SSLWrite with new data, it will push out the cached data first, and then the new data.
//如果我们用新的data调用SSLWrite,它会拉出这些缓存的数据,然后才轮到新数据
// If we call SSLWrite with empty data, then it will simply push out the cached data.
// 如果我们调用SSLWrite用一个空的数据,则它仅仅会拉出缓存数据。
// For this purpose we're going to break large writes into a series of smaller writes.
//为了这个目的,我们去分开一个大数据写成一连串的小数据,它允许我们去报告进度给代理。
// This allows us to report progress back to the delegate.

OSStatus result;

//SSL缓存的写的数据
BOOL hasCachedDataToWrite = (sslWriteCachedLength > 0);
//是否有新数据要写
BOOL hasNewDataToWrite = YES;

if (hasCachedDataToWrite)
{
size_t processed = 0;

//去写空指针,就是拉取了所有的缓存SSL数据
result = SSLWrite(sslContext, NULL, 0, &processed);

//如果写成功
if (result == noErr)
{
//拿到写的缓存长度
bytesWritten = sslWriteCachedLength;
//置空缓存长度
sslWriteCachedLength = 0;
//判断当前需要写的buffer长度,是否和已写的大小+缓存 大小相等
if ([currentWrite->buffer length] == (currentWrite->bytesDone + bytesWritten))
{
// We've written all data for the current write.
//相同则不需要再写新数据了
hasNewDataToWrite = NO;
}
}
//有错
else
{
//IO阻塞,等待
if (result == errSSLWouldBlock)
{
waiting = YES;
}
//报错
else
{
error = [self sslError:result];
}

// Can't write any new data since we were unable to write the cached data.
//如果读写cache出错,我们暂时不能去读后面的数据
hasNewDataToWrite = NO;
}
}

//如果还有数据去读
if (hasNewDataToWrite)
{
//拿到buffer偏移位置
const uint8_t *buffer = (const uint8_t *)[currentWrite->buffer bytes]
+ currentWrite->bytesDone
+ bytesWritten;

//得到需要读的长度
NSUInteger bytesToWrite = [currentWrite->buffer length] - currentWrite->bytesDone - bytesWritten;
//如果大于最大值,就等于最大值
if (bytesToWrite > SIZE_MAX) // NSUInteger may be bigger than size_t (write param 3)
{
bytesToWrite = SIZE_MAX;
}

size_t bytesRemaining = bytesToWrite;

//循环值
BOOL keepLooping = YES;
while (keepLooping)
{
//最大写的字节数?
const size_t sslMaxBytesToWrite = 32768;
//得到二者小的,得到需要写的字节数
size_t sslBytesToWrite = MIN(bytesRemaining, sslMaxBytesToWrite);
//已写字节数
size_t sslBytesWritten = 0;

//将结果从buffer中写到socket上(经由了这个函数,数据就加密了)
result = SSLWrite(sslContext, buffer, sslBytesToWrite, &sslBytesWritten);

//如果写成功
if (result == noErr)
{
//buffer指针偏移
buffer += sslBytesWritten;
//加上些的数量
bytesWritten += sslBytesWritten;
//减去仍需写的数量
bytesRemaining -= sslBytesWritten;
//判断是否需要继续循环
keepLooping = (bytesRemaining > 0);
}
else
{
//IO阻塞
if (result == errSSLWouldBlock)
{
waiting = YES;
//得到缓存的大小(后续长度会被自己写到SSL缓存去)
sslWriteCachedLength = sslBytesToWrite;
}
else
{
error = [self sslError:result];
}

//跳出循环
keepLooping = NO;
}

} // while (keepLooping)

} // if (hasNewDataToWrite)
}
}

//普通socket
else
{
//
// Writing data directly over raw socket
//

//拿到当前socket
int socketFD = (socket4FD != SOCKET_NULL) ? socket4FD : (socket6FD != SOCKET_NULL) ? socket6FD : socketUN;

//得到指针偏移
const uint8_t *buffer = (const uint8_t *)[currentWrite->buffer bytes] + currentWrite->bytesDone;

NSUInteger bytesToWrite = [currentWrite->buffer length] - currentWrite->bytesDone;

if (bytesToWrite > SIZE_MAX) // NSUInteger may be bigger than size_t (write param 3)
{
bytesToWrite = SIZE_MAX;
}
//直接写
ssize_t result = write(socketFD, buffer, (size_t)bytesToWrite);
LogVerbose(@"wrote to socket = %zd", result);

// Check results
if (result < 0)
{
//IO阻塞
if (errno == EWOULDBLOCK)
{
waiting = YES;
}
else
{
error = [self errnoErrorWithReason:@"Error in write() function"];
}
}
else
{
//得到写的大小
bytesWritten = result;
}
}

// We're done with our writing.
// If we explictly ran into a situation where the socket told us there was no room in the buffer,
// then we immediately resume listening for notifications.
//
// We must do this before we dequeue another write,
// as that may in turn invoke this method again.
//
// Note that if CFStream is involved, it may have maliciously put our socket in blocking mode.
//注意,如果用CFStream,很可能会被恶意的放置数据 阻塞socket

//如果等待,则恢复写source
if (waiting)
{
//把socket可接受数据的标记去掉
flags &= ~kSocketCanAcceptBytes;

if (![self usingCFStreamForTLS])
{
//恢复写source
[self resumeWriteSource];
}
}

// Check our results

//判断是否完成
BOOL done = NO;
//判断已写大小
if (bytesWritten > 0)
{
// Update total amount read for the current write
//更新当前总共写的大小
currentWrite->bytesDone += bytesWritten;
LogVerbose(@"currentWrite->bytesDone = %lu", (unsigned long)currentWrite->bytesDone);

// Is packet done?
//判断当前写包是否写完
done = (currentWrite->bytesDone == [currentWrite->buffer length]);
}

//如果完成了
if (done)
{
//完成操作
[self completeCurrentWrite];

if (!error)
{
dispatch_async(socketQueue, ^{ @autoreleasepool{
//开始下一次的读取任务
[self maybeDequeueWrite];
}});
}
}
//未完成
else
{
// We were unable to finish writing the data,
// so we're waiting for another callback to notify us of available space in the lower-level output buffer.
//如果不是等待 而且没有出错
if (!waiting && !error)
{
// This would be the case if our write was able to accept some data, but not all of it.
//这是我们写了一部分数据的情况。

//去掉可接受数据的标记
flags &= ~kSocketCanAcceptBytes;
//再去等读source触发
if (![self usingCFStreamForTLS])
{
[self resumeWriteSource];
}
}

//如果已写大于0
if (bytesWritten > 0)
{
// We're not done with the entire write, but we have written some bytes

__strong id theDelegate = delegate;

//调用写的进度代理
if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didWritePartialDataOfLength:tag:)])
{
long theWriteTag = currentWrite->tag;

dispatch_async(delegateQueue, ^{ @autoreleasepool {

[theDelegate socket:self didWritePartialDataOfLength:bytesWritten tag:theWriteTag];
}});
}
}
}

// Check for errors
//如果有错,则报错断开连接
if (error)
{
[self closeWithError:[self errnoErrorWithReason:@"Error in write() function"]];
}

// Do not add any code here without first adding a return statement in the error case above.
}

  • 这里不同doRead的是没有提前通过flush写入链路层
  • 如果socket中可接受写数据,防止反复触发写source,挂起
  • 如果当前socket无法在写数据了,则恢复写source,当有空间去写的时候,会触发回来



  • 如果正在进行TLS认证 如果是安全通道,并且I/O阻塞,那么重新去握手








    收起阅读 »

    CocoaAsyncSocket源码Read(七)

    最后还是提下SSL的回调方法,数据解密的地方。两种模式的回调;Part7.两种SSL数据解密位置:1.CFStream:当我们调用:CFIndex result = CFReadStreamRead(readStream, buffer, defaultByt...
    继续阅读 »

    最后还是提下SSL的回调方法,数据解密的地方。两种模式的回调;

    Part7.两种SSL数据解密位置:

    1.CFStream:当我们调用:

    CFIndex result = CFReadStreamRead(readStream, buffer, defaultBytesToRead);


    数据就会被解密。
    2.SSL安全通道:当我们调用:

    OSStatus result = SSLRead(sslContext, buffer, (size_t)estimatedBytesAvailable, &bytesRead);


    会触发SSL绑定的函数回调:

    //读函数
    static OSStatus SSLReadFunction(SSLConnectionRef connection, void *data, size_t *dataLength)
    {
    //拿到socket
    GCDAsyncSocket *asyncSocket = (__bridge GCDAsyncSocket *)connection;

    //断言当前为socketQueue
    NSCAssert(dispatch_get_specific(asyncSocket->IsOnSocketQueueOrTargetQueueKey), @"What the deuce?");

    //读取数据,并且返回状态码
    return [asyncSocket sslReadWithBuffer:data length:dataLength];
    }

    接着我们在下面的方法进行了数据读取:

    //SSL读取数据最终方法
    - (OSStatus)sslReadWithBuffer:(void *)buffer length:(size_t *)bufferLength
    {
    //...
    ssize_t result = read(socketFD, buf, bytesToRead);
    //....
    }

    其实read这一步,数据是没有被解密的,然后传递回SSLReadFunction,在传递到SSLRead内部,数据被解密。


    本篇重点涉及该框架是如何利用缓冲区对数据进行读取、以及各种情况下的数据包处理,其中还包括普通的、和基于TLS的不同读取操作等等。
    注:由于该框架源码篇幅过大,且有大部分相对抽象的数据操作逻辑,尽管楼主竭力想要简单的去陈述相关内容,但是阅读起来仍会有一定的难度。


    附上一张核心代码逻辑图


    文中涉及代码比较多,建议大家结合源码一起阅读比较容易能加深理解

    之后会涉及到
    CocoaAsyncSocket
  • 初始化写包 :GCDAsyncWritePacket
  • 写入包放入我们的写入队列(数组)[writeQueue addObject:packet];
  • 离队执行 [self maybeDequeueWrite];


  • 主要介绍GCDAsyncSpecialPacketGCDAsyncWritePacket类型数据的处理,还有核心写入方法doWriteData三种不同方式的写入



    作者:Cooci
    链接:https://www.jianshu.com/p/dfacaf629571





    收起阅读 »

    CocoaAsyncSocket源码Read(六)

    讲了半天理论,想必大家看的有点不耐烦了,接下来看看代码实际是如何处理的吧:step1:从prebuffer中读取数据://先从提前缓冲区去读,如果缓冲区可读大小大于0 if ([preBuffer availableBytes] > 0) { ...
    继续阅读 »

    讲了半天理论,想必大家看的有点不耐烦了,接下来看看代码实际是如何处理的吧:

    step1:从prebuffer中读取数据:
    //先从提前缓冲区去读,如果缓冲区可读大小大于0
    if ([preBuffer availableBytes] > 0)
    {
    // There are 3 types of read packets:
    //
    // 1) Read all available data.
    // 2) Read a specific length of data.
    // 3) Read up to a particular terminator.
    //3种类型的读法,1、全读、2、读取特定长度、3、读取到一个明确的界限

    NSUInteger bytesToCopy;

    //如果当前读的数据界限不为空
    if (currentRead->term != nil)
    {
    // Read type #3 - read up to a terminator
    //直接读到界限
    bytesToCopy = [currentRead readLengthForTermWithPreBuffer:preBuffer found:&done];
    }
    else
    {
    // Read type #1 or #2
    //读取数据,读到指定长度或者数据包的长度为止
    bytesToCopy = [currentRead readLengthForNonTermWithHint:[preBuffer availableBytes]];
    }

    // Make sure we have enough room in the buffer for our read.
    //从上两步拿到我们需要读的长度,去看看有没有空间去存储
    [currentRead ensureCapacityForAdditionalDataOfLength:bytesToCopy];

    // Copy bytes from prebuffer into packet buffer

    //拿到我们需要追加数据的指针位置
    #pragma mark - 不明白
    //当前读的数据 + 开始偏移 + 已经读完的??
    uint8_t *buffer = (uint8_t *)[currentRead->buffer mutableBytes] + currentRead->startOffset +
    currentRead->bytesDone;
    //从prebuffer处复制过来数据,bytesToCopy长度
    memcpy(buffer, [preBuffer readBuffer], bytesToCopy);

    // Remove the copied bytes from the preBuffer
    //从preBuffer移除掉已经复制的数据
    [preBuffer didRead:bytesToCopy];

    LogVerbose(@"copied(%lu) preBufferLength(%zu)", (unsigned long)bytesToCopy, [preBuffer availableBytes]);

    // Update totals

    //已读的数据加上
    currentRead->bytesDone += bytesToCopy;
    //当前已读的数据加上
    totalBytesReadForCurrentRead += bytesToCopy;

    // Check to see if the read operation is done
    //判断是不是读完了
    if (currentRead->readLength > 0)
    {
    // Read type #2 - read a specific length of data
    //如果已读 == 需要读的长度,说明已经读完
    done = (currentRead->bytesDone == currentRead->readLength);
    }
    //判断界限标记
    else if (currentRead->term != nil)
    {
    // Read type #3 - read up to a terminator

    // Our 'done' variable was updated via the readLengthForTermWithPreBuffer:found: method
    //如果没做完,且读的最大长度大于0,去判断是否溢出
    if (!done && currentRead->maxLength > 0)
    {
    // We're not done and there's a set maxLength.
    // Have we reached that maxLength yet?

    //如果已读的大小大于最大的大小,则报溢出错误
    if (currentRead->bytesDone >= currentRead->maxLength)
    {
    error = [self readMaxedOutError];
    }
    }
    }
    else
    {
    // Read type #1 - read all available data
    //
    // We're done as soon as
    // - we've read all available data (in prebuffer and socket)
    // - we've read the maxLength of read packet.
    //判断已读大小和最大大小是否相同,相同则读完
    done = ((currentRead->maxLength > 0) && (currentRead->bytesDone == currentRead->maxLength));
    }

    }


    这个方法就是利用我们之前提到的3种类型,来判断数据包需要读取的长度,然后调用:


    memcpy(buffer, [preBuffer readBuffer], bytesToCopy);


    把数据从preBuffer中,移到了currentRead数据包中。

    step2:从socket中读取数据:
    // 从socket中去读取

    //是否读到EOFException ,这个错误指的是文件结尾了还在继续读,就会导致这个错误被抛出
    BOOL socketEOF = (flags & kSocketHasReadEOF) ? YES : NO; // Nothing more to read via socket (end of file)

    //如果没完成,且没错,没读到结尾,且没有可读数据了
    BOOL waiting = !done && !error && !socketEOF && !hasBytesAvailable; // Ran out of data, waiting for more

    //如果没完成,且没错,没读到结尾,有可读数据
    if (!done && !error && !socketEOF && hasBytesAvailable)
    {
    //断言,有可读数据
    NSAssert(([preBuffer availableBytes] == 0), @"Invalid logic");
    //是否读到preBuffer中去
    BOOL readIntoPreBuffer = NO;
    uint8_t *buffer = NULL;
    size_t bytesRead = 0;

    //如果flag标记为安全socket
    if (flags & kSocketSecure)
    {
    //...类似flushSSLBuffer的一系列操作
    }
    else
    {
    // Normal socket operation
    //普通的socket 操作

    NSUInteger bytesToRead;

    // There are 3 types of read packets:
    //
    // 1) Read all available data.
    // 2) Read a specific length of data.
    // 3) Read up to a particular terminator.

    //和上面类似,读取到边界标记??不是吧
    if (currentRead->term != nil)
    {
    // Read type #3 - read up to a terminator

    //读这个长度,如果到maxlength,就用maxlength。看如果可用空间大于需要读的空间,则不用prebuffer
    bytesToRead = [currentRead readLengthForTermWithHint:estimatedBytesAvailable
    shouldPreBuffer:&readIntoPreBuffer];
    }

    else
    {
    // Read type #1 or #2
    //直接读这个长度,如果到maxlength,就用maxlength
    bytesToRead = [currentRead readLengthForNonTermWithHint:estimatedBytesAvailable];
    }

    //大于最大值,则先读最大值
    if (bytesToRead > SIZE_MAX) { // NSUInteger may be bigger than size_t (read param 3)
    bytesToRead = SIZE_MAX;
    }

    // Make sure we have enough room in the buffer for our read.
    //
    // We are either reading directly into the currentRead->buffer,
    // or we're reading into the temporary preBuffer.

    if (readIntoPreBuffer)
    {
    [preBuffer ensureCapacityForWrite:bytesToRead];

    buffer = [preBuffer writeBuffer];
    }
    else
    {
    [currentRead ensureCapacityForAdditionalDataOfLength:bytesToRead];

    buffer = (uint8_t *)[currentRead->buffer mutableBytes]
    + currentRead->startOffset
    + currentRead->bytesDone;
    }

    // Read data into buffer

    int socketFD = (socket4FD != SOCKET_NULL) ? socket4FD : (socket6FD != SOCKET_NULL) ? socket6FD : socketUN;
    #pragma mark - 开始读取数据,最普通的形式 read

    //读数据
    ssize_t result = read(socketFD, buffer, (size_t)bytesToRead);
    LogVerbose(@"read from socket = %i", (int)result);
    //读取错误
    if (result < 0)
    {
    //EWOULDBLOCK IO阻塞
    if (errno == EWOULDBLOCK)
    //先等待
    waiting = YES;
    else
    //得到错误
    error = [self errnoErrorWithReason:@"Error in read() function"];
    //把可读取的长度设置为0
    socketFDBytesAvailable = 0;
    }
    //读到边界了
    else if (result == 0)
    {
    socketEOF = YES;
    socketFDBytesAvailable = 0;
    }
    //正常
    else
    {
    //设置读到的数据长度
    bytesRead = result;

    //如果读到的数据小于应该读的长度,说明这个包没读完
    if (bytesRead < bytesToRead)
    {
    // The read returned less data than requested.
    // This means socketFDBytesAvailable was a bit off due to timing,
    // because we read from the socket right when the readSource event was firing.
    socketFDBytesAvailable = 0;
    }
    //正常
    else
    {
    //如果 socketFDBytesAvailable比读了的数据小的话,直接置为0
    if (socketFDBytesAvailable <= bytesRead)
    socketFDBytesAvailable = 0;
    //减去已读大小
    else
    socketFDBytesAvailable -= bytesRead;
    }
    //如果 socketFDBytesAvailable 可读数量为0,把读的状态切换为等待
    if (socketFDBytesAvailable == 0)
    {
    waiting = YES;
    }
    }
    }


    本来想讲点什么。。发现确实没什么好讲的,无非就是判断应该读取的长度,然后调用:

    ssize_t result = read(socketFD, buffer, (size_t)bytesToRead);

    socket中得到读取的实际长度。

    唯一需要讲一下的可能是数据流向的问题,这里调用:

    bytesToRead = [currentRead readLengthForTermWithHint:estimatedBytesAvailable shouldPreBuffer:&readIntoPreBuffer];

    来判断数据是否先流向prebuffer,还是直接流向currentRead,而SSL的读取中也有类似方法:

    - (NSUInteger)optimalReadLengthWithDefault:(NSUInteger)defaultValue shouldPreBuffer:(BOOL *)shouldPreBufferPtr

    这个方法核心的思路就是,如果当前读取包,长度给明了,则直接流向currentRead,如果数据长度不清楚,那么则去判断这一次读取的长度,和currentRead可用空间长度去对比,如果长度比currentRead可用空间小,则流向currentRead,否则先用prebuffer来缓冲。

    至于细节方面,大家对着github中的源码注释看看吧,这么大篇幅的业务代码,一行行讲确实没什么意义。

    走完这两步读取,接着就是第三步:

    step3:判断数据包完成程度:

    这里有3种情况:
    1.数据包刚好读完;2.数据粘包;3.数据断包;
    注:这里判断粘包断包的长度,都是我们一开始调用read方法给的长度或者分界符得出的。

    很显然,第一种就什么都不用处理,完美匹配。
    第二种情况,我们把需要的长度放到currentRead,多余的长度放到prebuffer中去。
    第三种情况,数据还没读完,我们暂时为未读完。

    这里就不贴代码了。

    就这样普通读取数据的整个流程就走完了,而SSL的两种模式,和上述基本一致。

    我们接着根据之前读取的结果,来判断数据是否读完:

    //检查是否读完
    if (done)
    {
    //完成这次数据的读取
    [self completeCurrentRead];
    //如果没出错,没有到边界,prebuffer中还有可读数据
    if (!error && (!socketEOF || [preBuffer availableBytes] > 0))
    {
    //让读操作离队,继续进行下一次读取
    [self maybeDequeueRead];
    }
    }


    如果读完,则去做读完的操作,并且进行下一次读取。

    我们来看看读完的操作:
    //完成了这次的读数据
    - (void)completeCurrentRead
    {
    LogTrace();
    //断言currentRead
    NSAssert(currentRead, @"Trying to complete current read when there is no current read.");

    //结果数据
    NSData *result = nil;

    //如果是我们自己创建的Buffer
    if (currentRead->bufferOwner)
    {
    // We created the buffer on behalf of the user.
    // Trim our buffer to be the proper size.
    //修剪buffer到合适的大小
    //把大小设置到我们读取到的大小
    [currentRead->buffer setLength:currentRead->bytesDone];
    //赋值给result
    result = currentRead->buffer;
    }
    else
    {
    // We did NOT create the buffer.
    // The buffer is owned by the caller.
    // Only trim the buffer if we had to increase its size.
    //这是调用者的data,我们只会去加大尺寸
    if ([currentRead->buffer length] > currentRead->originalBufferLength)
    {
    //拿到的读的size
    NSUInteger readSize = currentRead->startOffset + currentRead->bytesDone;
    //拿到原始尺寸
    NSUInteger origSize = currentRead->originalBufferLength;

    //取得最大的
    NSUInteger buffSize = MAX(readSize, origSize);
    //把buffer设置为较大的尺寸
    [currentRead->buffer setLength:buffSize];
    }
    //拿到数据的头指针
    uint8_t *buffer = (uint8_t *)[currentRead->buffer mutableBytes] + currentRead->startOffset;

    //reslut为,从头指针开始到长度为写的长度 freeWhenDone为YES,创建完就释放buffer
    result = [NSData dataWithBytesNoCopy:buffer length:currentRead->bytesDone freeWhenDone:NO];
    }

    __strong id theDelegate = delegate;

    #pragma mark -总算到调用代理方法,接受到数据了
    if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didReadData:withTag:)])
    {
    //拿到当前的数据包
    GCDAsyncReadPacket *theRead = currentRead; // Ensure currentRead retained since result may not own buffer

    dispatch_async(delegateQueue, ^{ @autoreleasepool {
    //把result在代理queue中回调出去。
    [theDelegate socket:self didReadData:result withTag:theRead->tag];
    }});
    }
    //取消掉读取超时
    [self endCurrentRead];
    }


    这里对currentReaddata做了个长度的设置。然后调用代理把最终包给回调出去。最后关掉我们之前提到的读取超时。

    还是回到doReadData,就剩下最后一点处理了:

    //如果这次读的数量大于0
    else if (totalBytesReadForCurrentRead > 0)
    {
    // We're not done read type #2 or #3 yet, but we have read in some bytes

    __strong id theDelegate = delegate;

    //如果响应读数据进度的代理
    if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didReadPartialDataOfLength:tag:)])
    {
    long theReadTag = currentRead->tag;

    //代理queue中回调出去
    dispatch_async(delegateQueue, ^{ @autoreleasepool {

    [theDelegate socket:self didReadPartialDataOfLength:totalBytesReadForCurrentRead tag:theReadTag];
    }});
    }
    }


    这里未完成,如果这次读取大于0,如果响应读取进度的代理,则把当前进度回调出去。

    最后检查错误:
    //检查错误
    if (error)
    {
    //如果有错直接报错断开连接
    [self closeWithError:error];
    }
    //如果是读到边界错误
    else if (socketEOF)
    {
    [self doReadEOF];
    }

    //如果是等待
    else if (waiting)
    {
    //如果用的是CFStream,则读取数据和source无关
    //非CFStream形式
    if (![self usingCFStreamForTLS])
    {
    // Monitor the socket for readability (if we're not already doing so)
    //重新恢复source
    [self resumeReadSource];
    }
    }


    如果有错,直接断开socket,如果是边界错误,调用边界错误处理,如果是等待,说明当前包还没读完,如果非CFStreamTLS,则恢复source,等待下一次数据到达的触发。

    关于这个读取边界错误EOF,这里我简单的提下,其实它就是服务端发出一个边界错误,说明不会再有数据发送给我们了。我们讲无法再接收到数据,但是我们其实还是可以写数据,发送给服务端的。

    doReadEOF这个方法的处理,就是做了这么一件事。判断我们是否需要这种不可读,只能写的连接。

    我们来简单看看这个方法:
    Part6.读取边界错误处理:
    //读到EOFException,边界错误
    - (void)doReadEOF
    {
    LogTrace();
    //这个方法可能被调用很多次,如果读到EOF的时候,还有数据在prebuffer中,在调用doReadData之后?? 这个方法可能被持续的调用

    //标记为读EOF
    flags |= kSocketHasReadEOF;

    //如果是安全socket
    if (flags & kSocketSecure)
    {
    //去刷新sslbuffer中的数据
    [self flushSSLBuffers];
    }

    //标记是否应该断开连接
    BOOL shouldDisconnect = NO;
    NSError *error = nil;

    //如果状态为开始读写TLS
    if ((flags & kStartingReadTLS) || (flags & kStartingWriteTLS))
    {
    //我们得到EOF在开启TLS之前,这个TLS握手是不可能的,因此这是不可恢复的错误

    //标记断开连接
    shouldDisconnect = YES;
    //如果是安全的TLS,赋值错误
    if ([self usingSecureTransportForTLS])
    {
    error = [self sslError:errSSLClosedAbort];
    }
    }
    //如果是读流关闭状态
    else if (flags & kReadStreamClosed)
    {

    //不应该被关闭
    shouldDisconnect = NO;
    }
    else if ([preBuffer availableBytes] > 0)
    {
    //仍然有数据可读的时候不关闭
    shouldDisconnect = NO;
    }
    else if (config & kAllowHalfDuplexConnection)
    {

    //拿到socket
    int socketFD = (socket4FD != SOCKET_NULL) ? socket4FD : (socket6FD != SOCKET_NULL) ? socket6FD : socketUN;

    //轮询用的结构体

    /*
    struct pollfd {
    int fd; //文件描述符
    short events; //要求查询的事件掩码 监听的
    short revents; //返回的事件掩码 实际发生的
    };
    */


    struct pollfd pfd[1];
    pfd[0].fd = socketFD;
    //写数据不会导致阻塞。
    pfd[0].events = POLLOUT;
    //这个为当前实际发生的事情
    pfd[0].revents = 0;

    /*
    poll函数使用pollfd类型的结构来监控一组文件句柄,ufds是要监控的文件句柄集合,nfds是监控的文件句柄数量,timeout是等待的毫秒数,这段时间内无论I/O是否准备好,poll都会返回。timeout为负数表示无线等待,timeout为0表示调用后立即返回。执行结果:为0表示超时前没有任何事件发生;-1表示失败;成功则返回结构体中revents不为0的文件描述符个数。pollfd结构监控的事件类型如下:
    int poll(struct pollfd *ufds, unsigned int nfds, int timeout);
    */

    //阻塞的,但是timeout为0,则不阻塞,直接返回
    poll(pfd, 1, 0);

    //如果被触发的事件是写数据
    if (pfd[0].revents & POLLOUT)
    {
    // Socket appears to still be writeable

    //则标记为不关闭
    shouldDisconnect = NO;
    //标记为读流关闭
    flags |= kReadStreamClosed;

    // Notify the delegate that we're going half-duplex
    //通知代理,我们开始半双工
    __strong id theDelegate = delegate;

    //调用已经关闭读流的代理方法
    if (delegateQueue && [theDelegate respondsToSelector:@selector(socketDidCloseReadStream:)])
    {
    dispatch_async(delegateQueue, ^{ @autoreleasepool {

    [theDelegate socketDidCloseReadStream:self];
    }});
    }
    }
    else
    {
    //标记为断开
    shouldDisconnect = YES;
    }
    }
    else
    {
    shouldDisconnect = YES;
    }

    //如果应该断开
    if (shouldDisconnect)
    {
    if (error == nil)
    {
    //判断是否是安全TLS传输
    if ([self usingSecureTransportForTLS])
    {
    ///标记错误信息
    if (sslErrCode != noErr && sslErrCode != errSSLClosedGraceful)
    {
    error = [self sslError:sslErrCode];
    }
    else
    {
    error = [self connectionClosedError];
    }
    }
    else
    {
    error = [self connectionClosedError];
    }
    }
    //关闭socket
    [self closeWithError:error];
    }
    //不断开
    else
    {
    //如果不是用CFStream流
    if (![self usingCFStreamForTLS])
    {
    // Suspend the read source (if needed)
    //挂起读source
    [self suspendReadSource];
    }
    }
    }

    简单说一下,这个方法主要是对socket是否需要主动关闭进行了判断:这里仅仅以下3种情况,不会关闭socket

    1. 读流已经是关闭状态(如果加了这个标记,说明为半双工连接状态)。
    • preBuffer中还有可读数据,我们需要等数据读完才能关闭连接。
    • 配置标记为kAllowHalfDuplexConnection,我们则要开始半双工处理。我们调用了:

    poll(pfd, 1, 0);

    函数,如果触发了写事件POLLOUT,说明我们半双工连接成功,则我们可以在读流关闭的状态下,仍然可以向服务器写数据。

    其他情况下,一律直接关闭socket
    而不关闭的情况下,我们会挂起source。这样我们就只能可写不可读了。



    作者:Cooci_和谐学习_不急不躁
    链接:https://www.jianshu.com/p/5a2df8a6a54e
    来源:简书
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。









    收起阅读 »

    CocoaAsyncSocket源码Read(五)

    在我们来看flushSSLBuffers方法之前,我们先来看看这个一直提到的全局缓冲区prebuffer的定义,它其实就是下面这么一个类的实例:Part3.GCDAsyncSocketPreBuffer的定义@interface GCDAsyncSocketP...
    继续阅读 »

    在我们来看flushSSLBuffers方法之前,我们先来看看这个一直提到的全局缓冲区prebuffer的定义,它其实就是下面这么一个类的实例:

    Part3.GCDAsyncSocketPreBuffer的定义

    @interface GCDAsyncSocketPreBuffer : NSObject
    {
    //unsigned char
    //提前的指针,指向这块提前的缓冲区
    uint8_t *preBuffer;
    //size_t 它是一个与机器相关的unsigned类型,其大小足以保证存储内存中对象的大小。
    //它可以存储在理论上是可能的任何类型的数组的最大大小
    size_t preBufferSize;
    //读的指针
    uint8_t *readPointer;
    //写的指针
    uint8_t *writePointer;
    }

    里面存了3个指针,包括preBuffer起点指针、当前读写所处位置指针、以及一个preBufferSize,这个sizepreBuffer所指向的位置,在内存中分配的空间大小。

    我们来看看它的几个方法:

    //初始化
    - (id)initWithCapacity:(size_t)numBytes
    {
    if ((self = [super init]))
    {
    //设置size
    preBufferSize = numBytes;
    //申请size大小的内存给preBuffer
    preBuffer = malloc(preBufferSize);

    //为同一个值
    readPointer = preBuffer;
    writePointer = preBuffer;
    }
    return self;
    }


    包括一个初始化方法,去初始化preBufferSize大小的一块内存空间。然后3个指针都指向这个空间。

    - (void)dealloc
    {
    if (preBuffer)
    free(preBuffer);
    }

    销毁的方法:释放preBuffer。

    //确认读的大小
    - (void)ensureCapacityForWrite:(size_t)numBytes
    {
    //拿到当前可用的空间大小
    size_t availableSpace = [self availableSpace];

    //如果申请的大小大于可用的大小
    if (numBytes > availableSpace)
    {
    //需要多出来的大小
    size_t additionalBytes = numBytes - availableSpace;
    //新的总大小
    size_t newPreBufferSize = preBufferSize + additionalBytes;
    //重新去分配preBuffer
    uint8_t *newPreBuffer = realloc(preBuffer, newPreBufferSize);

    //读的指针偏移量(已读大小)
    size_t readPointerOffset = readPointer - preBuffer;
    //写的指针偏移量(已写大小)
    size_t writePointerOffset = writePointer - preBuffer;
    //提前的Buffer重新复制
    preBuffer = newPreBuffer;
    //大小重新赋值
    preBufferSize = newPreBufferSize;

    //读写指针重新赋值 + 上偏移量
    readPointer = preBuffer + readPointerOffset;
    writePointer = preBuffer + writePointerOffset;
    }
    }


    确保prebuffer可用空间的方法:这个方法会重新分配preBuffer,直到可用大小等于传递进来的numBytes,已用大小不会变。

    //仍然可读的数据,过程是先写后读,只有写的大于读的,才能让你继续去读,不然没数据可读了
    - (size_t)availableBytes
    {
    return writePointer - readPointer;
    }

    - (uint8_t *)readBuffer
    {
    return readPointer;
    }

    - (void)getReadBuffer:(uint8_t **)bufferPtr availableBytes:(size_t *)availableBytesPtr
    {
    if (bufferPtr) *bufferPtr = readPointer;
    if (availableBytesPtr) *availableBytesPtr = [self availableBytes];
    }

    //读数据的指针
    - (void)didRead:(size_t)bytesRead
    {
    readPointer += bytesRead;
    //如果读了这么多,指针和写的指针还相同的话,说明已经读完,重置指针到最初的位置
    if (readPointer == writePointer)
    {
    // The prebuffer has been drained. Reset pointers.
    readPointer = preBuffer;
    writePointer = preBuffer;
    }
    }
    //prebuffer的剩余空间 = preBufferSize(总大小) - (写的头指针 - preBuffer一开的指针,即已被写的大小)

    - (size_t)availableSpace
    {
    return preBufferSize - (writePointer - preBuffer);
    }

    - (uint8_t *)writeBuffer
    {
    return writePointer;
    }

    - (void)getWriteBuffer:(uint8_t **)bufferPtr availableSpace:(size_t *)availableSpacePtr
    {
    if (bufferPtr) *bufferPtr = writePointer;
    if (availableSpacePtr) *availableSpacePtr = [self availableSpace];
    }

    - (void)didWrite:(size_t)bytesWritten
    {
    writePointer += bytesWritten;
    }

    - (void)reset
    {
    readPointer = preBuffer;
    writePointer = preBuffer;
    }

    然后就是对读写指针进行处理的方法,如果读了多少数据readPointer就后移多少,写也是一样。
    而获取当前未读数据,则是用已写指针-已读指针,得到的差值,当已读=已写的时候,说明prebuffer数据读完,则重置读写指针的位置,还是指向初始化位置。

    讲完全局缓冲区对于指针的处理,我们接着往下说
    Part4.flushSSLBuffers方法:

    //缓冲ssl数据
    - (void)flushSSLBuffers
    {
    LogTrace();
    //断言为安全Socket
    NSAssert((flags & kSocketSecure), @"Cannot flush ssl buffers on non-secure socket");
    //如果preBuffer有数据可读,直接返回
    if ([preBuffer availableBytes] > 0)
    {
    return;
    }

    #if TARGET_OS_IPHONE
    //如果用的CFStream的TLS,把数据用CFStream的方式搬运到preBuffer中
    if ([self usingCFStreamForTLS])
    {
    //如果flag为kSecureSocketHasBytesAvailable,而且readStream有数据可读
    if ((flags & kSecureSocketHasBytesAvailable) && CFReadStreamHasBytesAvailable(readStream))
    {
    LogVerbose(@"%@ - Flushing ssl buffers into prebuffer...", THIS_METHOD);

    //默认一次读的大小为4KB??
    CFIndex defaultBytesToRead = (1024 * 4);

    //用来确保有这么大的提前buffer缓冲空间
    [preBuffer ensureCapacityForWrite:defaultBytesToRead];
    //拿到写的buffer
    uint8_t *buffer = [preBuffer writeBuffer];

    //从readStream中去读, 一次就读4KB,读到数据后,把数据写到writeBuffer中去 如果读的大小小于readStream中数据流大小,则会不停的触发callback,直到把数据读完为止。
    CFIndex result = CFReadStreamRead(readStream, buffer, defaultBytesToRead);
    //打印结果
    LogVerbose(@"%@ - CFReadStreamRead(): result = %i", THIS_METHOD, (int)result);

    //大于0,说明读写成功
    if (result > 0)
    {
    //把写的buffer头指针,移动result个偏移量
    [preBuffer didWrite:result];
    }

    //把kSecureSocketHasBytesAvailable 仍然可读的标记移除
    flags &= ~kSecureSocketHasBytesAvailable;
    }

    return;
    }

    #endif

    //不用CFStream的处理方法

    //先设置一个预估可用的大小
    __block NSUInteger estimatedBytesAvailable = 0;
    //更新预估可用的Block
    dispatch_block_t updateEstimatedBytesAvailable = ^{

    //预估大小 = 未读的大小 + SSL的可读大小
    estimatedBytesAvailable = socketFDBytesAvailable + [sslPreBuffer availableBytes];

    size_t sslInternalBufSize = 0;
    //获取到ssl上下文的大小,从sslContext中
    SSLGetBufferedReadSize(sslContext, &sslInternalBufSize);
    //再加上下文的大小
    estimatedBytesAvailable += sslInternalBufSize;
    };

    //调用这个Block
    updateEstimatedBytesAvailable();

    //如果大于0,说明有数据可读
    if (estimatedBytesAvailable > 0)
    {

    LogVerbose(@"%@ - Flushing ssl buffers into prebuffer...", THIS_METHOD);

    //标志,循环是否结束,SSL的方式是会阻塞的,直到读的数据有estimatedBytesAvailable大小为止,或者出错
    BOOL done = NO;
    do
    {
    LogVerbose(@"%@ - estimatedBytesAvailable = %lu", THIS_METHOD, (unsigned long)estimatedBytesAvailable);

    // Make sure there's enough room in the prebuffer
    //确保有足够的空间给prebuffer
    [preBuffer ensureCapacityForWrite:estimatedBytesAvailable];

    // Read data into prebuffer
    //拿到写的buffer
    uint8_t *buffer = [preBuffer writeBuffer];
    size_t bytesRead = 0;
    //用SSLRead函数去读,读到后,把数据写到buffer中,estimatedBytesAvailable为需要读的大小,bytesRead这一次实际读到字节大小,为sslContext上下文
    OSStatus result = SSLRead(sslContext, buffer, (size_t)estimatedBytesAvailable, &bytesRead);
    LogVerbose(@"%@ - read from secure socket = %u", THIS_METHOD, (unsigned)bytesRead);

    //把写指针后移bytesRead大小
    if (bytesRead > 0)
    {
    [preBuffer didWrite:bytesRead];
    }

    LogVerbose(@"%@ - prebuffer.length = %zu", THIS_METHOD, [preBuffer availableBytes]);

    //如果读数据出现错误
    if (result != noErr)
    {
    done = YES;
    }
    else
    {
    //在更新一下可读的数据大小
    updateEstimatedBytesAvailable();
    }

    }
    //只有done为NO,而且 estimatedBytesAvailable大于0才继续循环
    while (!done && estimatedBytesAvailable > 0);
    }
    }

    这个方法有点略长,包含了两种SSL的数据处理:

    1. CFStream类型:我们会调用下面这个函数去从stream并且读取数据并解密:
    CFIndex result = CFReadStreamRead(readStream, buffer, defaultBytesToRead);

    数据被读取到后,直接转移到了prebuffer中,并且调用:

    [preBuffer didWrite:result];

    让写指针后移读取到的数据大小。
    这里有两个关于CFReadStreamRead方法,需要注意的问题:
    1)就是我们调用它去读取4KB数据,并不仅仅是只读这么多,而是因为这个方法是会递归调用的,它每次只读4KB,直到把stream中的数据读完。
    2)我们之前设置的CFStream函数的回调,在数据来了之后只会被触发一次,以后数据再来都不会触发。直到我们调用这个方法,把stream中的数据读完,下次再来数据才会触发函数回调。这也是我们在使用CFStream的时候,不需要担心像source那样,有数据会不断的被触发回调,而需要挂起像source那样挂起stream(实际也没有这样的方法)。

    1. SSL安全通道类型:这里我们主要是循环去调用下面这个函数去读取数据:
    OSStatus result = SSLRead(sslContext, buffer, (size_t)estimatedBytesAvailable, &bytesRead);

    其他的基本和CFStream一致

    这里需要注意的是SSLRead这个方法,并不是直接从我们的socket中获取到的数据,而是从我们一开始绑定的SSL回调函数中,得到数据。而回调函数本身,也需要调用read函数从socket中获取到加密的数据。然后再经由SSLRead这个方法,数据被解密,并且传递给buffer

    至于SSLRead绑定的回调函数,是怎么处理数据读取的,因为它处理数据的流程,和我们doReadData后续数据读取处理基本相似,所以现在暂时不提。

    我们绕了一圈,讲完了这个包为空或者当前暂停状态下的前置处理,总结一下:
    1. 就是如果是SSL类型的数据,那么先解密了,缓冲到prebuffer中去。
    2. 判断当前socket可读数据大于0,非CFStreamSSL类型,则挂起source,防止反复触发。
    Part5.接着我们开始doReadData正常数据处理流程:

    首先它大的方向,依然是分为3种类型的数据处理:
    1.SSL安全通道; 2.CFStream类型SSL; 3.普通数据传输。
    因为这3种类型的代码,重复部分较大,处理流程基本类似,只不过调用读取方法所有区别

    //1.
    OSStatus result = SSLRead(sslContext, buffer, (size_t)estimatedBytesAvailable, &bytesRead);
    //2.
    CFIndex result = CFReadStreamRead(readStream, buffer, defaultBytesToRead);
    //3.
    ssize_t result = read(socketFD, buffer, (size_t)bytesToRead);

    SSLRead回调函数内部,也调用了第3种read读取,这个我们后面会说。
    现在这里我们将跳过前两种(方法部分调用可以见上面的flushSSLBuffers方法),只讲第3种普通数据的读取操作,而SSL的读取操作,基本一致。

    先来看看当前数据包任务是否完成,是如何定义的:

    由于框架提供的对外read接口:


    - (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;
    - (void)readDataToLength:(NSUInteger)length withTimeout:(NSTimeInterval)timeout tag:(long)tag;
    - (void)readDataToData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag;

    将数据读取是否完成的操作,大致分为这3个类型:
    1.全读;2读取一定的长度;3读取到某个标记符为止。

    当且仅当上面3种类型对应的操作完成,才视作当前包任务完成,才会回调我们在类中声明的读取消息的代理:

    - (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag

    否则就等待着,直到当前数据包任务完成。

    然后我们读取数据的流程大致如下:

    先从prebuffer中去读取,如果读完了,当前数据包任务仍未完成,那么再从socket中去读取。
    而判断包是否读完,都是用我们上面的3种类型,来对应处理的。



    作者:Cooci
    链接:https://www.jianshu.com/p/5a2df8a6a54e






    收起阅读 »

    CocoaAsyncSocket源码Read(四)

    前文讲完了两次TLS建立连接的流程,接着就是本篇的重头戏了:doReadData方法。在这里我不准备直接把这个整个方法列出来,因为就光这一个方法,加上注释有1200行,整个贴过来也无法展开描述,所以在这里我打算对它分段进行讲解:注:以下代码整个包括在这个方法定...
    继续阅读 »
    前文讲完了两次TLS建立连接的流程,接着就是本篇的重头戏了:doReadData方法。在这里我不准备直接把这个整个方法列出来,因为就光这一个方法,加上注释有1200行,整个贴过来也无法展开描述,所以在这里我打算对它分段进行讲解:

    注:以下代码整个包括在doReadData大括号中:

    //读取数据
    - (void)doReadData
    {
    ....
    }
    Part1.无法正常读取数据时的前置处理:
    //如果当前读取的包为空,或者flag为读取停止,这两种情况是不能去读取数据的
    if ((currentRead == nil) || (flags & kReadsPaused))
    {
    LogVerbose(@"No currentRead or kReadsPaused");

    // Unable to read at this time
    //如果是安全的通信,通过TLS/SSL
    if (flags & kSocketSecure)
    {
    //刷新SSLBuffer,把数据从链路上移到prebuffer中 (当前不读取数据的时候做)
    [self flushSSLBuffers];
    }

    //判断是否用的是 CFStream的TLS
    if ([self usingCFStreamForTLS])
    {

    }
    else
    {
    //挂起source
    if (socketFDBytesAvailable > 0)
    {
    [self suspendReadSource];
    }
    }
    return;
    }

    当我们当前读取的包是空或者标记为读停止状态的时候,则不会去读取数据。
    前者不难理解,因为我们要读取的数据最终是要传给currentRead中去的,所以如果currentRead为空,我们去读数据也没有意义。
    后者kReadsPaused标记是从哪里加上的呢?我们全局搜索一下,发现它才read超时的时候被添加。
    讲到这我们顺便来看这个读取超时的一个逻辑,我们每次做读取任务传进来的超时,都会调用这么一个方法:

    Part2.读取超时处理:
    [self setupReadTimerWithTimeout:currentRead->timeout];

    //初始化读的超时
    - (void)setupReadTimerWithTimeout:(NSTimeInterval)timeout
    {
    if (timeout >= 0.0)
    {
    //生成一个定时器source
    readTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, socketQueue);

    __weak GCDAsyncSocket *weakSelf = self;

    //句柄
    dispatch_source_set_event_handler(readTimer, ^{ @autoreleasepool {
    #pragma clang diagnostic push
    #pragma clang diagnostic warning "-Wimplicit-retain-self"

    __strong GCDAsyncSocket *strongSelf = weakSelf;
    if (strongSelf == nil) return_from_block;

    //执行超时操作
    [strongSelf doReadTimeout];

    #pragma clang diagnostic pop
    }});

    #if !OS_OBJECT_USE_OBJC
    dispatch_source_t theReadTimer = readTimer;

    //取消的句柄
    dispatch_source_set_cancel_handler(readTimer, ^{
    #pragma clang diagnostic push
    #pragma clang diagnostic warning "-Wimplicit-retain-self"

    LogVerbose(@"dispatch_release(readTimer)");
    dispatch_release(theReadTimer);

    #pragma clang diagnostic pop
    });
    #endif

    //定时器延时 timeout时间执行
    dispatch_time_t tt = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC));
    //间隔为永远,即只执行一次
    dispatch_source_set_timer(readTimer, tt, DISPATCH_TIME_FOREVER, 0);
    dispatch_resume(readTimer);
    }
    }

    这个方法定义了一个GCD定时器,这个定时器只执行一次,间隔就是我们的超时,很显然这是一个延时执行,那小伙伴要问了,这里为什么我们不用NSTimer或者下面这种方式:
    [self performSelector:<#(nonnull SEL)#> withObject:<#(nullable id)#> afterDelay:<#(NSTimeInterval)#>

    原因很简单,performSelector是基于runloop才能使用的,它本质是转化成runloop基于非端口的源source0。很显然我们所在的socketQueue开辟出来的线程,并没有添加一个runloop。而NSTimer也是一样。

    所以这里我们用GCD Timer,因为它是基于XNU内核来实现的,并不需要借助于runloop

    这里当超时时间间隔到达时,我们会执行超时操作:

    [strongSelf doReadTimeout];


    //执行超时操作
    - (void)doReadTimeout
    {
    // This is a little bit tricky.
    // Ideally we'd like to synchronously query the delegate about a timeout extension.
    // But if we do so synchronously we risk a possible deadlock.
    // So instead we have to do so asynchronously, and callback to ourselves from within the delegate block.

    //因为这里用同步容易死锁,所以用异步从代理中回调

    //标记读暂停
    flags |= kReadsPaused;

    __strong id theDelegate = delegate;

    //判断是否实现了延时 补时的代理
    if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:shouldTimeoutReadWithTag:elapsed:bytesDone:)])
    {
    //拿到当前读的包
    GCDAsyncReadPacket *theRead = currentRead;

    //代理queue中回调
    dispatch_async(delegateQueue, ^{ @autoreleasepool {

    NSTimeInterval timeoutExtension = 0.0;

    //调用代理方法,拿到续的时长
    timeoutExtension = [theDelegate socket:self shouldTimeoutReadWithTag:theRead->tag
    elapsed:theRead->timeout
    bytesDone:theRead->bytesDone];

    //socketQueue中,做延时
    dispatch_async(socketQueue, ^{ @autoreleasepool {

    [self doReadTimeoutWithExtension:timeoutExtension];
    }});
    }});
    }
    else
    {
    [self doReadTimeoutWithExtension:0.0];
    }
    }
    //做读取数据延时
    - (void)doReadTimeoutWithExtension:(NSTimeInterval)timeoutExtension
    {
    if (currentRead)
    {
    if (timeoutExtension > 0.0)
    {
    //把超时加上
    currentRead->timeout += timeoutExtension;

    // Reschedule the timer
    //重新生成时间
    dispatch_time_t tt = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeoutExtension * NSEC_PER_SEC));
    //重置timer时间
    dispatch_source_set_timer(readTimer, tt, DISPATCH_TIME_FOREVER, 0);

    // Unpause reads, and continue
    //在把paused标记移除
    flags &= ~kReadsPaused;
    //继续去读取数据
    [self doReadData];
    }
    else
    {
    //输出读取超时,并断开连接
    LogVerbose(@"ReadTimeout");

    [self closeWithError:[self readTimeoutError]];
    }
    }
    }

    这里调用了续时代理,如果我们实现了这个代理,则可以增加这个超时时间,然后重新生成超时定时器,移除读取停止的标记kReadsPaused。继续去读取数据。
    否则我们就断开socket
    注意:这个定时器会被取消,如果当前数据包被读取完成,这样就不会走到定时器超时的时间,则不会断开socket

    我们接着回到doReadData中,我们讲到如果当前读取包为空或者状态为kReadsPaused,我们就去执行一些非读取数据的处理。
    这里我们第一步去判断当前连接是否为kSocketSecure,也就是安全通道的TLS。如果是我们则调用:

    if (flags & kSocketSecure)
    {
    //刷新,把TLS加密型的数据从链路上移到prebuffer中 (当前暂停的时候做)
    [self flushSSLBuffers];
    }

    按理说,我们有当前读取包的时候,在去从prebuffersocket中去读取,但是这里为什么要提前去读呢?
    我们来看看这个框架作者的解释:

    // Here's the situation:
    // We have an established secure connection.
    // There may not be a currentRead, but there might be encrypted data sitting around for us.
    // When the user does get around to issuing a read, that encrypted data will need to be decrypted.
    // So why make the user wait?
    // We might as well get a head start on decrypting some data now.
    // The other reason we do this has to do with detecting a socket disconnection.
    // The SSL/TLS protocol has it's own disconnection handshake.
    // So when a secure socket is closed, a "goodbye" packet comes across the wire.
    // We want to make sure we read the "goodbye" packet so we can properly detect the TCP disconnection.

    简单来讲,就是我们用TLS类型的Socket,读取数据的时候需要解密的过程,而这个过程是费时的,我们没必要让用户在读取数据的时候去等待这个解密的过程,我们可以提前在数据一到达,就去读取解密。
    而且这种方式,还能时刻根据TLSgoodbye包来准确的检测到TCP断开连接。



    作者:Cooci
    链接:https://www.jianshu.com/p/5a2df8a6a54e







    收起阅读 »

    CocoaAsyncSocket源码Read(三)

    这里我们就讲讲几个重要的关于SSL的函数,其余细节可以看看注释:创建SSL上下文对象:sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLServerSide, kSSLStreamType); ssl...
    继续阅读 »

    这里我们就讲讲几个重要的关于SSL的函数,其余细节可以看看注释:

    1. 创建SSL上下文对象:
    sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLServerSide, kSSLStreamType);
    sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLClientSide, kSSLStreamType);

    这个函数用来创建一个SSL上下文,我们接下来会把配置字典tlsSettings中所有的参数,都设置到这个sslContext中去,然后用这个sslContext进行TLS后续操作,握手等。

    1. 给SSL设置读写回调:
    status = SSLSetIOFuncs(sslContext, &SSLReadFunction, &SSLWriteFunction);

    这两个回调函数如下:

    //读函数
    static OSStatus SSLReadFunction(SSLConnectionRef connection, void *data, size_t *dataLength)
    {
    //拿到socket
    GCDAsyncSocket *asyncSocket = (__bridge GCDAsyncSocket *)connection;

    //断言当前为socketQueue
    NSCAssert(dispatch_get_specific(asyncSocket->IsOnSocketQueueOrTargetQueueKey), @"What the deuce?");

    //读取数据,并且返回状态码
    return [asyncSocket sslReadWithBuffer:data length:dataLength];
    }
    //写函数
    static OSStatus SSLWriteFunction(SSLConnectionRef connection, const void *data, size_t *dataLength)
    {
    GCDAsyncSocket *asyncSocket = (__bridge GCDAsyncSocket *)connection;

    NSCAssert(dispatch_get_specific(asyncSocket->IsOnSocketQueueOrTargetQueueKey), @"What the deuce?");

    return [asyncSocket sslWriteWithBuffer:data length:dataLength];
    }

    他们分别调用了sslReadWithBuffersslWriteWithBuffer两个函数进行SSL的读写处理,关于这两个函数,我们后面再来说。
    1. 发起SSL连接:
    status = SSLSetConnection(sslContext, (__bridge SSLConnectionRef)self);

    到这一步,前置的重要操作就完成了,接下来我们是对SSL进行一些额外的参数配置:
    我们根据tlsSettingsGCDAsyncSocketManuallyEvaluateTrust字段,去判断是否需要手动信任服务端证书,调用如下函数

    status = SSLSetSessionOption(sslContext, kSSLSessionOptionBreakOnServerAuth, true);

    这个函数是用来设置一些可选项的,当然不止kSSLSessionOptionBreakOnServerAuth这一种,还有许多种类型的可选项,感兴趣的朋友可以自行点进去看看这个枚举。

    接着我们按照字典中的设置项,一项一项去设置ssl上下文,类似:


    status = SSLSetPeerDomainName(sslContext, peer, peerLen);

    设置完这些有效的,我们还需要去检查无效的key,万一我们设置了这些废弃的api,我们需要报错处理。

    做完这些操作后,我们初始化了一个sslPreBuffer,这个ssl安全通道下的全局缓冲区:

    sslPreBuffer = [[GCDAsyncSocketPreBuffer alloc] initWithCapacity:(1024 * 4)];

    然后把prebuffer全局缓冲区中的数据全部挪到sslPreBuffer中去,这里为什么要这么做呢?按照我们上面的流程图来说,正确的数据流向应该是从sslPreBuffer->prebuffer的,楼主在这里也思考了很久,最后我的想法是,就是初始化的时候,数据的流向的统一,在我们真正数据读取的时候,就不需要做额外的判断了。

    到这里我们所有的握手前初始化工作都做完了。

    接着我们调用了ssl_continueSSLHandshake方法开始SSL握手

    //SSL的握手
    - (void)ssl_continueSSLHandshake
    {
    LogTrace();

    //用我们的SSL上下文对象去握手
    OSStatus status = SSLHandshake(sslContext);
    //拿到握手的结果,赋值给上次握手的结果
    lastSSLHandshakeError = status;

    //如果没错
    if (status == noErr)
    {
    LogVerbose(@"SSLHandshake complete");

    //把开始读写TLS,从标记中移除
    flags &= ~kStartingReadTLS;
    flags &= ~kStartingWriteTLS;

    //把Socket安全通道标记加上
    flags |= kSocketSecure;

    //拿到代理
    __strong id theDelegate = delegate;

    if (delegateQueue && [theDelegate respondsToSelector:@selector(socketDidSecure:)])
    {
    dispatch_async(delegateQueue, ^{ @autoreleasepool {
    //调用socket已经开启安全通道的代理方法
    [theDelegate socketDidSecure:self];
    }});
    }
    //停止读取
    [self endCurrentRead];
    //停止写
    [self endCurrentWrite];
    //开始下一次读写任务
    [self maybeDequeueRead];
    [self maybeDequeueWrite];
    }
    //如果是认证错误
    else if (status == errSSLPeerAuthCompleted)
    {
    LogVerbose(@"SSLHandshake peerAuthCompleted - awaiting delegate approval");

    __block SecTrustRef trust = NULL;
    //从sslContext拿到证书相关的细节
    status = SSLCopyPeerTrust(sslContext, &trust);
    //SSl证书赋值出错
    if (status != noErr)
    {
    [self closeWithError:[self sslError:status]];
    return;
    }

    //拿到状态值
    int aStateIndex = stateIndex;
    //socketQueue
    dispatch_queue_t theSocketQueue = socketQueue;

    __weak GCDAsyncSocket *weakSelf = self;

    //创建一个完成Block
    void (^comletionHandler)(BOOL) = ^(BOOL shouldTrust){ @autoreleasepool {
    #pragma clang diagnostic push
    #pragma clang diagnostic warning "-Wimplicit-retain-self"

    dispatch_async(theSocketQueue, ^{ @autoreleasepool {

    if (trust) {
    CFRelease(trust);
    trust = NULL;
    }

    __strong GCDAsyncSocket *strongSelf = weakSelf;
    if (strongSelf)
    {
    [strongSelf ssl_shouldTrustPeer:shouldTrust stateIndex:aStateIndex];
    }
    }});

    #pragma clang diagnostic pop
    }};

    __strong id theDelegate = delegate;

    if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didReceiveTrust:completionHandler:)])
    {
    dispatch_async(delegateQueue, ^{ @autoreleasepool {

    #pragma mark - 调用代理我们自己去https认证
    [theDelegate socket:self didReceiveTrust:trust completionHandler:comletionHandler];
    }});
    }
    //没实现代理直接报错关闭连接。
    else
    {
    if (trust) {
    CFRelease(trust);
    trust = NULL;
    }

    NSString *msg = @"GCDAsyncSocketManuallyEvaluateTrust specified in tlsSettings,"
    @" but delegate doesn't implement socket:shouldTrustPeer:";

    [self closeWithError:[self otherError:msg]];
    return;
    }
    }

    //握手错误为 IO阻塞的
    else if (status == errSSLWouldBlock)
    {
    LogVerbose(@"SSLHandshake continues...");

    // Handshake continues...
    //
    // This method will be called again from doReadData or doWriteData.
    }
    else
    {
    //其他错误直接关闭连接
    [self closeWithError:[self sslError:status]];
    }
    }

    这个方法就做了一件事,就是SSL握手,我们调用了这个函数完成握手:


    OSStatus status = SSLHandshake(sslContext);

    然后握手的结果分为4种情况:

    1. 如果返回为noErr,这个会话已经准备好了安全的通信,握手成功。
    • 如果返回的valueerrSSLWouldBlock,握手方法必须再次调用。
    • 如果返回为errSSLServerAuthCompleted,如果我们要调用代理,我们需要相信服务器,然后再次调用握手,去恢复握手或者关闭连接。
    • 否则,返回的value表明了错误的code

    其中需要说说的是errSSLWouldBlock,这个是IO阻塞下的错误,也就是服务器的结果还没来得及返回,当握手结果返回的时候,这个方法会被再次触发。

    还有就是errSSLServerAuthCompleted下,我们回调了代理:

    [theDelegate socket:self didReceiveTrust:trust completionHandler:comletionHandler];

    我们可以去手动对证书进行认证并且信任,当完成回调后,会调用到这个方法里来,再次进行握手:

    //修改信息后再次进行SSL握手
    - (void)ssl_shouldTrustPeer:(BOOL)shouldTrust stateIndex:(int)aStateIndex
    {
    LogTrace();

    if (aStateIndex != stateIndex)
    {
    return;
    }

    // Increment stateIndex to ensure completionHandler can only be called once.
    stateIndex++;

    if (shouldTrust)
    {
    NSAssert(lastSSLHandshakeError == errSSLPeerAuthCompleted, @"ssl_shouldTrustPeer called when last error is %d and not errSSLPeerAuthCompleted", (int)lastSSLHandshakeError);
    [self ssl_continueSSLHandshake];
    }
    else
    {

    [self closeWithError:[self sslError:errSSLPeerBadCert]];
    }
    }



    到这里,我们就整个完成安全通道下的TLS认证。

    接着我们来看看基于CFStreamTLS

    因为CFStream是上层API,所以它的TLS流程相当简单,我们来看看cf_startTLS这个方法:


    //CF流形式的TLS
    - (void)cf_startTLS
    {
    LogTrace();

    LogVerbose(@"Starting TLS (via CFStream)...");

    //如果preBuffer的中可读数据大于0,错误关闭
    if ([preBuffer availableBytes] > 0)
    {
    NSString *msg = @"Invalid TLS transition. Handshake has already been read from socket.";

    [self closeWithError:[self otherError:msg]];
    return;
    }

    //挂起读写source
    [self suspendReadSource];
    [self suspendWriteSource];

    //把未读的数据大小置为0
    socketFDBytesAvailable = 0;
    //去掉下面两种flag
    flags &= ~kSocketCanAcceptBytes;
    flags &= ~kSecureSocketHasBytesAvailable;

    //标记为CFStream
    flags |= kUsingCFStreamForTLS;

    //如果创建读写stream失败
    if (![self createReadAndWriteStream])
    {
    [self closeWithError:[self otherError:@"Error in CFStreamCreatePairWithSocket"]];
    return;
    }
    //注册回调,这回监听可读数据了!!
    if (![self registerForStreamCallbacksIncludingReadWrite:YES])
    {
    [self closeWithError:[self otherError:@"Error in CFStreamSetClient"]];
    return;
    }
    //添加runloop
    if (![self addStreamsToRunLoop])
    {
    [self closeWithError:[self otherError:@"Error in CFStreamScheduleWithRunLoop"]];
    return;
    }

    NSAssert([currentRead isKindOfClass:[GCDAsyncSpecialPacket class]], @"Invalid read packet for startTLS");
    NSAssert([currentWrite isKindOfClass:[GCDAsyncSpecialPacket class]], @"Invalid write packet for startTLS");

    //拿到当前包
    GCDAsyncSpecialPacket *tlsPacket = (GCDAsyncSpecialPacket *)currentRead;
    //拿到ssl配置
    CFDictionaryRef tlsSettings = (__bridge CFDictionaryRef)tlsPacket->tlsSettings;

    // Getting an error concerning kCFStreamPropertySSLSettings ?
    // You need to add the CFNetwork framework to your iOS application.

    //直接设置给读写stream
    BOOL r1 = CFReadStreamSetProperty(readStream, kCFStreamPropertySSLSettings, tlsSettings);
    BOOL r2 = CFWriteStreamSetProperty(writeStream, kCFStreamPropertySSLSettings, tlsSettings);

    //设置失败
    if (!r1 && !r2) // Yes, the && is correct - workaround for apple bug.
    {
    [self closeWithError:[self otherError:@"Error in CFStreamSetProperty"]];
    return;
    }

    //打开流
    if (![self openStreams])
    {
    [self closeWithError:[self otherError:@"Error in CFStreamOpen"]];
    return;
    }

    LogVerbose(@"Waiting for SSL Handshake to complete...");
    }
    1.这个方法很简单,首先它挂起了读写source,然后重新初始化了读写流,并且绑定了回调,和添加了runloop
    这里我们为什么要用重新这么做?看过之前connect篇的同学就知道,我们在连接成功之后,去初始化过读写流,这些操作之前都做过。而在这里重新初始化,并不会重新创建,只是修改读写流的一些参数,其中主要是下面这个方法,传递了一个YES过去:
    if (![self registerForStreamCallbacksIncludingReadWrite:YES])

    这个参数会使方法里多添加一种触发回调的方式:kCFStreamEventHasBytesAvailable
    当有数据可读时候,触发Stream回调。

    2.接着我们用下面这个函数把TLS的配置参数,设置给读写stream:

    //直接设置给读写stream
    BOOL r1 = CFReadStreamSetProperty(readStream, kCFStreamPropertySSLSettings, tlsSettings);
    BOOL r2 = CFWriteStreamSetProperty(writeStream, kCFStreamPropertySSLSettings, tlsSettings);

    3.最后打开读写流,整个CFStream形式的TLS就完成了。

    看到这,大家可能对数据触发的问题有些迷惑。总结一下,我们到现在一共有3种触发的回调:
    1. 读写source:这个和socket绑定在一起,一旦有数据到达,就会触发事件句柄,但是我们可以看到在cf_startTLS方法中我们调用了:

     //挂起读写source
    [self suspendReadSource];
    [self suspendWriteSource];

    所以,对于CFStream形式的TLS的读写并不是由source触发的,而其他的都是由source来触发。

    1. CFStream绑定的几种事件的读写回调函数:
    static void CFReadStreamCallback (CFReadStreamRef stream, CFStreamEventType type, void *pInfo)
    static void CFWriteStreamCallback (CFWriteStreamRef stream, CFStreamEventType type, void *pInfo)

    这个和CFStream形式的TLS相关,会触发这种形式的握手,流末尾等出现的错误,还有该形式下数据到达。
    因为我们在一开始的连接完成就初始化过stream,所以非CFStream形式下也回触发这个回调,只是不会在数据到达触发而已。

    1. SSL安全通道形式,绑定的SSL读写函数:
    static OSStatus SSLReadFunction(SSLConnectionRef connection, void *data, size_t *dataLength)
    static OSStatus SSLWriteFunction(SSLConnectionRef connection, const void *data, size_t *dataLength)

    这个函数并不是由系统触发,而是需要我们主动去调用SSLReadSSLWrite两个函数,回调才能被触发。























    收起阅读 »

    CocoaAsyncSocket源码Read(二)

    讲讲两种TLS建立连接的过程讲到这里,就不得不提一下,这里个框架开启TLS的过程。它对外提供了这么一个方法来开启TLS:- (void)startTLS:(NSDictionary *)tlsSettings 可以根据一个字典,去开启并且配置TLS,那么这个字...
    继续阅读 »
    讲讲两种TLS建立连接的过程

    讲到这里,就不得不提一下,这里个框架开启TLS的过程。它对外提供了这么一个方法来开启TLS

    - (void)startTLS:(NSDictionary *)tlsSettings

    可以根据一个字典,去开启并且配置TLS,那么这个字典里包含什么内容呢?
    一共包含以下这些key

    //配置SSL上下文的设置
    // Configure SSLContext from given settings
    //
    // Checklist:
    // 1\. kCFStreamSSLPeerName //证书名
    // 2\. kCFStreamSSLCertificates //证书数组
    // 3\. GCDAsyncSocketSSLPeerID //证书ID
    // 4\. GCDAsyncSocketSSLProtocolVersionMin //SSL最低版本
    // 5\. GCDAsyncSocketSSLProtocolVersionMax //SSL最高版本
    // 6\. GCDAsyncSocketSSLSessionOptionFalseStart
    // 7\. GCDAsyncSocketSSLSessionOptionSendOneByteRecord
    // 8\. GCDAsyncSocketSSLCipherSuites
    // 9\. GCDAsyncSocketSSLDiffieHellmanParameters (Mac)
    //
    // Deprecated (throw error): //被废弃的参数,如果设置了就会报错关闭socket
    // 10\. kCFStreamSSLAllowsAnyRoot
    // 11\. kCFStreamSSLAllowsExpiredRoots
    // 12\. kCFStreamSSLAllowsExpiredCertificates
    // 13\. kCFStreamSSLValidatesCertificateChain
    // 14\. kCFStreamSSLLevel
    其中有些Key的值,具体是什么意思,value如何设置,可以查查苹果文档,限于篇幅,我们就不赘述了,只需要了解重要的几个参数即可。
    后面一部分是被废弃的参数,如果我们设置了,就会报错关闭socket连接。
    除此之外,还有这么3个key被我们遗漏了,这3个key,是框架内部用来判断,并且做一些处理的标识:

    kCFStreamSSLIsServer  //判断当前是否是服务端
    GCDAsyncSocketManuallyEvaluateTrust //判断是否需要手动信任SSL
    GCDAsyncSocketUseCFStreamForTLS //判断是否使用CFStream形式的TLS

    这3个key的大意如注释,后面我们还会讲到,其中最重要的是GCDAsyncSocketUseCFStreamForTLS这个key,一旦我们设置为YES,将开启CFStream的TLS,关于这种基于流的TLS与普通的TLS的区别,我们来看看官方说明:

    • GCDAsyncSocketUseCFStreamForTLS (iOS only)

    • The value must be of type NSNumber, encapsulating a BOOL value.

    • By default GCDAsyncSocket will use the SecureTransport layer to perform encryption.

    • This gives us more control over the security protocol (many more configuration options),

    • plus it allows us to optimize things like sys calls and buffer allocation.

    • However, if you absolutely must, you can instruct GCDAsyncSocket to use the old-fashioned encryption

    • technique by going through the CFStream instead. So instead of using SecureTransport, GCDAsyncSocket

    • will instead setup a CFRead/CFWriteStream. And then set the kCFStreamPropertySSLSettings property

    • (via CFReadStreamSetProperty / CFWriteStreamSetProperty) and will pass the given options to this method.

    • Thus all the other keys in the given dictionary will be ignored by GCDAsyncSocket,

    • and will passed directly CFReadStreamSetProperty / CFWriteStreamSetProperty.

    • For more infomation on these keys, please see the documentation for kCFStreamPropertySSLSettings.

    • If unspecified, the default value is NO.

    从上述说明中,我们可以得知,CFStream形式的TLS仅仅可以被用于iOS平台,并且它是一种过时的加解密技术,如果我们没有必要,最好还是不要用这种方式的TLS

    至于它的实现,我们接着往下看。

    //开启TLS
    - (void)startTLS:(NSDictionary *)tlsSettings
    {
    LogTrace();

    if (tlsSettings == nil)
    {

    tlsSettings = [NSDictionary dictionary];
    }
    //新生成一个TLS特殊的包
    GCDAsyncSpecialPacket *packet = [[GCDAsyncSpecialPacket alloc] initWithTLSSettings:tlsSettings];

    dispatch_async(socketQueue, ^{ @autoreleasepool {

    if ((flags & kSocketStarted) && !(flags & kQueuedTLS) && !(flags & kForbidReadsWrites))
    {
    //添加到读写Queue中去
    [readQueue addObject:packet];
    [writeQueue addObject:packet];
    //把TLS标记加上
    flags |= kQueuedTLS;
    //开始读取TLS的任务,读到这个包会做TLS认证。在这之前的包还是不用认证就可以传送完
    [self maybeDequeueRead];
    [self maybeDequeueWrite];
    }
    }});

    }


    这个方法就是对外提供的开启TLS的方法,它把传进来的字典,包成一个TLS的特殊包,这个GCDAsyncSpecialPacket类包里面就一个字典属性:

    - (id)initWithTLSSettings:(NSDictionary *)settings;


    然后我们把这个包添加到读写queue中去,并且标记当前的状态,然后去执行maybeDequeueReadmaybeDequeueWrite
    需要注意的是,这里只有读到这个GCDAsyncSpecialPacket时,才开始TLS认证和握手。

    接着我们就来到了maybeDequeueRead这个方法,这个方法我们在前面第一条中讲到过,忘了的可以往上拉一下页面就可以看到。
    它就是让我们的ReadQueue中的读任务离队,并且开始执行这条读任务。

    • 当我们读到的是GCDAsyncSpecialPacket类型的包,则开始进行TLS认证。
    • 当我们读到的是GCDAsyncReadPacket类型的包,则开始进行一次读取数据的任务。
    • 如果ReadQueue为空,则对几种情况进行判断,是否是读取上一次数据失败,则断开连接。
      如果是基于TLSSocket,则把SSL安全通道的数据,移到全局缓冲区preBuffer中。如果数据仍然为空,则恢复读source,等待下一次读source的触发。

    接着我们来看看这其中第一条,当读到的是一个GCDAsyncSpecialPacket类型的包,我们会调用maybeStartTLS这个方法:


    //可能开启TLS
    - (void)maybeStartTLS
    {

    //只有读和写TLS都开启
    if ((flags & kStartingReadTLS) && (flags & kStartingWriteTLS))
    {
    //需要安全传输
    BOOL useSecureTransport = YES;

    #if TARGET_OS_IPHONE
    {
    //拿到当前读的数据
    GCDAsyncSpecialPacket *tlsPacket = (GCDAsyncSpecialPacket *)currentRead;
    //得到设置字典
    NSDictionary *tlsSettings = tlsPacket->tlsSettings;

    //拿到Key为CFStreamTLS的 value
    NSNumber *value = [tlsSettings objectForKey:GCDAsyncSocketUseCFStreamForTLS];

    if (value && [value boolValue])
    //如果是用CFStream的,则安全传输为NO
    useSecureTransport = NO;
    }
    #endif
    //如果使用安全通道
    if (useSecureTransport)
    {
    //开启TLS
    [self ssl_startTLS];
    }
    //CFStream形式的Tls
    else
    {
    #if TARGET_OS_IPHONE
    [self cf_startTLS];
    #endif
    }
    }
    }

    这里根据我们之前添加标记,判断是否读写TLS状态,是才继续进行接下来的TLS认证。
    接着我们拿到当前GCDAsyncSpecialPacket,取得配置字典中keyGCDAsyncSocketUseCFStreamForTLS的值:
    如果为YES则说明使用CFStream形式的TLS,否则使用SecureTransport安全通道形式的TLS。关于这个配置项,还有二者的区别,我们前面就讲过了。

    接着我们分别来看看这两个方法,先来看看ssl_startTLS

    这个方法非常长,大概有400多行,所以为了篇幅和大家阅读体验,楼主简化了一部分内容用省略号+注释的形式表示。

    //开启TLS
    - (void)ssl_startTLS
    {
    LogTrace();

    LogVerbose(@"Starting TLS (via SecureTransport)...");

    //状态标记
    OSStatus status;

    //拿到当前读的数据包
    GCDAsyncSpecialPacket *tlsPacket = (GCDAsyncSpecialPacket *)currentRead;
    if (tlsPacket == nil) // Code to quiet the analyzer
    {
    NSAssert(NO, @"Logic error");

    [self closeWithError:[self otherError:@"Logic error"]];
    return;
    }
    //拿到设置
    NSDictionary *tlsSettings = tlsPacket->tlsSettings;

    // Create SSLContext, and setup IO callbacks and connection ref

    //根据key来判断,当前包是否是服务端的
    BOOL isServer = [[tlsSettings objectForKey:(__bridge NSString *)kCFStreamSSLIsServer] boolValue];

    //创建SSL上下文
    #if TARGET_OS_IPHONE || (__MAC_OS_X_VERSION_MIN_REQUIRED >= 1080)
    {
    //如果是服务端的创建服务端上下文,否则是客户端的上下文,用stream形式
    if (isServer)
    sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLServerSide, kSSLStreamType);
    else
    sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLClientSide, kSSLStreamType);
    //为空则报错返回
    if (sslContext == NULL)
    {
    [self closeWithError:[self otherError:@"Error in SSLCreateContext"]];
    return;
    }
    }

    #else // (__MAC_OS_X_VERSION_MIN_REQUIRED < 1080)
    {
    status = SSLNewContext(isServer, &sslContext);
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLNewContext"]];
    return;
    }
    }
    #endif

    //给SSL上下文设置 IO回调 分别为SSL 读写函数
    status = SSLSetIOFuncs(sslContext, &SSLReadFunction, &SSLWriteFunction);
    //设置出错
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetIOFuncs"]];
    return;
    }

    //在握手之调用,建立SSL连接 ,第一次连接 1
    status = SSLSetConnection(sslContext, (__bridge SSLConnectionRef)self);
    //连接出错
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetConnection"]];
    return;
    }

    //是否应该手动的去信任SSL
    BOOL shouldManuallyEvaluateTrust = [[tlsSettings objectForKey:GCDAsyncSocketManuallyEvaluateTrust] boolValue];
    //如果需要手动去信任
    if (shouldManuallyEvaluateTrust)
    {
    //是服务端的话,不需要,报错返回
    if (isServer)
    {
    [self closeWithError:[self otherError:@"Manual trust validation is not supported for server sockets"]];
    return;
    }
    //第二次连接 再去连接用kSSLSessionOptionBreakOnServerAuth的方式,去连接一次,这种方式可以直接信任服务端证书
    status = SSLSetSessionOption(sslContext, kSSLSessionOptionBreakOnServerAuth, true);
    //错误直接返回
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetSessionOption"]];
    return;
    }

    #if !TARGET_OS_IPHONE && (__MAC_OS_X_VERSION_MIN_REQUIRED < 1080)

    // Note from Apple's documentation:
    //
    // It is only necessary to call SSLSetEnableCertVerify on the Mac prior to OS X 10.8.
    // On OS X 10.8 and later setting kSSLSessionOptionBreakOnServerAuth always disables the
    // built-in trust evaluation. All versions of iOS behave like OS X 10.8 and thus
    // SSLSetEnableCertVerify is not available on that platform at all.

    //为了防止kSSLSessionOptionBreakOnServerAuth这种情况下,产生了不受信任的环境
    status = SSLSetEnableCertVerify(sslContext, NO);
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetEnableCertVerify"]];
    return;
    }

    #endif
    }

    //配置SSL上下文的设置

    id value;
    //这个参数是用来获取证书名验证,如果设置为NULL,则不验证
    // 1\. kCFStreamSSLPeerName

    value = [tlsSettings objectForKey:(__bridge NSString *)kCFStreamSSLPeerName];
    if ([value isKindOfClass:[NSString class]])
    {
    NSString *peerName = (NSString *)value;

    const char *peer = [peerName UTF8String];
    size_t peerLen = strlen(peer);

    //把证书名设置给SSL
    status = SSLSetPeerDomainName(sslContext, peer, peerLen);
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetPeerDomainName"]];
    return;
    }
    }
    //不是string就错误返回
    else if (value)
    {
    //这个断言啥用也没有啊。。
    NSAssert(NO, @"Invalid value for kCFStreamSSLPeerName. Value must be of type NSString.");

    [self closeWithError:[self otherError:@"Invalid value for kCFStreamSSLPeerName."]];
    return;
    }

    // 2\. kCFStreamSSLCertificates
    ...
    // 3\. GCDAsyncSocketSSLPeerID
    ...
    // 4\. GCDAsyncSocketSSLProtocolVersionMin
    ...
    // 5\. GCDAsyncSocketSSLProtocolVersionMax
    ...
    // 6\. GCDAsyncSocketSSLSessionOptionFalseStart
    ...
    // 7\. GCDAsyncSocketSSLSessionOptionSendOneByteRecord
    ...
    // 8\. GCDAsyncSocketSSLCipherSuites
    ...
    // 9\. GCDAsyncSocketSSLDiffieHellmanParameters (Mac)
    ...

    //弃用key的检查,如果有下列key对应的value,则都报弃用的错误

    // 10\. kCFStreamSSLAllowsAnyRoot
    ...
    // 11\. kCFStreamSSLAllowsExpiredRoots
    ...
    // 12\. kCFStreamSSLAllowsExpiredCertificates
    ...
    // 13\. kCFStreamSSLValidatesCertificateChain
    ...
    // 14\. kCFStreamSSLLevel
    ...

    // Setup the sslPreBuffer
    //
    // Any data in the preBuffer needs to be moved into the sslPreBuffer,
    // as this data is now part of the secure read stream.

    //初始化SSL提前缓冲 也是4Kb
    sslPreBuffer = [[GCDAsyncSocketPreBuffer alloc] initWithCapacity:(1024 * 4)];
    //获取到preBuffer可读大小
    size_t preBufferLength = [preBuffer availableBytes];

    //如果有可读内容
    if (preBufferLength > 0)
    {
    //确保SSL提前缓冲的大小
    [sslPreBuffer ensureCapacityForWrite:preBufferLength];
    //从readBuffer开始读,读这个长度到 SSL提前缓冲的writeBuffer中去
    memcpy([sslPreBuffer writeBuffer], [preBuffer readBuffer], preBufferLength);
    //移动提前的读buffer
    [preBuffer didRead:preBufferLength];
    //移动sslPreBuffer的写buffer
    [sslPreBuffer didWrite:preBufferLength];
    }
    //拿到上次错误的code,并且让上次错误code = 没错
    sslErrCode = lastSSLHandshakeError = noErr;

    // Start the SSL Handshake process
    //开始SSL握手过程
    [self ssl_continueSSLHandshake];
    }


    这个方法的结构也很清晰,主要就是建立TLS连接,并且配置SSL上下文对象:sslContext,为TLS握手做准备。











    收起阅读 »

    CocoaAsyncSocket源码Read(一)

    本文为CocoaAsyncSocket源码阅读 将重点涉及该框架是如何利用缓冲区对数据进行读取、以及各种情况下的数据包处理,其中还包括普通的、和基于TLS的不同读取操作等等。注:由于该框架源码篇幅过大,且有大部分相对抽象的数据操作逻辑,尽管楼主竭力想...
    继续阅读 »
    本文为CocoaAsyncSocket源码阅读 将重点涉及该框架是如何利用缓冲区对数据进行读取、以及各种情况下的数据包处理,其中还包括普通的、和基于TLS的不同读取操作等等。
    注:由于该框架源码篇幅过大,且有大部分相对抽象的数据操作逻辑,尽管楼主竭力想要简单的去陈述相关内容,但是阅读起来仍会有一定的难度。如果不是诚心想学习IM相关知识,在这里就可以离场了...

    附上一张 SSL / TSL


    • 1.浅析Read读取,并阐述数据从socket到用户手中的流程。✅
    • 2.讲讲两种TLS建立连接的过程。✅
    • 3.深入讲解Read的核心方法---doReadData的实现。❌
    正文:
    一.浅析Read读取,并阐述数据从socket到用户手中的流程

    大家用过这个框架就知道,我们每次读取数据之前都需要主动调用这么一个Read方法:

    [gcdSocket readDataWithTimeout:-1 tag:110];


    设置一个超时和tag值,这样我们就可以在这个超时的时间里,去读取到达当前socket的数据了。

    那么本篇Read就从这个方法开始说起,我们点进框架里,来到这个方法:

    - (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag
    {
    [self readDataWithTimeout:timeout buffer:nil bufferOffset:0 maxLength:0 tag:tag];
    }

    - (void)readDataWithTimeout:(NSTimeInterval)timeout
    buffer:(NSMutableData *)buffer
    bufferOffset:(NSUInteger)offset
    tag:(long)tag
    {
    [self readDataWithTimeout:timeout buffer:buffer bufferOffset:offset maxLength:0 tag:tag];
    }

    //用偏移量 maxLength 读取数据
    - (void)readDataWithTimeout:(NSTimeInterval)timeout
    buffer:(NSMutableData *)buffer
    bufferOffset:(NSUInteger)offset
    maxLength:(NSUInteger)length
    tag:(long)tag
    {
    if (offset > [buffer length]) {
    LogWarn(@"Cannot read: offset > [buffer length]");
    return;
    }

    GCDAsyncReadPacket *packet = [[GCDAsyncReadPacket alloc] initWithData:buffer
    startOffset:offset
    maxLength:length
    timeout:timeout
    readLength:0
    terminator:nil
    tag:tag];

    dispatch_async(socketQueue, ^{ @autoreleasepool {

    LogTrace();

    if ((flags & kSocketStarted) && !(flags & kForbidReadsWrites))
    {
    //往读的队列添加任务,任务是包的形式
    [readQueue addObject:packet];
    [self maybeDequeueRead];
    }
    }});
    }


    这个方法很简单。最终调用,去创建了一个GCDAsyncReadPacket类型的对象packet,简单来说这个对象是用来标识读取任务的。然后把这个packet对象添加到读取队列中。然后去调用:

    [self maybeDequeueRead];


    去从队列中取出读取任务包,做读取操作。

    还记得我们之前Connect篇讲到的GCDAsyncSocket这个类的一些属性,其中有这么一个:

    //当前这次读取数据任务包
    GCDAsyncReadPacket *currentRead;

    这个属性标识了我们当前这次读取的任务,当读取到packet任务时,其实这个属性就被赋值成packet,做数据读取。

    接着来看看GCDAsyncReadPacket这个类,同样我们先看看属性:

    @interface GCDAsyncReadPacket : NSObject
    {
    @public
    //当前包的数据 ,(容器,有可能为空)
    NSMutableData *buffer;
    //开始偏移 (数据在容器中开始写的偏移)
    NSUInteger startOffset;
    //已读字节数 (已经写了个字节数)
    NSUInteger bytesDone;

    //想要读取数据的最大长度 (有可能没有)
    NSUInteger maxLength;
    //超时时长
    NSTimeInterval timeout;
    //当前需要读取总长度 (这一次read读取的长度,不一定有,如果没有则可用maxLength)
    NSUInteger readLength;

    //包的边界标识数据 (可能没有)
    NSData *term;
    //判断buffer的拥有者是不是这个类,还是用户。
    //跟初始化传不传一个buffer进来有关,如果传了,则拥有者为用户 NO, 否则为YES
    BOOL bufferOwner;
    //原始传过来的data长度
    NSUInteger originalBufferLength;
    //数据包的tag
    long tag;
    }

    这个类的内容还是比较多的,但是其实理解起来也很简单,它主要是来装当前任务的一些标识和数据,使我们能够正确的完成我们预期的读取任务。
    这些属性,大家同样过一个眼熟即可,后面大家就能理解它们了。

    这个类还有一堆方法,包括初始化的、和一些数据的操作方法,其具体作用如下注释:

    //初始化
    - (id)initWithData:(NSMutableData *)d
    startOffset:(NSUInteger)s
    maxLength:(NSUInteger)m
    timeout:(NSTimeInterval)t
    readLength:(NSUInteger)l
    terminator:(NSData *)e
    tag:(long)i;

    //确保容器大小给多余的长度
    - (void)ensureCapacityForAdditionalDataOfLength:(NSUInteger)bytesToRead;
    ////预期中读的大小,决定是否走preBuffer
    - (NSUInteger)optimalReadLengthWithDefault:(NSUInteger)defaultValue shouldPreBuffer:(BOOL *)shouldPreBufferPtr;
    //读取指定长度的数据
    - (NSUInteger)readLengthForNonTermWithHint:(NSUInteger)bytesAvailable;

    //上两个方法的综合
    - (NSUInteger)readLengthForTermWithHint:(NSUInteger)bytesAvailable shouldPreBuffer:(BOOL *)shouldPreBufferPtr;

    //根据一个终结符去读数据,直到读到终结的位置或者最大数据的位置,返回值为该包的确定长度
    - (NSUInteger)readLengthForTermWithPreBuffer:(GCDAsyncSocketPreBuffer *)preBuffer found:(BOOL *)foundPtr;
    ////查找终结符,在prebuffer之后,返回值为该包的确定长度
    - (NSInteger)searchForTermAfterPreBuffering:(ssize_t)numBytes;

    这里暂时仍然不准备去讲这些方法,等我们用到了在去讲它。

    我们通过上述的属性和这些方法,能够把数据正确的读取到packet的属性buffer中,再用代理回传给用户。

    这个GCDAsyncReadPacket类暂时就先这样了,我们接着往下看,前面讲到调用maybeDequeueRead开始读取任务,我们接下来就看看这个方法:

    //让读任务离队,开始执行这条读任务
    - (void)maybeDequeueRead
    {
    LogTrace();
    NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");

    // If we're not currently processing a read AND we have an available read stream

    //如果当前读的包为空,而且flag为已连接
    if ((currentRead == nil) && (flags & kConnected))
    {
    //如果读的queue大于0 (里面装的是我们封装的GCDAsyncReadPacket数据包)
    if ([readQueue count] > 0)
    {
    // Dequeue the next object in the write queue
    //使得下一个对象从写的queue中离开

    //从readQueue中拿到第一个写的数据
    currentRead = [readQueue objectAtIndex:0];
    //移除
    [readQueue removeObjectAtIndex:0];

    //我们的数据包,如果是GCDAsyncSpecialPacket这种类型,这个包里装了TLS的一些设置
    //如果是这种类型的数据,那么我们就进行TLS
    if ([currentRead isKindOfClass:[GCDAsyncSpecialPacket class]])
    {
    LogVerbose(@"Dequeued GCDAsyncSpecialPacket");

    // Attempt to start TLS
    //标记flag为正在读取TLS
    flags |= kStartingReadTLS;

    // This method won't do anything unless both kStartingReadTLS and kStartingWriteTLS are set
    //只有读写都开启了TLS,才会做TLS认证
    [self maybeStartTLS];
    }
    else
    {
    LogVerbose(@"Dequeued GCDAsyncReadPacket");

    // Setup read timer (if needed)
    //设置读的任务超时,每次延时的时候还会调用 [self doReadData];
    [self setupReadTimerWithTimeout:currentRead->timeout];

    // Immediately read, if possible
    //读取数据
    [self doReadData];
    }
    }

    //读的队列没有数据,标记flag为,读了没有数据则断开连接状态
    else if (flags & kDisconnectAfterReads)
    {
    //如果标记有写然后断开连接
    if (flags & kDisconnectAfterWrites)
    {
    //如果写的队列为0,而且写为空
    if (([writeQueue count] == 0) && (currentWrite == nil))
    {
    //断开连接
    [self closeWithError:nil];
    }
    }
    else
    {
    //断开连接
    [self closeWithError:nil];
    }
    }
    //如果有安全socket。
    else if (flags & kSocketSecure)
    {
    [self flushSSLBuffers];

    //如果可读字节数为0
    if ([preBuffer availableBytes] == 0)
    {
    //
    if ([self usingCFStreamForTLS]) {
    // Callbacks never disabled
    }
    else {
    //重新恢复读的source。因为每次开始读数据的时候,都会挂起读的source
    [self resumeReadSource];
    }
    }
    }
    }
    }

    详细的细节看注释即可,这里我们讲讲主要的作用:

    1. 我们首先做了一些是否连接,读队列任务是否大于0等等一些判断。当然,如果判断失败,那么就不在读取,直接返回。
    • 接着我们从全局的readQueue中,拿到第一条任务,去做读取,我们来判断这个任务的类型,如果是GCDAsyncSpecialPacket类型的,我们将开启TLS认证。(后面再来详细讲)

    如果是是我们之前加入队列中的GCDAsyncReadPacket类型,我们则开始读取操作,调用doReadData,这个方法将是整个Read篇的核心方法。

    • 如果队列中没有任务,我们先去判断,是否是上一次是读取了数据,但是没有数据的标记,如果是的话我们则断开socket连接(注:还记得么,我们之前应用篇有说过,调取读取任务时给一个超时,如果超过这个时间,还没读取到任务,则会断开连接,就是在这触发的)。
    • 如果我们是安全的连接(基于TLS的Socket),我们就去调用flushSSLBuffers,把数据从SSL通道中,移到我们的全局缓冲区preBuffer中。

    讲到这,大家可能觉得有些迷糊,为了能帮助大家理解,这里我准备了一张流程图,来讲讲整个框架读取数据的流程:




    1. 这张图就是整个数据的流向了,这里我们读取数据分为两种情况,一种是基于TLS,一种是普通的数据读取。
    • 而基于TLS的数据读取,又分为两种,一种是基于CFStream,另一种则是安全通道SecureTransport形式。
    • 这两种类型的TLS都会在各自的通道内,完成数据的解密,然后解密后的数据又流向了全局缓冲区prebuffer
    • 这个全局缓冲区prebuffer就像一个蓄水池,如果我们一直不去做读取任务的话,它里面的数据会越来越多,当我们读取其中所有数据,它就会回归最初的状态。
    • 我们用currentRead的方式,从prebuffer中读取数据,当读到我们想要的位置时,就会回调代理,用户得到数据。





    收起阅读 »

    Gradle 爬坑指南 -- 导论

    Gradle 内容真是超乎寻常的多,在写本文之前我以为有个万把字就差不多了,但随着越看越多,我发现想写的话一本书都是可以写出来的 〒▽〒 因为内容多,我只能拆成多篇文章了,希望能写全吧 我写文章都是喜欢以小白为出发点的,希望对那些一点都不了解 Gralde 的...
    继续阅读 »

    Gradle 内容真是超乎寻常的多,在写本文之前我以为有个万把字就差不多了,但随着越看越多,我发现想写的话一本书都是可以写出来的 〒▽〒


    因为内容多,我只能拆成多篇文章了,希望能写全吧


    我写文章都是喜欢以小白为出发点的,希望对那些一点都不了解 Gralde 的朋友能所有帮助,也希望能大大缩短大家学习 Gralde 的时间成本。Gradle 这东西对于一般人真的是难,非常难理解。相关的技术文章都是18年后才开始涌现出来的,之前的文章(尤其是15年那会AS出现时的文章)真的是非常非常少,可见难度之大。我想也只有之前精通后端,熟悉 Ant,Maven 构建工具,转到 Android 的那些高手们才能一上来就玩转 Gradle 吧 (੭ˊᵕˋ)੭*ଘ


    前言


    长征一直是被世人当做奇迹来看待的,实在是不能想象要拥有如何的毅力才能跨越这些人类禁区,我想也只有:负重前行、披荆斩棘、为了唯一的光明、相互鼓励、相互扶持 的先人们才能做的到吧 ┗|`O′|┛ 嗷~~


    可能是因为本猿感情丰富一些、平日爱做白日梦的关系吧,一直一来我都把软件开发这条路当做新时代版的万里长征:未来很光明、路途很遥远、当下很艰难。谁能说编程学习路线上那每一座难关不是雪山、不是草地?迈过去的难度非常大,太多的人徘徊在关口呻吟、亦或是被碰的头破血流,但是只要迈过去了,后面的风景何其精彩。即便前路还有万千苦难,但我已成战士,百折不挠是我的血,握紧手里的剑,我能扶摇天际 (☆-v-)


    曾几何时,大家还记得俯于案头而苦恼、百思不得其解而难过、怀疑自己能力而悲愤?Gradle 反反复复看了好几次,次次个把星期,但就是不得其解、不得其意、不得已精髓。而别人一个星期,Gradle 各种花样,各种黑科技,难倒大家不羡慕?为啥别人行我不行,为啥对比差距这么大?究其原因就是:低效学习! 基础薄弱怎能助自己攀登高峰,怀着没吃过猪肉还没见过猪跑的心情,怎能烹饪一手好红烧肉呢。全聚德的烤鸭之所以闻名全国,那是因为不仅了解、掌握食材的整个成长过程,更是自己优中选优选择最好的鸭苗,掌握鸭苗全程的成长,才能有的放矢,才能做出最好的烤鸭子


    这里安利下自己的文章:Android 筑基导论,希望能让大家能静下心来想一想,梳理一下自己的过往,明白万丈高楼平地起,什么才是助自己走向高峰的基石 (o≖◡≖)



    感谢前辈们孜孜不倦的输出,我才能把 Gradle 斩于马下,非常感谢 (๑•̀ㅂ•́)و✧


    写的有点啰嗦,希望啰嗦一点把 gradle 讲清楚,掌握合适的学习脉络,其实 Gradle 不难入手的


    本文主要是从 AS 角度来讲解 Gradle ✧(≖ ◡ ≖✿)



    单词


    学习 Gradle 第一个拦路虎就是有点多、不认识的单词了,总是看见不认识的单词、不理解是什么意思,其实挺劝退的,至少我的感受是这样。下面这些单词大家熟悉下:



    • Script --> 脚本(build.gradle)

    • Plugin --> 插件(apply plugin: 'com.android.application')

    • generic --> 通用、一般

    • task --> 任务(cleanBuildCache)

    • graph --> 图表、曲线图

    • assemble --> 集合、收集、打包

    • compile --> 编译

    • Evaluate --> 评估、构建

    • resolve --> 决定

    • Execution --> 执行

    • Closure --> 闭包、终止(android{...})

    • confuse --> 混乱

    • Script Block --> 脚本块

    • delegate --> 委托

    • transform --> 变换

    • channel --> 渠道

    • flavor --> 味道、差异、这里代指渠道

    • dimension --> 维度

    • variant --> 变体

    • annotationprocessor --> 注解处理器

    • ProGuard --> 混淆

    • console --> 控制台

    • company --> 仓库


    参考资料



    maven 中心仓库 大家百度打开这个连接,进去直接搜索我们想看的第三当框架,能看到所有版本的信息,点开可以看到 maven 地址





    我的学习文章


    成体系的东西,尤其是你妹接触过的,我认为在学习阶段必须要写文章,要记录下来,即便网上这类文章有太多太多,你也必须写自己的文章。写完了你才能把自己学到的梳理清楚,动键盘开始码字你才知道有多少自己其实并没有学明白,记下来你才能记得清楚,忘了好回头看


    第一阶段


    该阶段目的在于了解 Gradle 构建工具、Groovy 语法,了解其中概念,知道什么是 Task、Plugin,了解 Gradle 内置对象、生命周期 Hook 函数,构建过程。这些都是 Gradle 比较粗粒度的知识体系,只有了解这些你才能上手、入门 Gradle



    第二阶段


    该阶段在于深入学习 Gradle 的细节,及其基于自定义插件实现各种功能,学到这里基本就能出师了,Gradle 以后就不是问题了,再看见有新的内容也能看得懂、学的会了,就能跟上业界 Gradle 主流水平了



    Gradle 学习指南


    1. Gradle 基本学习路线



    1. 先了解什么是 Gradle、Groovy

    2. 熟悉 Groovy 语法

    3. 熟悉什么是 plugin 插件、task 任务

    4. 熟悉 Gradle 核心对象:Gradle、Setting、Project,Gradle 构建流程、生命周期、及其 hook 勾子函数

    5. 熟悉 Android 项目构建,application 这个插件

    6. 了解自定义 task,自定义 plugin 并上传 maven 以及使用

    7. 各种 自定义 plugin 花样


    这是基本的学习思路,结合我提供的学习资料,我想至少可以给大家减少很多寻找资料、理清脉络、反复折腾的时间,下面贴一下具体学习指南


    2. Gradle 学习资料食用指南


    Gradle 很复杂、学习难度很大的,一遍基本是不够的,请大家耐心反复看几遍 <( ̄ˇ ̄)/



    1. 首先还是希望大家能先阅读下本文,先对 Gradle 有些基本理解再看后面,尤其是一点 Gradle 基础都没有的同学,我是真的建议大家先把我这篇文章看完,我写文章从来都是从小白出发

    2. 来自Gradle开发团队的Gradle入门教程 --> 优先推荐大家看看这个,来自 Gradle 管方团队的推广真不是盖的,概念解释清晰,言简意赅、逻辑条理 Nice,会帮你理解 Gradle 的全貌,虽然具体内容不是很多。但是带着官方的理解再去看其他资料能减少很多概念理解上的歧义,能帮助提升大家的学习效率

    3. Gradle系统详解 --> 这个4个来小时,尝试把 Gradle 都讲一遍,但是效果不怎么好,推荐大家过一遍,增加了解

    4. gradle快速入门 | 自定义编写Gradle插件 --> 这2篇都是讲 plugin 插件、task 的,大家看完之后对这2块会有比较深的了解

    5. 用Artifactory和Retryofit助力Gradle提高开发效率 --> 这个是讲解 Gradle 自动化打包、上传、发布的,适合有需求的朋友看

    6. 掘金小册-Mastering Gradle --> 这本掘金小册真的是新人杀手、劝退指南。文章虽然内容混乱,质量还是不错的,推荐大家对 Gradle 有系统了解之后再来看

    7. Gradle--官方文档 --> 管方文档最后还是推荐大家看看的,结合 Google 浏览器的自动翻译插件,还是能看的,完全没问题

    8. 剩下的就是 Gradle 插件的应用了原理了,这块推荐大家看看掘金 jsonchao 同学的系列文章,既全面,又有深度


    这一套下来,基本上 Gradle 对于大家来说就没什么大问题了,剩下的就是在项目中实际应用了。再有掘金上有黑科技文章出来大家也不会看不懂,不会用了。总体耗时会长一些,但是真的可以一次学习,终身受益,不用反复折腾了


    Android 插件打包流程图




    作者:前行的乌龟
    链接:https://juejin.cn/post/6882178101191639053
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    后台返回数据错误json解析总报错怎么办!这个框架解决你的烦恼!

    集成步骤在项目根目录下的 build.gradle 文件中加入buildscript { ...... } allprojects { repositories { // JitPack 远程仓库:https...
    继续阅读 »

    集成步骤

    • 在项目根目录下的 build.gradle 文件中加入
    buildscript {
    ......
    }
    allprojects {
    repositories {
    // JitPack 远程仓库:https://jitpack.io
    maven { url 'https://jitpack.io' }
    }
    }
    • 在项目 app 模块下的 build.gradle 文件中加入
    android {
    // 支持 JDK 1.8
    compileOptions {
    targetCompatibility JavaVersion.VERSION_1_8
    sourceCompatibility JavaVersion.VERSION_1_8
    }
    }

    dependencies {
    // Gson 解析容错:https://github.com/getActivity/GsonFactory
    implementation 'com.github.getActivity:GsonFactory:5.2'
    // Json 解析框架:https://github.com/google/gson
    implementation 'com.google.code.gson:gson:2.8.5'
    }

    使用文档

    • 请使用框架返回的 Gson 对象来代替项目中的 Gson 对象
    // 获取单例的 Gson 对象(已处理容错)
    Gson gson = GsonFactory.getSingletonGson();
    • 因为框架中的 Gson 对象已经对解析规则进行了容错处理

    其他 API

    // 设置自定义的 Gson 对象
    GsonFactory.setSingletonGson(Gson gson);

    // 创建一个 Gson 构建器(已处理容错)
    GsonBuilder gsonBuilder = GsonFactory.newGsonBuilder();

    // 注册类型适配器
    GsonFactory.registerTypeAdapterFactory(TypeAdapterFactory factory);

    // 注册构造函数创建器
    GsonFactory.registerInstanceCreator(Type type, InstanceCreator<?> creator);

    // 设置 Json 解析容错监听
    GsonFactory.setJsonCallback(new JsonCallback() {

    @Override
    public void onTypeException(TypeToken<?> typeToken, String fieldName, JsonToken jsonToken) {
    // Log.e("GsonFactory", "类型解析异常:" + typeToken + "#" + fieldName + ",后台返回的类型为:" + jsonToken);
    // 上报到 Bugly 错误列表
    CrashReport.postCatchedException(new IllegalArgumentException("类型解析异常:" + typeToken + "#" + fieldName + ",后台返回的类型为:" + jsonToken));
    }
    });

    容错介绍

    • 目前支持容错的数据类型有:

      • Bean 类

      • 数组集合

      • String(字符串)

      • boolean / Boolean(布尔值)

      • int / Integer(整数,属于数值类)

      • long /Long(长整数,属于数值类)

      • float / Float(单精度浮点数,属于数值类)

      • double / Double(双精度浮点数,属于数值类)

      • BigDecimal(精度更高的浮点数,属于数值类)

    • 基本涵盖 99.99% 的开发场景,可以运行 Demo 中的单元测试用例来查看效果:

    数据类型容错的范围数据示例
    bean集合、字符串、布尔值、数值[]""false0
    集合bean、字符串、布尔值、数值{}""false0
    字符串bean、集合、布尔值、数值{}[]false0
    布尔值bean、集合、字符串、数值{}[]""0
    数值bean、集合、字符串、布尔值{}[]""false
    • 大家可能觉得 Gson 解析容错没什么,那是因为我们对 Gson 解析失败的场景没有了解过:

      • 类型不对:后台有数据时返回 JsonObject,没数据返回 [],Gson 会直接抛出异常

      • 措手不及:如果客户端定义的是整数,但是后台返回浮点数,Gson 会直接抛出异常

      • 意想不到:如果客户端定义的是布尔值,但是后台返回的是 0 或者 1,Gson 会直接抛出异常

    • 以上情况框架已经做了容错处理,具体处理规则如下:

      • 如果后台返回的类型和客户端定义的类型不匹配,框架就不解析这个字段

      • 如果客户端定义的是整数,但后台返回浮点数,框架就对数值进行取整并赋值给字段

      • 如果客户端定义布尔值,但是后台返回整数,框架则将非 0 的数值则赋值为 true,否则为 false

    常见疑问解答

    • Retrofit + RxJava 怎么替换?
    Retrofit retrofit = new Retrofit.Builder()
    .addConverterFactory(GsonConverterFactory.create(GsonFactory.getSingletonGson()))
    .build();
    • 有没有必要处理 Json 解析容错?

    我觉得非常有必要,因为后台返回的数据结构是什么样我们把控不了,但是有一点是肯定的,我们都不希望它崩,因为一个接口的失败导致整个 App 崩溃退出实属不值得,但是 Gson 很敏感,动不动就崩。

    • 我们后台用的是 Java,有必要处理容错吗?

    如果你们的后台用的是 PHP,那我十分推荐你使用这个框架,因为 PHP 返回的数据结构很乱,这块经历过的人都懂,没经历过的人怎么说都不懂。

    如果你们的后台用的是 Java,那么可以根据实际情况而定,例如我现在的公司用的就是 Java 后台,但是 Bugly 有上报一个关于 Gson 解析的 Crash,所以后台的话只能信一半。


    代码下载:GsonFactory-master.zip

    收起阅读 »

    Android下拉刷新完全解析,教你如何一分钟实现下拉刷新功能

    最近项目中需要用到 ListView 下拉刷新的功能,一开始想图省事,在网上直接找一个现成的,可是尝试了网上多个版本的下拉刷新之后发现效果都不怎么理想。有些是因为功能不完整或有 Bug,有些是因为使用起来太复杂,十全十美的还真没找到。因此我也是放弃了在网上找现...
    继续阅读 »

    Android下拉刷新完全解析,教你如何一分钟实现下拉刷新功能

    最近项目中需要用到 ListView 下拉刷新的功能,一开始想图省事,在网上直接找一个现成的,可是尝试了网上多个版本的下拉刷新之后发现效果都不怎么理想。有些是因为功能不完整或有 Bug,有些是因为使用起来太复杂,十全十美的还真没找到。因此我也是放弃了在网上找现成代码的想法,自己花功夫编写了一种非常简单的下拉刷新实现方案,现在拿出来和大家分享一下。相信在阅读完本篇文章之后,大家都可以在自己的项目中一分钟引入下拉刷新功能。

    首先讲一下实现原理。这里我们将采取的方案是使用组合 View 的方式,先自定义一个布局继承自 LinearLayout,然后在这个布局中加入下拉头和 ListView 这两个子元素,并让这两个子元素纵向排列。初始化的时候,让下拉头向上偏移出屏幕,这样我们看到的就只有 ListView 了。然后对 ListView 的 touch 事件进行监听,如果当前 ListView 已经滚动到顶部并且手指还在向下拉的话,那就将下拉头显示出来,松手后进行刷新操作,并将下拉头隐藏。原理示意图如下:

    那我们现在就来动手实现一下,新建一个项目起名叫 PullToRefreshTest,先在项目中定义一个下拉头的布局文件 pull_to_refresh.xml,代码如下所

    在这个布局中,我们包含了一个下拉指示箭头,一个下拉状态文字提示,和一个上次更新的时间。当然,还有一个隐藏的旋转进度条,只有正在刷新的时候我们才会将它显示出来。

    布局中所有引用的字符串我们都放在 strings.xml 中,如下所示

    PullToRefreshTest
    下拉可以刷新
    释放立即刷新
    正在刷新…
    暂未更新过
    上次更新于%1$s前
    刚刚更新
    时间有问题


    然后新建一个 RefreshableView 继承自 LinearLayout,代码如下所示:

    public class RefreshableView extends LinearLayout implements OnTouchListener {

    /**
    * 下拉状态
    */
    public static final int STATUS_PULL_TO_REFRESH = 0;

    /**
    * 释放立即刷新状态
    */
    public static final int STATUS_RELEASE_TO_REFRESH = 1;

    /**
    * 正在刷新状态
    */
    public static final int STATUS_REFRESHING = 2;

    /**
    * 刷新完成或未刷新状态
    */
    public static final int STATUS_REFRESH_FINISHED = 3;

    /**
    * 下拉头部回滚的速度
    */
    public static final int SCROLL_SPEED = -20;

    /**
    * 一分钟的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_MINUTE = 60 * 1000;

    /**
    * 一小时的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_HOUR = 60 * ONE_MINUTE;

    /**
    * 一天的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_DAY = 24 * ONE_HOUR;

    /**
    * 一月的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_MONTH = 30 * ONE_DAY;

    /**
    * 一年的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_YEAR = 12 * ONE_MONTH;

    /**
    * 上次更新时间的字符串常量,用于作为SharedPreferences的键值
    */
    private static final String UPDATED_AT = "updated_at";

    /**
    * 下拉刷新的回调接口
    */
    private PullToRefreshListener mListener;

    /**
    * 用于存储上次更新时间
    */
    private SharedPreferences preferences;

    /**
    * 下拉头的View
    */
    private View header;

    /**
    * 需要去下拉刷新的ListView
    */
    private ListView listView;

    /**
    * 刷新时显示的进度条
    */
    private ProgressBar progressBar;

    /**
    * 指示下拉和释放的箭头
    */
    private ImageView arrow;

    /**
    * 指示下拉和释放的文字描述
    */
    private TextView description;

    /**
    * 上次更新时间的文字描述
    */
    private TextView updateAt;

    /**
    * 下拉头的布局参数
    */
    private MarginLayoutParams headerLayoutParams;

    /**
    * 上次更新时间的毫秒值
    */
    private long lastUpdateTime;

    /**
    * 为了防止不同界面的下拉刷新在上次更新时间上互相有冲突,使用id来做区分
    */
    private int mId = -1;

    /**
    * 下拉头的高度
    */
    private int hideHeaderHeight;

    /**
    * 当前处理什么状态,可选值有STATUS_PULL_TO_REFRESH, STATUS_RELEASE_TO_REFRESH,
    * STATUS_REFRESHING 和 STATUS_REFRESH_FINISHED
    */
    private int currentStatus = STATUS_REFRESH_FINISHED;;

    /**
    * 记录上一次的状态是什么,避免进行重复操作
    */
    private int lastStatus = currentStatus;

    /**
    * 手指按下时的屏幕纵坐标
    */
    private float yDown;

    /**
    * 在被判定为滚动之前用户手指可以移动的最大值。
    */
    private int touchSlop;

    /**
    * 是否已加载过一次layout,这里onLayout中的初始化只需加载一次
    */
    private boolean loadOnce;

    /**
    * 当前是否可以下拉,只有ListView滚动到头的时候才允许下拉
    */
    private boolean ableToPull;

    /**
    * 下拉刷新控件的构造函数,会在运行时动态添加一个下拉头的布局。
    *
    * @param context
    * @param attrs
    */
    public RefreshableView(Context context, AttributeSet attrs) {
    super(context, attrs);
    preferences = PreferenceManager.getDefaultSharedPreferences(context);
    header = LayoutInflater.from(context).inflate(R.layout.pull_to_refresh, null, true);
    progressBar = (ProgressBar) header.findViewById(R.id.progress_bar);
    arrow = (ImageView) header.findViewById(R.id.arrow);
    description = (TextView) header.findViewById(R.id.description);
    updateAt = (TextView) header.findViewById(R.id.updated_at);
    touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
    refreshUpdatedAtValue();
    setOrientation(VERTICAL);
    addView(header, 0);
    }

    /**
    * 进行一些关键性的初始化操作,比如:将下拉头向上偏移进行隐藏,给ListView注册touch事件。
    */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
    super.onLayout(changed, l, t, r, b);
    if (changed && !loadOnce) {
    hideHeaderHeight = -header.getHeight();
    headerLayoutParams = (MarginLayoutParams) header.getLayoutParams();
    headerLayoutParams.topMargin = hideHeaderHeight;
    listView = (ListView) getChildAt(1);
    listView.setOnTouchListener(this);
    loadOnce = true;
    }
    }

    /**
    * 当ListView被触摸时调用,其中处理了各种下拉刷新的具体逻辑。
    */
    @Override
    public boolean onTouch(View v, MotionEvent event) {
    setIsAbleToPull(event);
    if (ableToPull) {
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
    yDown = event.getRawY();
    break;
    case MotionEvent.ACTION_MOVE:
    float yMove = event.getRawY();
    int distance = (int) (yMove - yDown);
    // 如果手指是下滑状态,并且下拉头是完全隐藏的,就屏蔽下拉事件
    if (distance <= 0 && headerLayoutParams.topMargin <= hideHeaderHeight) {
    return false;
    }
    if (distance < touchSlop) {
    return false;
    }
    if (currentStatus != STATUS_REFRESHING) {
    if (headerLayoutParams.topMargin > 0) {
    currentStatus = STATUS_RELEASE_TO_REFRESH;
    } else {
    currentStatus = STATUS_PULL_TO_REFRESH;
    }
    // 通过偏移下拉头的topMargin值,来实现下拉效果
    headerLayoutParams.topMargin = (distance / 2) + hideHeaderHeight;
    header.setLayoutParams(headerLayoutParams);
    }
    break;
    case MotionEvent.ACTION_UP:
    default:
    if (currentStatus == STATUS_RELEASE_TO_REFRESH) {
    // 松手时如果是释放立即刷新状态,就去调用正在刷新的任务
    new RefreshingTask().execute();
    } else if (currentStatus == STATUS_PULL_TO_REFRESH) {
    // 松手时如果是下拉状态,就去调用隐藏下拉头的任务
    new HideHeaderTask().execute();
    }
    break;
    }
    // 时刻记得更新下拉头中的信息
    if (currentStatus == STATUS_PULL_TO_REFRESH
    || currentStatus == STATUS_RELEASE_TO_REFRESH) {
    updateHeaderView();
    // 当前正处于下拉或释放状态,要让ListView失去焦点,否则被点击的那一项会一直处于选中状态
    listView.setPressed(false);
    listView.setFocusable(false);
    listView.setFocusableInTouchMode(false);
    lastStatus = currentStatus;
    // 当前正处于下拉或释放状态,通过返回true屏蔽掉ListView的滚动事件
    return true;
    }
    }
    return false;
    }

    /**
    * 给下拉刷新控件注册一个监听器。
    *
    * @param listener
    * 监听器的实现。
    * @param id
    * 为了防止不同界面的下拉刷新在上次更新时间上互相有冲突, 请不同界面在注册下拉刷新监听器时一定要传入不同的id。
    */
    public void setOnRefreshListener(PullToRefreshListener listener, int id) {
    mListener = listener;
    mId = id;
    }

    /**
    * 当所有的刷新逻辑完成后,记录调用一下,否则你的ListView将一直处于正在刷新状态。
    */
    public void finishRefreshing() {
    currentStatus = STATUS_REFRESH_FINISHED;
    preferences.edit().putLong(UPDATED_AT + mId, System.currentTimeMillis()).commit();
    new HideHeaderTask().execute();
    }

    /**
    * 根据当前ListView的滚动状态来设定 {@link #ableToPull}
    * 的值,每次都需要在onTouch中第一个执行,这样可以判断出当前应该是滚动ListView,还是应该进行下拉。
    *
    * @param event
    */
    private void setIsAbleToPull(MotionEvent event) {
    View firstChild = listView.getChildAt(0);
    if (firstChild != null) {
    int firstVisiblePos = listView.getFirstVisiblePosition();
    if (firstVisiblePos == 0 && firstChild.getTop() == 0) {
    if (!ableToPull) {
    yDown = event.getRawY();
    }
    // 如果首个元素的上边缘,距离父布局值为0,就说明ListView滚动到了最顶部,此时应该允许下拉刷新
    ableToPull = true;
    } else {
    if (headerLayoutParams.topMargin != hideHeaderHeight) {
    headerLayoutParams.topMargin = hideHeaderHeight;
    header.setLayoutParams(headerLayoutParams);
    }
    ableToPull = false;
    }
    } else {
    // 如果ListView中没有元素,也应该允许下拉刷新
    ableToPull = true;
    }
    }

    /**
    * 更新下拉头中的信息。
    */
    private void updateHeaderView() {
    if (lastStatus != currentStatus) {
    if (currentStatus == STATUS_PULL_TO_REFRESH) {
    description.setText(getResources().getString(R.string.pull_to_refresh));
    arrow.setVisibility(View.VISIBLE);
    progressBar.setVisibility(View.GONE);
    rotateArrow();
    } else if (currentStatus == STATUS_RELEASE_TO_REFRESH) {
    description.setText(getResources().getString(R.string.release_to_refresh));
    arrow.setVisibility(View.VISIBLE);
    progressBar.setVisibility(View.GONE);
    rotateArrow();
    } else if (currentStatus == STATUS_REFRESHING) {
    description.setText(getResources().getString(R.string.refreshing));
    progressBar.setVisibility(View.VISIBLE);
    arrow.clearAnimation();
    arrow.setVisibility(View.GONE);
    }
    refreshUpdatedAtValue();
    }
    }

    /**
    * 根据当前的状态来旋转箭头。
    */
    private void rotateArrow() {
    float pivotX = arrow.getWidth() / 2f;
    float pivotY = arrow.getHeight() / 2f;
    float fromDegrees = 0f;
    float toDegrees = 0f;
    if (currentStatus == STATUS_PULL_TO_REFRESH) {
    fromDegrees = 180f;
    toDegrees = 360f;
    } else if (currentStatus == STATUS_RELEASE_TO_REFRESH) {
    fromDegrees = 0f;
    toDegrees = 180f;
    }
    RotateAnimation animation = new RotateAnimation(fromDegrees, toDegrees, pivotX, pivotY);
    animation.setDuration(100);
    animation.setFillAfter(true);
    arrow.startAnimation(animation);
    }

    /**
    * 刷新下拉头中上次更新时间的文字描述。
    */
    private void refreshUpdatedAtValue() {
    lastUpdateTime = preferences.getLong(UPDATED_AT + mId, -1);
    long currentTime = System.currentTimeMillis();
    long timePassed = currentTime - lastUpdateTime;
    long timeIntoFormat;
    String updateAtValue;
    if (lastUpdateTime == -1) {
    updateAtValue = getResources().getString(R.string.not_updated_yet);
    } else if (timePassed < 0) {
    updateAtValue = getResources().getString(R.string.time_error);
    } else if (timePassed < ONE_MINUTE) {
    updateAtValue = getResources().getString(R.string.updated_just_now);
    } else if (timePassed < ONE_HOUR) {
    timeIntoFormat = timePassed / ONE_MINUTE;
    String value = timeIntoFormat + "分钟";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    } else if (timePassed < ONE_DAY) {
    timeIntoFormat = timePassed / ONE_HOUR;
    String value = timeIntoFormat + "小时";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    } else if (timePassed < ONE_MONTH) {
    timeIntoFormat = timePassed / ONE_DAY;
    String value = timeIntoFormat + "天";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    } else if (timePassed < ONE_YEAR) {
    timeIntoFormat = timePassed / ONE_MONTH;
    String value = timeIntoFormat + "个月";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    } else {
    timeIntoFormat = timePassed / ONE_YEAR;
    String value = timeIntoFormat + "年";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    }
    updateAt.setText(updateAtValue);
    }

    /**
    * 正在刷新的任务,在此任务中会去回调注册进来的下拉刷新监听器。
    *
    * @author guolin
    */
    class RefreshingTask extends AsyncTask {

    @Override
    protected Void doInBackground(Void... params) {
    int topMargin = headerLayoutParams.topMargin;
    while (true) {
    topMargin = topMargin + SCROLL_SPEED;
    if (topMargin <= 0) {
    topMargin = 0;
    break;
    }
    publishProgress(topMargin);
    sleep(10);
    }
    currentStatus = STATUS_REFRESHING;
    publishProgress(0);
    if (mListener != null) {
    mListener.onRefresh();
    }
    return null;
    }

    @Override
    protected void onProgressUpdate(Integer... topMargin) {
    updateHeaderView();
    headerLayoutParams.topMargin = topMargin[0];
    header.setLayoutParams(headerLayoutParams);
    }

    }

    /**
    * 隐藏下拉头的任务,当未进行下拉刷新或下拉刷新完成后,此任务将会使下拉头重新隐藏。
    *
    * @author guolin
    */
    class HideHeaderTask extends AsyncTask {

    @Override
    protected Integer doInBackground(Void... params) {
    int topMargin = headerLayoutParams.topMargin;
    while (true) {
    topMargin = topMargin + SCROLL_SPEED;
    if (topMargin <= hideHeaderHeight) {
    topMargin = hideHeaderHeight;
    break;
    }
    publishProgress(topMargin);
    sleep(10);
    }
    return topMargin;
    }

    @Override
    protected void onProgressUpdate(Integer... topMargin) {
    headerLayoutParams.topMargin = topMargin[0];
    header.setLayoutParams(headerLayoutParams);
    }

    @Override
    protected void onPostExecute(Integer topMargin) {
    headerLayoutParams.topMargin = topMargin;
    header.setLayoutParams(headerLayoutParams);
    currentStatus = STATUS_REFRESH_FINISHED;
    }
    }

    /**
    * 使当前线程睡眠指定的毫秒数。
    *
    * @param time
    * 指定当前线程睡眠多久,以毫秒为单位
    */
    private void sleep(int time) {
    try {
    Thread.sleep(time);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }

    /**
    * 下拉刷新的监听器,使用下拉刷新的地方应该注册此监听器来获取刷新回调。
    *
    * @author guolin
    */
    public interface PullToRefreshListener {

    /**
    * 刷新时会去回调此方法,在方法内编写具体的刷新逻辑。注意此方法是在子线程中调用的, 你可以不必另开线程来进行耗时操作。
    */
    void onRefresh();

    }

    }


    这个类是整个下拉刷新功能中最重要的一个类,注释已经写得比较详细了,我再简单解释一下。首先在 RefreshableView 的构造函数中动态添加了刚刚定义的 pull_to_refresh 这个布局作为下拉头,然后在 onLayout 方法中将下拉头向上偏移出了屏幕,再给 ListView 注册了 touch 事件。之后每当手指在 ListView 上滑动时,onTouch 方法就会执行。在 onTouch 方法中的第一行就调用了 setIsAbleToPull 方法来判断 ListView 是否滚动到了最顶部,只有滚动到了最顶部才会执行后面的代码,否则就视为正常的 ListView 滚动,不做任何处理。当 ListView 滚动到了最顶部时,如果手指还在向下拖动,就会改变下拉头的偏移值,让下拉头显示出来,下拉的距离设定为手指移动距离的 1/2,这样才会有拉力的感觉。如果下拉的距离足够大,在松手的时候就会执行刷新操作,如果距离不够大,就仅仅重新隐藏下拉头。

    具体的刷新操作会在 RefreshingTask 中进行,其中在 doInBackground 方法中回调了 PullToRefreshListener 接口的 onRefresh 方法,这也是大家在使用 RefreshableView 时必须要去实现的一个接口,因为具体刷新的逻辑就应该写在 onRefresh 方法中,后面会演示使用的方法。

    另外每次在下拉的时候都还会调用 updateHeaderView 方法来改变下拉头中的数据,比如箭头方向的旋转,下拉文字描述的改变等。更加深入的理解请大家仔细去阅读 RefreshableView 中的代码。

    现在我们已经把下拉刷新的所有功能都完成了,接下来就要看一看如何在项目中引入下拉刷新了。打开或新建 activity_main.xml 作为程序主界面的布局,加入如下代码:

    可以看到,我们在自定义的 RefreshableView 中加入了一个 ListView,这就意味着给这个 ListView 加入了下拉刷新的功能,就是这么简单!
    然后我们再来看一下程序的主 Activity,打开或新建 MainActivity,加入如下代码:

    public class MainActivity extends Activity {

    RefreshableView refreshableView;
    ListView listView;
    ArrayAdapter adapter;
    String[] items = { "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L" };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    setContentView(R.layout.activity_main);
    refreshableView = (RefreshableView) findViewById(R.id.refreshable_view);
    listView = (ListView) findViewById(R.id.list_view);
    adapter = new ArrayAdapter(this, android.R.layout.simple_list_item_1, items);
    listView.setAdapter(adapter);
    refreshableView.setOnRefreshListener(new PullToRefreshListener() {
    @Override
    public void onRefresh() {
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    refreshableView.finishRefreshing();
    }
    }, 0);
    }

    }


    可以看到,我们通过调用 RefreshableView 的 setOnRefreshListener 方法注册了一个监听器,当 ListView 正在刷新时就会回调监听器的 onRefresh 方法,刷新的具体逻辑就在这里处理。而且这个方法已经自动开启了线程,可以直接在 onRefresh 方法中进行耗时操作,比如向服务器请求最新数据等,在这里我就简单让线程睡眠 3 秒钟。另外在 onRefresh 方法的最后,一定要调用 RefreshableView 中的 finishRefreshing 方法,这个方法是用来通知 RefreshableView 刷新结束了,不然我们的 ListView 将一直处于正在刷新的状态。

    不知道大家有没有注意到,setOnRefreshListener 这个方法其实是有两个参数的,我们刚刚也是传入了一个不起眼的 0。那这第二个参数是用来做什么的呢?由于 RefreshableView 比较智能,它会自动帮我们记录上次刷新完成的时间,然后下拉的时候会在下拉头中显示距上次刷新已过了多久。这是一个非常好用的功能,让我们不用再自己手动去记录和计算时间了,但是却存在一个问题。如果当前我们的项目中有三个地方都使用到了下拉刷新的功能,现在在一处进行了刷新,其它两处的时间也都会跟着改变!因为刷新完成的时间是记录在配置文件中的,由于在一处刷新更改了配置文件,导致在其它两处读取到的配置文件时间已经是更改过的了。那解决方案是什么?就是每个用到下拉刷新的地方,给 setOnRefreshListener 方法的第二个参数中传入不同的 id 就行了。这样各处的上次刷新完成时间都是单独记录的,相互之间就不会再有影响。

    好了,全部的代码都在这里了,让我们来运行一下,看看效果吧。

    效果看起来还是非常不错的。我们最后再来总结一下,在项目中引入 ListView 下拉刷新功能只需三步:

    1. 在 Activity 的布局文件中加入自定义的 RefreshableView,并让 ListView 包含在其中。

    2. 在 Activity 中调用 RefreshableView 的 setOnRefreshListener 方法注册回调接口。

    3. 在 onRefresh 方法的最后,记得调用 RefreshableView 的 finishRefreshing 方法,通知刷新结束。

    从此以后,在项目的任何地方,一分钟引入下拉刷新功能妥妥的。

    好了,今天的讲解到此结束,有疑问的朋友请在下面留言。

    收起阅读 »

    Android JPEG 压缩那些事

    JPEG 基础知识 JPEG(Joint Photographic Experts Group,联合图像专家小组)是一种针对照片影像广泛使用的有损压缩标准方法。 使用 JPEG 格式压缩的图片文件,最普遍使用的扩展名格式为 .jpg,其他常用的扩展名还包括 ....
    继续阅读 »

    JPEG 基础知识


    JPEG(Joint Photographic Experts Group,联合图像专家小组)是一种针对照片影像广泛使用的有损压缩标准方法。


    使用 JPEG 格式压缩的图片文件,最普遍使用的扩展名格式为 .jpg,其他常用的扩展名还包括 .JPEG、.jpe、.jfif 以及 .jif。


    JEPG 编码原理


    虽然 JEPG 文件可以以各种方式进行编码,但最常见的是使用 JFIF 编码,编码过程包括以下几个步骤:




    • 色彩空间转换


      将图像从 RGB 转换为 Y'CbCr 的不同颜色空间。Y' 分量表示像素的亮度,而 CbCr 代表"色差值"(分为蓝色和红色分量)。Y'CbCr 颜色空间允许更大的压缩,而不会对感知图像质量产生重大影响。



      关于"色差"


      "色差"这个概念起源于电视行业,最早的电视都是黑白的,那时候传输电视信号只需要传输亮度信号,也就是Y信号即可,彩色电视出现之后,人们在Y信号之外增加了两条色差信号以传输颜色信息,这么做的目的是为了兼容黑白电视机,因为黑白电视只需要处理信号中的Y信号即可。


      根据三基色原理,人们发现红绿蓝三种颜色所贡献的亮度是不同的,绿色的“亮度”最大,蓝色最暗,设红色所贡献的亮度的份额为KR,蓝色贡献的份额为KB,那么亮度为


      1


      根据经验,KR=0.299,KB=0.114,那么


      2


      蓝色和红色的色差的定义如下


      3


      最终可以得到RGB转换为YCbCr的数学公式为


      4





    • 下采样


      与图像的颜色(Cb 和 Cr 分量)相比,人类眼睛对图像的亮度(Y' 分量)在图像精细度上更敏感。



      对于人眼来说,图像中明暗的变化更容易被感知到,这是由于人眼的构造引起的。视网膜上有两种感光细胞,能够感知亮度变化的视杆细胞,以及能够感知颜色的视锥细胞,由于视杆细胞在数量上远大于视锥细胞,所以我们更容易感知到明暗细节。比如说下面这张图


      原图


      只保留 Y' 分量


      Y


      只保留 Cb 分量


      Cb


      只保留 Cr 分量


      Cr



      利用这个特性,可以对 Y'CbCr 颜色空间做进一步的下采样,即降低 CbCr 分量的空间分辨率。


      下采样率为 "4:4:4" 表示不进行下采样;


      下采样率为 "4:2:2" 表示水平方向上减少 2 倍


      下采样率为 "4:2:0" 表示水平和垂直方向上减少 2 倍(最常用)


      image-20210411164013275



      下采样率通常表示为三部分比率 j:a:b,如果存在透明度则为四部分,这描述了 j个像素宽和 2 个像素高的概念区域中亮度和色度样本的数量。



      • j 表示水平方向采样率参考(概念区域的宽度)

      • a 表示第一行的色差采样(Cr,Cb)

      • b 表示第二行和第一行的色差采样(Cr,Cb)变化





    • 块分割


      在下采样之后,每个通道必须被分割为 8x8 的像素块,最小编码单位(MCU) 取决于使用的下采样。


      如果下采样率为 "4:4:4",则最小编码单位块的大小为 8x8;


      如果下采样率为 "4:2:2",则最小编码单位块的大小为 16x8;


      如果下采样率为 "4:2:0",则最小编码单位块的大小为 16x16;


      image-20210509221734480


      如果通道数据不能被切割为整数倍的块,则通常会使用纯色去填充,例如黑色。




    • 离散余弦变换




    • 量化


      人眼善于在相对较大的区域看到较小的亮度差异,但不能很好地区分高频亮度变化的确切强度。这使得人们可以大大减少高频分量中的信息量。只需将频域中的每个分量除以该分量的常量,然后四舍五入(有损运算)为最接近的整数即可。


      这个步骤是不可逆的




    • 使用无损算法(霍夫曼编码的一种变体)进一步压缩所有 8×8 块的数据。




    JEPG 压缩效果











































    图片质量([1,100])大小(bytes)压缩比例
    JPEG example JPG RIP 100.jpg最高质量(100)814472.7:1
    JPEG example JPG RIP 050.jpg高质量(50)1467915:1
    JPEG example JPG RIP 025.jpg中等质量(25)940723:1
    JPEG example JPG RIP 010.jpg低质量(10)478746:1
    JPEG example JPG RIP 001.jpg最低质量(1)1523144:1

    JEPG 编码实现




    • libjpeg


      广泛使用的 C 库,用于读取和写入 JPEG 图像文件。




    • libjpeg-turbo


      高性能的 JEPG 图像解编码器,使用 SIMD 指令来加速在 x86、x86-64、Arm 和 PowerPC 系统上的 JEPG 文件压缩和解压缩,以及在 x86、x86-64 系统上的渐进式压缩。


      在 x86 和 x86-64 系统上,libjpeg-turbo 的速度是 libjpeg 的 2-6 倍,在其他系统上,也能大大优于 libjpeg。




    Android 图像解码


    Android 上展示一张图像,都需要将图像解码成 Bitmap 对象,Bitmap 表示图像像素的集合,像素占用的内存大小取决 Bitmap 配置,目前 Android 支持的配置有如下:




    • ALPHA_8


      只存储透明度通道




    • ARGB_4444


      每个像素使用 2 字节存储




    • ARGB_8888


      每个像素使用 4 字节存储(默认)




    • HARDWARE


      特殊配置,Bitmap 数据存储在专门的图形内存(Native)




    • RGBA_F16


      每个像素使用 8 字节存储




    • RGB_565


      每个像素使用 2 字节存储,只有 RGB 通道。




    源码解析


    通常我们可以调用 BitmapFactory.decodeStream 方法从图像流中解码,Java 层只是个简单的入口,相关实现都在 Native 层的 BitmapFactory.doDecode 方法中。


    // frameworks/base/libs/hwui/jni/BitmapFactory.cpp
    static jobject doDecode(JNIEnv* env, std::unique_ptr<SkStreamRewindable> stream,jobject padding, jobject options, jlong inBitmapHandle,jlong colorSpaceHandle) {
    // ...
    }
    复制代码

    一. 初始化相关参数




    • sampleSize


      采样率




    • onlyDecodeSize


      是否只解码尺寸




    • prefCodeType


      优先使用的颜色类型




    • isHardware


      是否存储在专门的图像内存




    • isMutable


      是否可变




    • scale


      缩放系数




    • requireUnpremultiplied


      颜色通道是否不需要"预乘"透明通道




    • javaBitmap


      可复用的 Bitmap




    二. 创建解码器


    根据解码的图像格式,创建不同的解码器 SkCodec。



























    图像格式SkCodec
    JPEGSkJpegCodec
    WebPSkWebpCodec
    GifSkGifCodec
    PNGSkPngCodec

    SkCodec 负责核心实现,SkAndroidCodec 则是 SkCodec 的包装类,用于提供一些 Android 特有的 API。同样的,SkAndroidCodec 也是根据图像格式,创建不同的 SkAndroidCodec。



















    图像格式SkAndroidCodec
    JPEG,PNG,GifSkSampledCodec
    WebPSkAndroidCodecadapter

    三. 创建内存分配器


    根据是否存在可复用的 Bitmap,和是否需要缩放,使用不同的内存分配器 Allocator。


    image-20210418224749597

    四. 分配像素内存


    调用 SkBitmap.tryAllocPixels 方法尝试分配所需的像素内存,存在以下情况,可能会导致分配失败。



    • Java Heap OOM

    • Native Heap OOM

    • 使用的可复用 Bitmap 太小


    五. 执行解码


    调用 SkAndroidCodec.getAndroidPixels 方法开始执行编码操作。


    SkCodec::Result SkAndroidCodec::getAndroidPixels(const SkImageInfo& requestInfo,
    void* requestPixels, size_t requestRowBytes, const AndroidOptions* options) {
    // ...
    return this->onGetAndroidPixels(requestInfo,requestPixels,requestRowBytes,*options);
    }
    复制代码

    SkAndroid.onGetAndroidPixels 方法有两个实现,分别是 SkSampledCodec 和 SkAndroidCodecadapter。


    这里我们以 JPEG 图像解码为例,从上文可知,它使用的是 SkSampledCodec 和 SkJpegCodec,SkJpegCodec 是核心实现。


    image-20210419142507664

    Android 除了支持使用 BitmapFactory 进行完整的解码,也支持使用 BitmapRegionDecoder 进行局部解码,这个在处理特大的图像时特别有用。


    Android JPEG 压缩


    Android 在图像压缩上一直有个令人诟病的问题,同等大小的图像文件,iOS 显示上总是更加细腻,也就是压缩效果更好,关于这个问题更详细的讨论,可以看这篇文章:github.com/bither/bith…


    总的来说,就是 Android 底层使用的自家维护的一个开源 2D 渲染引擎 Skia,Skia 在 JPEG 图像文件的解编码上依赖的是 libjpeg 库,libjpeg 压缩参数叫:optimize_coding,这个参数为 TRUE,可以带来更好的压缩效果,同时也会消耗更多的 时间。


    在 7.0 以下,Google 为了兼容性能较差的设备,而将这个值设置为 FALSE,7.0 及其以上,已经设置为 TRUE。



    关于 optimize_coding 为 FALSE,更多的讨论可以看 groups.google.com/g/skia-disc…


    7.0 以下:androidxref.com/6.0.1_r10/x…


    7.0 及其以上:androidxref.com/7.0.0_r1/xr…



    所以,现在比较主流的做法是,在 7.0 以下版本,可以基于 libjpeg-turbo 实现 JPEG 图像文件的压缩。


    源码解析


    可以通过调用 Bitmap.compress 方法来进行图像压缩,可选配置有:




    • format


      压缩图像格式,有 JPEG、PNG、WEBP。




    • quality


      压缩质量,可选值有 0-100。




    同样的,Java 层只是提供 API 入口,实现还是在 Native 层的 Bitmap.Bitmap_comperss() 方法。


    // framework/base/libs/hwui/jni/Bitmap.cpp
    static jboolean Bitmap_compress(JNIEnv* env, jobject clazz, jlong bitmapHandle,jint format, jint quality,jobject jstream, jbyteArray jstorage) {
    }
    复制代码

    一. 创建编码器


    根据图像格式创建不同的编码器。























    图像格式编码器
    JPEGSkJpegEncoder
    PNGSkPngEnccoder
    WebPSkWebpEncoder

    二. 设置编码参数


    Android JPEG 解码是依赖于 libjpeglibjpeg-turbo


    在开始压缩编码之前,会先设置一系列参数。




    • 图像尺寸




    • 颜色类型


      常用的颜色类型有:


      JCS_EXT_BGRA,           /* blue/green/red/alpha */
      JCS_EXT_BGRA, /* blue/green/red/alpha */
      复制代码



    • 下采样率


      目前 Android 支持 "4:2:0"(默认),"4:2:2" 和 "4:4:4"。




    • 最佳霍夫曼编码表


      默认为 true,表示使用最佳霍夫曼编码表,虽然会降低压缩性能,但提高了压缩效率。


      // Tells libjpeg-turbo to compute optimal Huffman coding tables
      // for the image. This improves compression at the cost of
      // slower encode performance.
      fCInfo.optimize_coding = TRUE;
      复制代码



    • 质量


      这个参数会影响 JPEG 编码中 "量化" 这个步骤




    三. 执行编码


    // external/skia/src/imagess/SkImageEncoder.cpp
    bool SkEncoder::encodeRows(int numRows) {
    // ...
    this->onEncodeRows(numRows);
    }
    复制代码

    JPEG 图像编码由 SkJpegEncoder 实现。


    // txternal/skia/src/images/SkJpegEncoder.cpp
    bool SkJpegEncoder::onEncodeRows(int numRows) {
    // ...
    for (int i = 0; i < numRows; i++) {
    // 执行 libjpeg-turbo 编码操作
    jpeg_write_scanlines(fEncoderMgr->cinfo(), &jpegSrcRow, 1);
    }
    }
    复制代码

    采样算法


    当调整图像的尺寸时,就需要对原始图像像素数据进行重新处理,这称为图像的采样处理。


    目前 Android 默认支持 Nearest neighbor(邻近采样)Bilinear(双线性采样) 这两种采样算法。




    • Nearest neighbor(邻近采样)


      重新采样的栅格中每个像素获取与原始栅格中的最近像素相同的值,这个处理时间是最快的,但也会导致图像产生锯齿。




    • Bilinear(双线性采样)


      重新采样的栅格中的每个像素都是原始栅格中 2x2 4 个最近像素的加权平均值的结果。




    除了以上两种,还有以下几种效果的更好的算法:




    • Bicubic(双立方采样)


      重新采样的栅格中的每个像素都是原始栅格中 4x4 16 个最近像素值的加权值的结果,更接近的像素会有更高的权重。




    • Lanczos


      高阶插值算法,它考虑了更多周围像素,并保留了最多的图像信息。




    • Magic Kernel


      快速又高效,却能产生惊人的清晰和锐利的结果,更详细的介绍:www.johncostella.com/magic/。




    Spectrum


    Spectrum 是 Facebook 开源的跨平台图像转码依赖库,与 Android 系统默认自带的 jpeg-turbo 相对,它有以下优势:



    • JPEG 编码基于 mozjpeg,相对于 jpeg-turbo,它提高了压缩率,但也增加了压缩处理时间。

    • 支持 Bicubic(双立方采样)和 Magic Kernel 采样算法。

    • 核心使用 CPP 实现,可以同时在 Android 和 iOS 平台实现一致的压缩效果。

    • 支持更多自定义配置,包括色度采样模式等等。


    基准测试


    基于 google/butteraugli 来比较原图像和压缩图像之间的质量差异,这个数值越小越好。


    设备信息:华为 P20 Pro,Android 10


    A 压缩质量 80




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo80S4442.9433522.5MB2255ms71%
    mozjpeg80S4442.4862662.8MB3567ms67%
    mozjpeg80S4202.493475(-15%)2.3MB2703ms73%(+2%)

    B 压缩质量 75




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo75S4443.0758842.3MB2252ms73%
    mozjpeg75S4442.6989832.4MB3188ms72%
    mozjpeg75S4202.670076(-13%)2MB2470ms77%(+4%)

    C 压缩质量 70




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo70S4442.7397942.1MB2230ms75%
    mozjpeg70S4442.8385952.2MB3089ms74%
    mozjpeg70S4202.810702(+2%)1.8MB2404ms79%(+4%)

    D 压缩质量 65




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo65S4443.7341051.9MB2227ms78%
    mozjpeg65S4443.1777062MB2775ms77%
    mozjpeg65S4203.251182(-12%)1.6MB2116ms81%(+3%)

    E 压缩质量 60




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo60S4444.5269811.8MB2189ms79%
    mozjpeg60S4443.4863471.8MB2454ms79%
    mozjpeg60S4203.479777(-23%)1.5MB2035ms82%(+3%)

    从以上数据可知,使用 mozjpeg + S420 相对于 jpeg-turbo + S444 而言,压缩率平均有 3% 的提升,图像质量有 12% 的提升。



    作者:奋斗的Leo
    链接:https://juejin.cn/post/6961302183392313352
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
    收起阅读 »

    精简快速的图片加载框架!

    Demo效果图说明支持加载网络图片(String格式url)/本地资源(mipmap和drawable)/网络.9图片/gif加载/自定义样式(圆形/圆角/centerCrop)/dataBindingv1.1.0起支持读取zip中图片加载至任意View中,无...
    继续阅读 »

    Demo效果图

    话不多说先放图

    说明

    支持加载网络图片(String格式url)/本地资源(mipmap和drawable)/网络.9图片/gif加载/自定义样式(圆形/圆角/centerCrop)/dataBinding

    v1.1.0起支持读取zip中图片加载至任意View中,无需解压.

    更多使用方法和示例代码请下载demo源码查看

    github : BaseImageLoader

    设计说明

    根据BaseImageLoader持有图片View层的contextBaseImageConfig类实现Glide原生的生命周期感知和多样化的自定义配置加载

    BaseImageConfig使用建造者模式,使用更灵活更方便,也可自行继承BaseImageConfig减少类名长度和实现自定义功能

    主要功能

    • loadImage 动态配置config加载你需求的资源图片
    • loadImageAs 获取网络url返回的资源,可获取drawable/bitmap/file/gif四种文件格式,可控知否获取资源的同时加载到View上
    • clear 取消加载或清除内存/储存中的缓存
    • BaseImageView 与动态config完全相同功能的自定义ImageView,支持xml中自定义属性配置各种加载需求
    • autoLoadImage 开发者自行指定zip压缩包的路径.并绑定当前View的根布局,配合View的tag字段自动加载zip中符合tag中图片名称的图片

    添加依赖

    implementation 'com.alex:BaseImageLoader:1.1.0'

    使用的依赖库

    • api 'com.github.bumptech.glide:glide:4.11.0'
    • annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'

    开发者如需剔除重复依赖自行处理

    使用说明

    1.添加权限

    需要添问网络和内存读写权限

    2.项目通用配置

    功能配置全部可选,如不配置则:

    默认内存缓存大小20mb

    默认bitmap池缓存30mb

    默认硬盘缓存250mb

    默认缓存文件夹名称BaseImageLoaderCache

    默认缓存策略为AUTOMATIC,自动模式

        BaseImageSetting.getInstance()
    .setMemoryCacheSize(30)//设置内存缓存大小 单位mb
    .setBitmapPoolSize(50)//设置bitmap池缓存大小 单位mb
    .setDiskCacheSize(80)//设置储存储存缓存大小 单位mb
    .setLogLevel(Log.ERROR)//设置log等级
    .setPlaceholder(R.drawable.ic_baseline_adb_24)//设置通用占位图片,全项目生效
    .setErrorPic(R.mipmap.ic_launcher)//设置通用加载错误图片,全项目生效
    .setCacheFileName("BaseImageLoaderDemo")//设置储存缓存文件夹名称,api基于Glide v4
    .setCacheStrategy(CacheStrategy.AUTOMATIC)//设置缓存策略
    .setCacheSize(50)//设置自动加载图片缓存数量,默认50
    ;

    3.使用

    1.获取BaseImageLoader对象

    根据开发者项目设计模式,MVC/MVP/MVVM自行获取BaseImageLoader类对象,并自行管理生命周期.

    BaseImageLoader自行提供单例,BaseImageLoader.getInstance();

    2.加载至ImageView(包括但不限于任何继承于View或ViewGroup的view)

        BaseImageLoader mImageLoader = new BaseImageLoader();
    mImageLoader.loadImage(this, ImageConfig.builder()
    .url(Uri.parse(imageUrl))//url
    .imageView(img1)//imageView
    .placeholder(R.drawable.ic_baseline_adb_24)//占位图
    .errorPic(R.mipmap.ic_launcher)//加载错误图片
    .cacheStrategy(CacheStrategy.ALL)//缓存策略
    .centerCrop(true)//centerCrop
    .crossFade(true)//淡出淡入
    .isCircle(true)//是否圆形显示
    .setAsBitmap(true)//是否以bitmap加载图片,默认为drawable格式
    .setRadius(30)//设置通用圆角,单位dp
    .setTopRightRadius(10)//左上圆角,单位dp
    .setTopLeftRadius(20)//右上圆角,单位dp
    .setBottomRightRadius(30)//左下圆角,单位dp
    .setBottomLeftRadius(40)//右下圆角,单位dp
    .show());

    注意:

    避免过度绘制和二次绘制,其中优先级

    isCircle(true) > setRadius(int) > setTopRightRadius(int) = setTopLeftRadius(int) = setBottomRightRadius(int) = setBottomLeftRadius(int)

    1. 设置isCircle(true)会使通用圆角设置不生效,减少绘制次数

    2. 设置setRadius()会使分别控制单独圆角不生效,减少绘制次数

    3.资源文件直出

    方法一:

        /**
    * 加载图片同时获取不同格式的资源
    * @param context {@link Context}
    * @param url 资源url或资源文件
    * @param listener 获取的资源回调结果
    */
    void loadImageAs(@NonNull Context context, @NonNull Object url, @NonNull L listener);

    /**
    * 根据图片类型直出对象
    * 需要根据参数类型判断获取的字段,比如使用OnBitmapResult,就只有getBitmap方法不为null
    * 根据是否传入imageView是否直接显示图片,如果想自己处理过资源再加载则不传入imageView
    *
    */
    mImageLoader.loadImageAs(this, imageUrlTest, new OnBitmapResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getBitmap() + "");
    }
    });

    方法二:

        /**
    *
    * @param context {@link Context}
    * @param url 资源url或资源文件
    * @param imageView 显示的imageView
    * @param listener 获取的资源回调结果
    */
    void loadImageAs(@NonNull Context context, @NonNull Object url, @Nullable ImageView imageView, @NonNull L listener);

    /**
    * 加载图片且获得bitmap格式图片 且以 imageView.setImageBitmap(bitmap) 模式加载图片
    */
    mImageLoader.loadImageAs(this, imageUrlTest, img14, new OnBitmapResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getBitmap() + "");
    }
    });

    /**
    * 使用File类型获取result时,默认result.getFile()是在设置的cache目录中
    * 加载图片且获得File文件 但是以Glide默认方式加载图片(drawable格式) imageView.setImageDrawable(drawable);
    */
    mImageLoader.loadImageAs(this, imageUrlTest, img14, new OnFileResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getFile() + "");
    }
    });

    /**
    * 加载gif且获得gif文件 以 imageView.setImageDrawable(GifDrawable); 模式加载图片
    */
    mImageLoader.loadImageAs(this, gifUrl, img14, new OnGifResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getGif() + "");
    }
    });

    /**
    * 加载图片且获得drawable格式图片 以Glide默认方式加载图片(drawable格式) imageView.setImageDrawable(drawable);
    */
    mImageLoader.loadImageAs(this, imageUrlTest, img14, new OnDrawableResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getDrawable() + "");
    }
    });

    4.自定义BaseImageView

    xml中:

        <me.alex.baseimageloader.view.BaseImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    app:asBitmap="true"
    app:bottomLeftRadius="40dp"
    app:bottomRightRadius="30dp"
    app:cacheStrategy="ALL"
    app:errorPic="@mipmap/ic_launcher"
    app:isCenterCrop="true"
    app:isCircle="true"
    app:isCrossFade="true"
    app:placeholder="@drawable/ic_baseline_adb_24"
    app:radius="30dp"
    app:topLeftRadius="20dp"
    app:topRightRadius="10dp"
    app:url="https://xxx.xxx.com/photo/xxxxxx.png" />

    api与代码设置相同

    支持DataBinding:

        <me.alex.baseimageloader.view.BaseImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    app:url="@{data.imageUrl}" />

    详见demo中dataBinding简单使用 优先级规则同上

    4.自动加载图片

    String ZIP_FILE_PATH = me.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS).getAbsolutePath() + File.separator + "imgs.zip";

    //ZIP_FILE_PATH真实路径为:/storage/emulated/0/Android/data/me.alex.baseimageloaderdemo/files/Documents/imgs.zip

    ScrollView autoLoadViewGroup = findViewById(R.id.autoLoadViewGroup);

    BaseImageLoader.getInstance().autoLoadImage(this, autoLoadViewGroup, ZIP_FILE_PATH);

    xml中

    <?xml version="1.0" encoding="utf-8"?>
    <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/autoLoadViewGroup"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center_horizontal"
    android:orientation="vertical"
    android:overScrollMode="never">

    <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center_horizontal"
    android:orientation="vertical">

    <me.alex.baseimageloader.view.BaseImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="10dp"
    android:tag="img1.png" />

    <me.alex.baseimageloader.view.BaseImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="10dp"
    android:tag="img2.png"
    app:isCircle="true" />

    <ImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="10dp"
    android:tag="img3.png" />

    <LinearLayout
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="10dp"
    android:tag="img1.png" />
    </LinearLayout>
    </ScrollView>
    • zip文件夹位置开发者自行设置,demo中是将assets中的imgs.zip复制至指定路径然后加载.
    • 配合xml中View对象的tag参数匹配zip中的文件名称.
    • 如使用BaseImageView+tag加载图片,支持自定义属性
    • Demo中加载6张图片耗时60ms左右
    • 本功能开发本意是减少apk体积 , 减少重复资源下载 , 开发者可在业务流程中自行处理zip文件的下载和存放位置 , 自行处理数据安全

    参数说明

    1.BaseImageSetting:

    函数名入参类型参数说明
    setMemoryCacheSize()int设置内存缓存大小 单位mb
    setBitmapPoolSize()int设置bitmap池缓存大小 单位mb
    setDiskCacheSize()int设置储存储存缓存大小 单位mb
    setLogLevel()int设置框架日志打印等级,详看android.util.Log类与Glide v4文档
    placeholder()intv1.0.0版本仅支持resource类型int参数
    errorPic()intv1.0.0版本仅支持resource类型int参数
    setCacheFileName()String设置储存缓存文件夹名称,api基于Glide v4

    2.BaseImageLoader:

    /**
    * 加载图片 使用继承自BaseImageConfig的配置
    *
    * @param context {@link Context}
    * @param config {@link BaseImageConfig} 图片加载配置信息
    */
    void loadImage(@NonNull Context context, @NonNull T config);
    /**
    * 自动加载图片
    * @param context {@link Context}
    * @param viewGroup xml中的根标签View
    * @param zipFileRealPath zip文件夹路径
    */
    void autoLoadImage(@NonNull Context context, @NonNull ViewGroup viewGroup, @NonNull String zipFileRealPath);
    /**
    * 加载图片同时获取不同格式的资源
    * @param context {@link Context}
    * @param url 资源url或资源文件
    * @param listener 获取的资源回调结果
    */
    void loadImageAs(@NonNull Context context, @NonNull Object url, @NonNull L listener);
    /**
    *
    * @param context {@link Context}
    * @param url 资源url或资源文件
    * @param imageView 显示的imageView
    * @param listener 获取的资源回调结果
    */
    void loadImageAs(@NonNull Context context, @NonNull Object url, @Nullable ImageView imageView, @NonNull L listener);
    /**
    * 停止加载 或 清除缓存
    *
    * @param context {@link Context}
    * @param config {@link BaseImageConfig} 图片加载配置信息
    */
    void clear(@NonNull Context context, @NonNull T config);

    代码下载:BaseImageLoader-master.zip

    收起阅读 »

    Flutter 中键盘弹起时,Scaffold 发生了什么变化

    最近刚好有网友咨询一个问题,那就顺便借着这个问题给大家深入介绍下 Flutter 中键盘弹起时, Scaffold 的内部发生了什么变化,让大家更好理解 Flutter 中的输入键盘和 Scaffold 的关系。 如下图...
    继续阅读 »

    最近刚好有网友咨询一个问题,那就顺便借着这个问题给大家深入介绍下 Flutter 中键盘弹起时, Scaffold 的内部发生了什么变化,让大家更好理解 Flutter 中的输入键盘和 Scaffold 的关系。


    如下图所示,当时的问题是:当界面内有 TextField 输入框时,点击键盘弹起后,界面内底部的按键和 FloatButton 会被挤到键盘上面,有什么办法可以让底部按键和 FloatButton 不被顶上来吗?





    其实解决这个问题很简单,那就是只要 「把 Scaffold 的 resizeToAvoidBottomInset 配置为 false 」 ,结果如下图所示,键盘弹起后底部按键和 FloatButton 不会再被顶上来,问题解决。 「那为什么键盘弹起会和 resizeToAvoidBottomInset 有关系?」





    Scaffold 的 resize

    Scaffold 是 Flutter 中最常用的页面脚手架,前面知道了通过 resizeToAvoidBottomInset ,我们可以配置在键盘弹起时页面的底部按键和 FloatButton 不会再被顶上来,其实这个行为是因为 Scaffold 的 body 大小被 resize 了。


    那这个过程是怎么发生的呢?首先如下图所示,我们在 Scaffold 的源码里可以看到,当 resizeToAvoidBottomInset 为 true 时,会使用 mediaQuery.viewInsets.bottom 作为 minInsets 的参数,也就是可以确定: 「键盘弹起时的界面 resize 和 mediaQuery.viewInsets.bottom 有关系」 。





    而如下图所示, Scaffold 内部的布局主要是靠 CustomMultiChildLayout , CustomMultiChildLayout 的布局逻辑主要在 MultiChildLayoutDelegate 对象里。


    前面获取到的 minInsets 会被用到 _ScaffoldLayout 这个 MultiChildLayoutDelegate 里面,也就是说  Scaffold 的内部是通过 CustomMultiChildLayout 实现的布局,具体实现逻辑在 _ScaffoldLayout 这个 Delegate 里」 。




    关于 CustomMultiChildLayout 的详细使用介绍在之前的文章 《详解自定义布局实战》 里可以找到。



    接着看 _ScaffoldLayout , 在 _ScaffoldLayout 进行布局时,会通过传入的 minInsets 来决定 body 显示的 contentBottom , 所以可以看到 「事实上传入的 minInsets 改变的是 Scaffold 布局的 bottom 位置」 。



    > 上图代码中使用的 _ScaffoldSlot.body 这个枚举其实是作为 LayoutId 的值, MultiChildLayoutDelegate 在布局时可以通过 LayoutId 获取到对应 child 进行布局操作,详细可见: 《详解自定义布局实战》



    那么 Scaffold 的 body 是什么呢?如上图代码所示,其实 Scaffold 的 body 是一个叫 _BodyBuilder 的对象,而这个 _BodyBuilder 内部其实是一个 LayoutBuilder 。(注意,在 widget.appbar 不为 null 时,会 removeTopPadding )


    所以如下图代码所示 body 在添加时, 「它父级的 MediaQueryData 会被重载,特别是 removeTopPadding 会被清空, viewInsets.bottom 也是会被重置」 。





    最后如下代码所示, _BodyBuilder 的 LayoutBuilder 里会获取到一个 top 和 bottom 的参数,这两个参数都通过前面在 _ScaffoldLayout 布局时传入的 constraints 去判断得到,最终 copyWith 得到新的 MediaQuery 。



    这里就涉及到一个有意思的点,在 _BodyBuilder 里的通过 copyWith 得到新的 MediaQuery 会影响什么呢?如下代码所示,这里用一个简单的例子来解释下。


    class MainWidget extends StatelessWidget {
    final TextEditingController controller =
    new TextEditingController(text: "init Text");
    @override
    Widget build(BuildContext context) {
    print("Main MediaQuery padding: ${MediaQuery.of(context).padding} viewInsets.bottom: ${MediaQuery.of(context).viewInsets.bottom}");
    return Scaffold(
    appBar: AppBar(
    title: new Text("MainWidget"),
    ),
    extendBody: true,
    body: Column(
    children: [
    new Expanded(child: InkWell(onTap: (){
    FocusScope.of(context).requestFocus(FocusNode());
    })),
    ///增加 CustomWidget
    CustomWidget(),
    new Container(
    margin: EdgeInsets.all(10),
    child: new Center(
    child: new TextField(
    controller: controller,
    ),
    ),
    ),
    new Spacer(),
    ],
    ),
    );
    }
    }
    class CustomWidget extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    print("Custom MediaQuery padding: ${MediaQuery.of(context).padding} viewInsets.bottom: ${MediaQuery.of(context).viewInsets.bottom}\n \n");
    return Container();
    }
    }
    `


    如上代码所示:

    举个例子,如下图所示,从 Android 的 Java 层弹出键盘开始,会把改变后的视图信息传递给 C++ 层,最后回调到 Dart 层,从而触发 MaterialApp 内的 didChangeMetrics 方法执行 setState(() {}); ,进而让 _MediaQueryFromWindow 内的 build 更新了 MediaQueryData ,最终改变了 Scaffod 的 body 大小。





    那么到这里,你知道如何在 Flutter 里正确地去获取键盘的高度了吧?


    最后

    从一个简单的 resizeToAvoidBottomInset 去拓展到 Scaffod 的内部布局和 MediaQueryData 与键盘的关系,其实这也是学习框架过程中很好的知识延伸,通过特定的问题去深入理解框架的实现原理,最后再把知识点和问题关联起来,这样问题在此之后便不再是问题,因为入脑了~



    转自:https://www.agora.io/cn/community/blog/121-category/21060

    收起阅读 »

    SwiftUI动画—只需5个步骤即可构建加载微调器

    自SwiftUI出现以来,编写UI代码的方式就已经改变了,它为我们发展创造力提供了很多功能,这些功能之一与动画状态转换有关。本文会指引你构建自定义加载微调器,下图就是基于此设计的:此外,你可以在我的项目存储库中找到所有操作细节:https://github.c...
    继续阅读 »


    自SwiftUI出现以来,编写UI代码的方式就已经改变了,它为我们发展创造力提供了很多功能,这些功能之一与动画状态转换有关。

    本文会指引你构建自定义加载微调器,下图就是基于此设计


    此外,你可以在我的项目存储库中找到所有操作细节:

    让我们开始吧!

    1.创建

    针对此动画,我们将使用 Circle 结构。如你所见,此次进行的小改动将帮助我们获得所需的微调框外观。

    struct Spinner: View {

    var body: some View {
    ZStack {
    SpinnerCircle()
    }.frame(width: 200, height: 200)
    }
    }

    struct SpinnerCircle: View {

    var body: some View {
    Circle()
    .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
    }
    }
    • 创建一个名为 Spinner 的新SwiftUI文件。
    • 再创建一个名为 SpinnerCircle 的视图,其中包含一个 Circle 。
    • 使用 stroke(style:) 修改器获得微调器外观。
    • 最后,在主视图中,构建一个200像素的 SpinnerCircle 框架。


    当前预览

    2.修剪

    要包含的下一个修饰符是 trim(from:to:) ,这样我们就可以利用提供的参数来绘制部分形状。具体工作方式如下:


    考虑到这一点,我们在 SpinnerCircle 视图中做了一些更改:

    struct SpinnerCircle: View {
    var start: CGFloat
    var end: CGFloat

    var body: some View {
    Circle()
    .trim(from: start, to: end)
    .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
    }
    }

    3.旋转

    最后,我们将包含另一个修饰符。 rotationEffect() 可以使我们的视图旋转到特定的角度。

    拥有半个修剪圆( .trim(from:0.0 to: 0.5) ),并且 rotationEffect(d) 中的 d 是先前定义的一个变量,然后旋转修饰符将导致:


    要做的更改:

    struct SpinnerCircle: View {
    var start: CGFloat
    var end: CGFloat
    var rotation: Angle
    var color: Color

    var body: some View {
    Circle()
    .trim(from: start, to: end)
    .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
    .fill(color)
    .rotationEffect(rotation)
    }
    }

    我们完成了!并且已经准备好进行动画处理。还要注意的是,我们包括了 fill 修饰符和 color 属性。

    在这一点上, Spinner 整体视图是不完整的,不用担心,这一问题在下一部分会得到解决。

    4.对其进行动画处理

    回到主视图,我们要定义一些常量,例如微调器旋转的持续时间以及将微调器置于初始位置的度数:

    struct Spinner: View {

    let rotationTime: Double = 0.75
    let fullRotation: Angle = .degrees(360)
    static let initialDegree: Angle = .degrees(270)

    var body: some View { ... }
    }

    动画将基于已更改的 SpinnerCircle 修剪和旋转属性,因此包含带有 State 的以下变量:

        @State var spinnerStart: CGFloat = 0.0
    @State var spinnerEndS1: CGFloat = 0.03
    @State var rotationDegreeS1 = initialDegree

    最后,主体中包含 SpinnerCircle 视图、一些动画方法和一个 .onAppear() 块:

        var body: some View {
    ZStack {
    // S1
    SpinnerCircle(start: spinnerStart, end: spinnerEndS1, rotation: rotationDegreeS1, color: darkBlue)

    }.frame(width: 200, height: 200)
    .onAppear() {
    Timer.scheduledTimer(withTimeInterval: animationTime, repeats: true) { (mainTimer) in
    self.animateSpinner()
    }
    }
    }

    // MARK: Animation methods
    func animateSpinner(with timeInterval: Double, completion: @escaping (() -> Void)) {
    Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { _ in
    withAnimation(Animation.easeInOut(duration: rotationTime)) {
    completion()
    }
    }
    }

    func animateSpinner() {

    }
    • 动画方法将在特定时间间隔内更新微调器的圆圈属性,从而创建单独的动画。
    • 一旦视图出现,微调框将使用 Timer 不断进行动画处理。
    • animationTime 代表动画的总时间。它是动画方法中定义所有时间间隔的总和。
    • 将其定义为 let animationTime: Double = 1.9 。

    我们准备好了,那就开始制作动画吧!

    在 animateSpinner() 方法中,包括在 rotationTime 期间填充 Circle 形状所需的代码,具体如下:

    animateSpinner(with:rotationTime){self.spinnerEndS1 = 1.0}


    当前预览

    在下面的旋转中( rotationTime x 2 ),我们将返回到初始形状:

    animateSpinner(with:(rotationTime * 2)){     
    self.spinnerEndS1 = 0.03
    }


    当前预览

    最后的一个技巧。我们将在上一个动画之前进行一个完整的旋转(360°)。这样,我们就可以实现所需的动画了:

    animateSpinner(with:(rotationTime * 2
    -0.025 ){ self.rotationDegreeS1 + = fullRotation
    }


    当前预览

    • 通过测试得到 0.025 。我们尝试几个值,直到找到一个合适的值为我们提供一个流畅的动画。

    5.完成

    其余练习只是对先前步骤的重复。如果要检查我们正使用的设计,你会注意到其余部分也都是 Circle 。

    因此,如果我们在主视图中添加此颜色和一些颜色详细信息,则它看起来就像:

    ZStack {
    darkGray
    .edgesIgnoringSafeArea(.all)

    ZStack {
    // S3
    SpinnerCircle(start: spinnerStart, end: spinnerEndS2S3, rotation: rotationDegreeS3, color: darkViolet)

    // S2
    SpinnerCircle(start: spinnerStart, end: spinnerEndS2S3, rotation: rotationDegreeS2, color: darkPink)

    // S1
    SpinnerCircle(start: spinnerStart, end: spinnerEndS1, rotation: rotationDegreeS1, color: darkBlue)

    }.frame(width: 200, height: 200)
    }
    • spinnerEndS2S3 的初始化方式与 spinnerEndS1 相同。
    • rotationDegreeS2 和 rotationDegreeS3 与 rotationDegreeS1 相同。
    • 使用的颜色是自定义的颜色,可以检查存储库或使用你自己的颜色。

    另一方面, animateSpinner() 将包含所需动画:

        func animateSpinner() {
    animateSpinner(with: rotationTime) { self.spinnerEndS1 = 1.0 }

    animateSpinner(with: (rotationTime * 2) - 0.025) {
    self.rotationDegreeS1 += fullRotation
    self.spinnerEndS2S3 = 0.8
    }

    animateSpinner(with: (rotationTime * 2)) {
    self.spinnerEndS1 = 0.03
    self.spinnerEndS2S3 = 0.03
    }

    animateSpinner(with: (rotationTime * 2) + 0.0525) { self.rotationDegreeS2 += fullRotation }

    animateSpinner(with: (rotationTime * 2) + 0.225) { self.rotationDegreeS3 += fullRotation }
    }

    这样,就大功告成了:


    当前预览

    如果你对SwiftUI动画感兴趣并希望看到更多内容,可以着手于该项目或在Instagram上关注我:

    转自:https://www.agora.io/cn/community/blog/121-category/21094

    收起阅读 »

    在SFU上实现RED音频冗余功能

    SFU
    最近,Chrome添加了使用RFC 2198中定义的RED格式给音频流添加冗余的选项。Fippo之前写过一篇文章解释该过程和实现,建议大家研读。大致总结一下这篇文章的话,主要讲述了RED的工作原理是在同一个数据包中添加具有不同时间戳的冗余有效载荷。如果你在出现...
    继续阅读 »

    最近,Chrome添加了使用RFC 2198中定义的RED格式给音频流添加冗余的选项。Fippo之前写过一篇文章解释该过程和实现,建议大家研读。大致总结一下这篇文章的话,主要讲述了RED的工作原理是在同一个数据包中添加具有不同时间戳的冗余有效载荷。如果你在出现损耗的网络中丢失了一个数据包,若另一个数据包被成功接收,其中可能会含有丢失的数据,产生更好的音频质量。

    上述假设发生在简化的一对一场景下,但音频质量问题往往对多方大型通话影响最大。本篇作为Fippo文章的后续,Jitsi 设计师、 Improving Scale and Media Quality with Cascading SFU 的作者Boris Grozev会在本文中向我们介绍他为应对在更复杂的环境添加音频冗余而进行的设计和测试,该环境中存在大量端通过SFU路由媒体。


    Fippo在之前的文章中介绍了如何无需任何类似SFU的中间件,就能在标准的端对端呼叫中添加冗余数据包。那么当你在中间件插入SFU时会发生什么呢?需要考虑以下问题:

    • 如何处理会议中不同客户有不同RED功能的情况?可能会议中只有一部分人支持RED。事实上该情况现在很常见,因为RED是WebRTC、Chromium、Chrome中相对较新添加的功能。
    • 哪些流应该添加冗余?我们是否应该给所有音频流添加冗余,即便这样会产生额外成本?还是只为当前活动的扬声器(或2-3个扬声器)添加冗余?
    • 哪些部分应该添加冗余?在多SFU级联场景中,我们是否需要为SFU-SFU流添加冗余?

    接下来我们会深入讨论这些问题,介绍我们最近在Jitsi Videobridge中的实现内容,并分享更多测试结果。

    RED 客户端和非RED 客户端的混搭

    如果会议所有客户端都支持RED,他们便无需在服务器上进行任何特殊处理,即可使用它,SFU还是像往常一样转发音频流,只不过恰好包含冗余罢了。然而,如果会议中一些客户端支持RED,一些客户端不支持RED,这就有点复杂了。基于发送方和/或接收方是否支持RED,我们需要考虑以下四种情况:

    1. 非RED到非RED

    2. RED到RED

    3. 非RED到RED

    4. RED到非RED

    非RED 到非RED

    第一种情况比较简单:从一个非RED客户端将流转发到一个非RED客户端。流上没有冗余,我们也不被允许添加任何冗余。没什么好办法能改善该情况了。

    RED 到RED

    第二种也很简单:将RED流转发到支持RED的客户端。最简单的做法就是简单转发不变的转发流,这也是一个合理的解决方案。因为不必要对RED流重新编码,我们只需将其转发过去即可。

    非RED 到RED

    SFU的最后一种情况是将Opus流转发到支持RED的客户端,即对RED进行编码,也就是Fippo文章中所说的一对一情况,但增加了一些下述的限制

    RED 到非RED

    第三种是比较困难的情况,即将RED流转发到没有RED的客户端。当然,我们可以直接删除RED并丢弃冗余,但如果SFU和客户端之间丢包,这也不能提高音频质量。该缺陷揭示了RFC2198 RED格式的一个限制。因为中间件SFU需要产生有效的RTP数据流,它需要知道从冗余块中恢复的数据包应该使用哪个RTP序列号,但该信息并不包含在RED header里。这是因为该格式被设计为由端点,而不是由时间戳足以用于回放的中间件来解释,所以其中只包含了一个 “时间戳偏移”(TO)字段

    0 1 2 3

    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1

    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

    |F| block PT | timestamp offset | block length |

    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+


    如果我们总是使用distance 1,也就是总有一个冗余的数据包(详见Fippo有关distance的文章),对SFU来说不成问题。因为所有的RED包都有一个冗余块,其序列号就是RED包的序列号前面的那一串。同样,如果我们总是使用distance 2(两个冗余数据包),并为所有数据包添加冗余,是不会产生问题的。如果流采用为数据包特设添加冗余的distance 2,且该数据包包含语音活动时(即VAD位被设置),问题就来了。

    (对于RED ) VAD 无益

    假设原始流有三个数据包(seq=1,2,3)。其中一个包含语音,另外两个不包(没有VAD)。当RED编码器处理数据包3时,它只对数据包1添加冗余,因为数据包2不包含语音。



    问题是:当我们在SFU上收到这样一个数据包时,我们怎么知道RED块是否包含数据包1或数据包2的副本呢?如果编码器使用的是distance 1,或者数据包1和2中的语音标志不见了,RED数据包看起来是一样的,其实包含了数据包2的冗余。


    当我们从SFU的RED数据包中提取冗余时,要如何决定对它使用什么序列号呢?一种方法是看时间戳,对数据包的持续时间做出假设。由于我们使用Opus的RTP时钟速率为48000,帧数为20毫秒,即可表示为red_seq = seq – timestamp_offset * (20 / 48000) 。这样做出的假设就很多了。我们也可以更进一步,读取Opus数据包的持续时间。所以我们不必假设它是20毫秒,但也要考虑到如下问题:

    1. 它不能用于e2e加密流。

    2. 它是编解码器专用的。

    3. 技术上来说,opus流可以在中途改变帧大小。

    改变RED

    我们无法在当前的规范中找到一个好的解决方案。所以我们给RED编码器序列计数器增加了一个新的限制,以确保冗余数据包的序列号总是直接在主数据包的序列号之前。对于上面的示例流,RED编码器有三个有效选项。


    第一张图中的选项操作已被禁止了,即给数据包1添加冗余,但不给数据包2添加。这就是Fippo最新的WebRTC源补丁所实现的功能

    所有的流都应该有冗余吗?

    在一对一的情况下,若双方也使用视频,那么相对于组合比特率来说RED的开支很小。对于一个典型的32kbps Opus流,distance 2的RED增加约64kbps的成本。而在我们的服务中,一典型的视频流所占成本为2Mbps,所以总体增加的开销在3%左右——并非微不足道,而是相对较小。

    然而在多方会议中,若SFU向每个接收端转发多个流,且许多视频流是低比特率(即缩略图)运行时,增加的成本可能会更多。

    我们从最简单的技术方案开始进行实现,即为所有流添加冗余,并假设大多数会议同时有多人发言。因为SFU不会转发标记为静音的音频,所以同时发言的人少意味着音频流少。因此在大多数情况下,每个与会者只会在少数音频流上收到RED,减少了开销 。事实上,根据我们从meet.jit.si得到的数据来看,78%的实时会议的发言人为三人及以下(即音频水平不为零的流),所以大多数情况下,在接收端点没有太多RED过载的可能性。


    我们还可以做些什么来进一步减少开销呢?我们可以只为活跃的发言人,或者最活跃的2-3个发言人添加冗余。此外,我们还可以根据可用带宽和会议中的视频流做出更具体、复杂的决定。

    级联式SFU

    另外要考虑的是如何处理级联SFU中的RED。在这种情况下,我们用SFU到SFU的连接以获取更大规模。我们部署了稳定的高带宽链路,以及在SFU之间没有明显的数据包丢失,所以我们选择不主动给没有冗余的流添加冗余。但是我们也不会主动删除冗余,因为我们有很多低成本的带宽。最终的流程如下图所示:


    测试结果

    为了验证新的RED功能,我们创建了一个测试床以测量系统在不同的丢包情况下的表现。我们的默认配置是将SFU上编码的流设置为distance=2,vad-only=true。并且我们给以下两条链路都引入了20%、40%和60%的均匀丢包情况,即发送方和SFU之间大的链路(1.)和SFU和接收方之间的链路(2.)。数据包丢失的位置不同并不会造成大的影响。点击下方你可以收听发送端丢失的例子,所有的测试案例请点击此处收听。


    注:请访问原文地址(见文章末尾处),播放音频示例。

    通过收听例子,我们会发现因为RED的存在,音频质量有了明显提升。

    POLQA 测试

    在另一个具有类似设置的实验中,我在8×8的同事Garth Judge使用POLQA标准和工具集量化了RED带来的影响。以下是他的检测结果。


    结论

    最近,使用RED标准的WebRTC音频冗余在Chrome浏览器进行了现场试验,试验显示该技术巨大的前景。要在多方会议环境中有效使用RED,就需要SFU上额外的服务器支持。我们的测试表明,这些SFU对RED的增强产生了切实效果。

    但我们还有更多工作要做,包括选择要添加冗余的流的子集、使用VAD选择要添加的数据包,以及可能使用Opus LBRR(低比特率冗余)来降低冗余带来的额外比特率等等。

    实验室的测试也表明,重度丢包下的音频质量得到了显著改善。请大家继续关注现场测试结果。

    转自:https://www.agora.io/cn/community/blog/121-category/21120

    收起阅读 »

    如何集成拨号功能至WebRTC应用

    如何才能把拨入或拨出功能添加到你的WebRTC视频应用中呢?在何种情况下,你会把公用电话交换网(PSTN)上的传统拨号电话连接到WebRTC音视频会议呢?下面我们就来探讨一下如何把拨号功能集成到WebRTC。你可以观看我们WebRTC.ventures工程团队...
    继续阅读 »

    如何才能把拨入或拨出功能添加到你的WebRTC视频应用中呢?在何种情况下,你会把公用电话交换网(PSTN)上的传统拨号电话连接到WebRTC音视频会议呢?下面我们就来探讨一下如何把拨号功能集成到WebRTC。

    你可以观看我们WebRTC.ventures工程团队提供的下列视频和其他提示,这些都是我们YouTube视频WebRTC Tips系列的一部分。除此之外,你也可以继续阅读下文。

    视频网址

    拨号集成至WebRTC 视频应用的案例

    呼叫中心 ——许多情况下,代理人和客户直接见面大有裨益。因为你也许能借此了解到用户或产品的情况,亦或能进行手语翻译。

    视频会议拨入 ——拨入选项可以让与会者使用他们的电话加入会议。这是一种重要的备份,以防网络连接不畅或用户的麦克风有问题。(点击此处,了解通话前测试的重要性。)

    点击呼叫按钮 ——现在,我们经常会在网站的右下角看到一个联系客服的图标。该客服可能是一个聊天机器人,甚至可能在你和代理人之间启动安全的WebRTC视频会话。此外,第三种选择是使用你的麦克风和浏览器启动音频呼叫。多见于有传统呼叫中心的公司。

    软件电话 ——软件电话模仿了电脑上桌面电话的功能。它可以内置到应用程序中,拨出到PSTN。软件电话使用WebRTC来捕捉拨号方的音频,但它会连接到传统电话网络中呼出电话。这在销售领域很常见。例如在CRM系统中工作的销售人员可以使用同一系统给客户打电话。一切都会被追踪且保存在一个地方。

    如何将拨号集成到WebRTC 视频应用中?

    内置到CPaaS ——除了使用传统的媒体服务器,许多通信平台即服务(CPaaS)提供商(如Vonage和Twilio)还提供拨入或拨出功能。正如我们在其他文章中讨论过的,CPaaS的一大优势是他们会为你处理所有的交互。

    SIP网关服务 ——如果你正在构建自己的WebRTC应用,那么可以考虑将拨号集成到视频应用的另一种商业方案。会话启动协议(SIP)是一种信令协议,可以实现多种类型的互联网通信会话,包括拨号。8×8等供应商会提供SIP网关作为一个商业的现收现付平台。

    DIY ——你也可以完全开源。如果你的公司已经自己掌握了PBX软件,并且使用的是比较复杂的电话拓扑结构,开源是很好的选择。

    架构示例


    使用Janus开源媒体服务器的拨号架构示例

    至此,我们已经展示了许多可以将拨入功能构建到视频会议中的方法。上述的架构是一个使用Janus(由Meetecho开发的开源WebRTC服务器)的示例。Janus媒体服务器没有内置拨号功能,但它有一个SIP插件,你可以使用SIP协议连接到其他系统。我们会选择连接到一个商业SIP网关,或使用我们自己的开源配置。在上述这个例子中,我们使用Asterisk作为我们的专用分支交换机(PBX)。如果你有我们公司自己的内部电话网络,也是个不错的选择。总之最后你实现了在视频会议中内置拨入和拨出功能。

    转自:https://www.agora.io/cn/community/blog/121-category/21158

    收起阅读 »

    「Typing」开源—— 3步打造属于自己的实时文字互动社交App

    为了与开发者一起更好地探索互动实时消息的更多可能性,我们基于声网云信令/ RTM(Real-time Messaging)SDK 开源了一个实时文字互动 Demo——「Typing」。从体验来讲,「Typing」与音视频通话更为类似。对方打字时的速度或每一个停...
    继续阅读 »

    为了与开发者一起更好地探索互动实时消息的更多可能性,我们基于声网云信令/ RTM(Real-time Messaging)SDK 开源了一个实时文字互动 Demo——「Typing」。从体验来讲,「Typing」与音视频通话更为类似。对方打字时的速度或每一个停顿都可以被看见,并且实时展示的文字信息与数据也不会有历史留存。

    开源地址:https://github.com/AgoraIO-Community/typing/releases


    「Typing」Demo演示
    这样一种几乎“无时延”、无留存信息的互动方式在很多针对 Z 世代群体(Generation-Z,一般是指在1995年——2009年出生的一代)进行开发的 App 中也受到了广泛的应用。

    比如主打 00 后社交新模式的「Honk」就是一款致力于“消除”社交延时的文字对话互动 App,希望通过“真阅后即焚”的 100% 实时、无历史数据留存的私密体验,让使用者体验到几乎无时间差的熟人社交型文字互动。在「Honk」上线的第二天,下载排名就达到了美国 iOS 社交类榜单的第 10 位。


    Honk丨图片来源:Sensor Tower
    Z 世代是伴随着互联网和社交媒体长大的一代,相较于其他群体而言,他们对于技术和互联网社交的需求显得更为原始本能——实时互动、安全及熟人社交。而 「Honk」 之所以能够颠覆传统的文本消息互动体验,背后依靠的正是实时消息技术。

    关于实时消息
    通常实时消息可以分为两种,一种是帮助用户来交流的消息,比如文字消息、点赞、送礼物、发弹幕等。另一种则是信令消息,比如聊天室中禁言踢人的权限管理、上麦请求等。与微信、Snapchat 等这类即时通讯聊天软件相比,实时消息传输的重点在于信令、消息传输的低延时和高送达率上。

    声网云信令/RTM (Real-time Messaging)SDK 是一个通用的消息系统,主要是为了解决实时场景下信令的低延迟和高并发问题。云信令/RTM (Real-time Messaging)SDK 的服务器端采用分布式架构,没有一个单点或者中心式的情况,通过多机房多路保活机制,智能优化路径,在其它节点失效时可以自动转移,选择最优节点路径传输。因此,可以有效保证传输的稳定性与可靠性,在性能方面也可以支持高吞吐量和低延时。

    我们尝试基于声网云信令/RTM(Real-time Messaging) SDK 实现了 「Honk」 中的实时文字消息聊天功能,并作为 Demo 开源。希望可以抛砖引玉,与社区的开发者们一起探索更多基于实时信令和虚拟键盘的互动实时消息的新玩儿法。

    「Typing」开源
    目前的「Typing」Demo 中,我们提供了类似 「Honk」 的实时文字聊天,以及点击对方聊天框发送震动的功能,开发者只需要简单的几步就可以实现。以 iOS 版为例:

    安装
    更改目录为 iOS 文件夹,运行以下命令安装项目依赖项,

    pod install

    输入验证,生成正确的 xcworkspace

    获取 App ID

    要构建并运行示例应用程序,需要获取一个应用 ID :
    1、在agora.io创建一个开发者帐户。完成注册过程后,会重新回到仪表板。
    2、在左侧的仪表板树中导航到项目 > 项目列表。
    3、保存仪表板上的 App ID 以备以后使用。
    4、生成一个临时访问 Token (24 小时内有效) 从仪表板页面给定的通道名称,保存以后使用。

    *注:对于安全性要求更高的场景,如果想要部署自己的RTM Token服务器,请参阅文档(https://docs.agora.io/cn/Real-time-Messaging/token_server_rtm

    接下来,打开 Typing.xcworkspace 并编辑 KeyCenter.swift 文件。在 KeyCenter 中更新 <#Your App Id#>,用仪表盘生成的 Token 更改<#Temp Access Token#>

    *注:如果建立的项目没有打开security token,可以将 token 变量保留为nil。

    1Swift
    2 struct KeyCenter {
    3 static let AppId: String = <#Your App Id#>
    4
    5 // assign token to nil if you have not enabled app certificate
    6 static var Token: String? = <#Temp Access Token#>
    7 }

    目前,该 Demo 支持 iOS 与 Android 平台。对于 Android 平台的小伙伴,可以选择下载打包好的 apk 文件,直接体验「Typing」。

    我们相信,关于声网云信令/RTM(Real-time Messaging)SDK 的应用场景和使用方式在不断涌现的新需求下,还有很大的待挖掘潜力。例如,或许你可以考虑把手机键盘变成一个简易的虚拟钢琴键盘,为对方弹奏一首简单的小乐曲?开发者可以通过「Typing」,快速了解声网云信令/RTM(Real-time Messaging) SDK的基本用法,并且继续探索除了文字实时交流之外的,基于各种类型虚拟键盘所进行的 1 对 1 实时互动。

    如果你对「Typing」感兴趣的话,可以进入我们的讨论群与社区的开发者们进行交流,也可以在 GitHub 仓库提交 Issue 留下你的问题、收藏/Fork「Typing」项目,或是通过 Pull Request 提交你的创意与成果。

    转自:https://www.agora.io/cn/community/blog/121-category/21310

    收起阅读 »