注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

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
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

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
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

由三个感叹号开启的 debug 篇章 | Java Debug 笔记

!!! JUnit version 3.8 or later expected: 如下所示,当我在进行单元测试时,控制台居然抛出了这么诡异的bug! 三个感叹号开头 此刻的我 ??? 异常信息如下: java.lang.ClassNotFoundExce...
继续阅读 »

!!! JUnit version 3.8 or later expected:


如下所示,当我在进行单元测试时,控制台居然抛出了这么诡异的bug!


image-20210510221406791


三个感叹号开头


此刻的我 ???



异常信息如下:



java.lang.ClassNotFoundException: junit.framework.ComparisonFailure



那么先挖到它的源码看个究竟叭 😝


在264行打个断点,然后debug运行起来


image-20210511072459885


通过 Alt+F8 来获取这个类加载器 都使用到了哪些类


ClassLoader.getClassLoader(caller)


效果如下:可以看到这里


image-20210511072832306


至于为啥会点开这里,主要时因为它比较突出 哈哈~


可以发现它加载了idea 插件目录 IntelliJ IDEA 2020.1\plugins\junit\lib 中的 junit-rt.jar 文件


img


犹豫了下,还是继续探究下去 哈哈


奇怪的参数


于是我就一路 debug 下来,最后看到这个东东, 运行了 JUnitStartermain 函数~


同时传递了三个变量



  • -ideVersion5

  • -junit3

  • com.java4ye.demo.A,contextLoads (类,测试方法)


如图~


image-20210511080154452


这里我们把这个 junit-rt.jar 解压到上面的这个 junit-rt 目录,


image-20210511203847954


IDEA 打开 很快就可以找到这个 JUnitStarter 了。


image-20210511204053445


!!!的来源


查阅代码,发下有这么一个调用逻辑~


if (!"com.intellij.junit5.JUnit5IdeaTestRunner".equals(agentName) && !canWorkWithJUnitVersion(System.err, agentName)) {
System.exit(-3);
}
复制代码

Soga , 这个 Process finished with exit code -3 是这么来的


canWorkWithJUnitVersion


image-20210512070528217


junitVersionChecks


image-20210512070721736


小结


可以发现如果代理名称 agentName 不是 com.intellij.junit5.JUnit5IdeaTestRunner


就会去 check 这个 junit 版本。 然后去加载这个 junit.framework.ComparisonFailure 类。


tipJunit5 中并没有这个类,版本 5 的架构更复杂,JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage


顺带提下这个 ComparisonFailure 的作用:



当断言equals for Strings失败时抛出



如下 ε=ε=ε=( ̄▽ ̄) 居然还有邮箱 📫


image-20210512071244798




为何会出现 Junit3 这个奇怪的参数


这里先解释下,传递的参数乎关系到这个 agentName


image-20210512075559415


那么问题来了!


在我的 demo 中,使用的 Springboot 版本是 2.4.5 ,同时在 pom 文件中引入了 spring-boot-starter-test ,它的版本号是5.7 ,如下


image-20210512072425629


可以看到明明使用的是 JUnit5


带着疑问来看看项目的结构是啥样子叭~


image-20210512065210618


嘿嘿,可以发现这里 test 目录下 和 main 目录中有个 同包同名的类 A


test 下的 A


package com.java4ye.demo;

//import org.junit.Test;

import org.junit.jupiter.api.Test;

public class A{

@Test
public void contextLoads() {
System.out.println("hello");
}

}
复制代码

这时我尝试着将这个 test 下的 A 重命名为 AA ,奇怪的是,它正常跑起来了,哈哈,而且确实是用的 Junit5


image-20210512064803405


img


于是我又做了一个实验,导入 Junit4 的包,将 AA 改为 A ,继续测试,结果也是正常的


小结


使用 Junit5 时,如果测试目录 test 下的测试类和 main 目录下的同包同名,会出现这个奇怪的参数 -Junit3 , 导致抛出异常 !!! JUnit version 3.8 or later expected:


这里我也很好奇为啥参数变成了 -Junit3 ,可是不懂要怎么 debug 看下了,无奈作罢 🐖




插曲


java.lang.NoClassDefFoundError:


在找到这个 JUnitStarter 类时, 4ye 尝试着用命令 java JUnitStarter 去运行,结果居然抛出了 java.lang.NoClassDefFoundError:


java JUnitStarter 命令去运行,结果居然抛出了 java.lang.NoClassDefFoundError:


image-20210511204334905


区别

不知道小伙伴们对这个 Error 熟不熟悉 哈哈,平时看到的都是 ClassNotFoundException


这两者最大的区别就是:

一个是 Error ,一个是 Exception 哈哈


详细点点说:

ClassNotFoundException 是非运行时异常,在编译期间就检测出来可能会发生的异常,需要你 try catch


而这个 java.lang.NoClassDefFoundError: 是属于 error ,是 JVM 处理不了的错误。


这里还有一点点小细节~

就是这个原因是在 JDK11 下才显示出来的,之前用 JDK8 只有错误一行~ 小伙伴们可以自己尝试下


image-20210511205557300


解决办法

咳咳,那这个 错误 怎么解决呢 ?


其实这个也是最原始的解决办法 哈哈


可以在上面 IDEA 中反编译出来的代码看到我们这个 JUnitStarter 是属于


package com.intellij.rt.junit; 包的 。


那么我们正确的运行方式就是跑到 com同级目录下去运行 ,如下~


image-20210511212006742


注意这里运行时要带上包名(先不带上那三个参数试试~)


java com.intellij.rt.junit.JUnitStarter
复制代码

可以看到这里已经出现了 !!! JUnit version 3.8 or later expected


也就是我们文章最开始的那段异常信息了!


后面手动将需要的包放到这个目录下,也可以正常运行啦~


其他小实验和感悟就写在下面的总结里啦~


总结


一. 单元测试的命名要规范


二. 不要引入不同版本的单元测试包


如果项目中使用到这个 Junit5 ,此时又直接根据上面 !!! JUnit version 3.8 or later expected 这个异常,引入 Junit4 , 会出现新的异常


java.lang.Exception: No runnable methods ,此时需要你将 @Test 注解修改为 junit4 的版本~ 🐷


三. 扩展包解惑


比如我在 pom 文件中引入了这个 spring-boot-starter-test ,此时它会帮我导入相应版本junit 包 ,而我也不知道它引入了什么版本的测试包,这时可以在 IDEA 的扩展包中搜索,就可以查找到 junit 的版本了


image-20210512082317501


四. junit3 是使用继承的方式, Junit4 开始才使用注解的形式


所以,如果你想试试继承的写法的话✍,可以试试 哈哈


五. 单元测试很重要,主要是为了证明你的逻辑在这个测试范围是对的😝


作者:4ye酱
链接:https://juejin.cn/post/6961264708749885453
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

教你如何修改他人网络请求数据!!!

# Http 请求dns 劫持 解决方案: HttpDns 服务器接入 「阿里云 收费 腾讯HttpDns 服务器免费(接入方案 七牛云 sdk)」 OkHttp HttpDns + 证书验证 # OkHttp HttpDns + 证书验证 Ok...
继续阅读 »

# Http 请求dns 劫持


解决方案:




  • HttpDns 服务器接入 「阿里云 收费 腾讯HttpDns 服务器免费(接入方案 七牛云 sdk)」




  • OkHttp HttpDns + 证书验证




# OkHttp HttpDns + 证书验证



OkHttp 是一个处理网络请求的开源项目,是 Android 端最火热的轻量级网络框架。在 OkHttp 中,默认是使用系统的 DNS 服务 InetAddress 进行域名解析



而想在 OkHttp 中使用 HTTPDNS,有两种方式。



  • 通过拦截器,在发送请求之前,将域名替换为 IP 地址。

  • 通过 OkHttp 提供的 .dns() 接口,配置 HTTPDNS。



对这两种方法来说,当然是推荐使用标准 API 来实现了。拦截器的方式,也建议有所了解,实现很简单,但是有坑。



# OkHttp 拦截器接入方式



拦截器是 OkHttp 中,非常强大的一种机制,它可以在请求和响应之间,做一些我们的定制操作。



在 OkHttp 中,可以通过实现 Interceptor 接口,来定制一个拦截器。使用时,只需要在 OkHttpClient.Builder 中,调用 addInterceptor() 方法来注册此拦截器即可。


class HTTPDNSInterceptor : Interceptor{
override fun intercept(chain: Interceptor.Chain): Response {
val originRequest = chain.request()
val httpUrl = originRequest.url()

val url = httpUrl.toString()
val host = httpUrl.host()

val hostIP = HttpDNS.getIpByHost(host)
val builder = originRequest.newBuilder()

if(hostIP!=null){
builder.url(HttpDNS.getIpUrl(url,host,hostIP))
builder.header("host",hostIP)
}
val newRequest = builder.build()
val newResponse = chain.proceed(newRequest)
return newResponse
}
}
复制代码

在拦截器中,使用 HttpDNS 这个帮助类,通过 getIpByHost() 将 Host 转为对应的 IP。


如果通过抓包工具抓包,你会发现,原本的类似 http://www.cxmydev.com/api/user 的请求,被替换为:http://220.181.57.xxx/api/user


拦截器接入的坏处:


使用拦截器,直接绕过了 DNS 的步骤,在请求发送前,将 Host 替换为对应的 IP 地址。


这种方案,在流程上很清晰,没有任何技术性的问题。但是这种方案存在一些问题,例如:HTTPS 下 IP 直连的证书问题、代理的问题、Cookie 的问题等等。


其中最严重的问题是,此方案(拦截器+HTTPDNS)遇到 https 时,如果存在一台服务器支持多个域名,可能导致证书无法匹配的问题。


在说到这个问题之前,就要先了解一下 HTTPS 和 SNI。


HTTPS 是为了保证安全的,在发送 HTTPS 请求之前,首先要进行 SSL/TLS 握手,握手的大致流程如下:



  1. 客户端发起握手请求,携带随机数、支持算法列表等参数。

  2. 服务端根据请求,选择合适的算法,下发公钥证书和随机数。

  3. 客户端对服务端证书,进行校验,并发送随机数信息,该信息使用公钥加密。

  4. 服务端通过私钥获取随机数信息。

  5. 双方根据以上交互的信息,生成 Session Ticket,用作该连接后续数据传输的加密密钥。


在这个流程中,客户端需要验证服务器下发的证书。首先通过本地保存的根证书解开证书链,确认证书可信任,然后客户端还需要检查证书的 domain 域和扩展域,看看是否包含本次请求的 HOST。


在这一步就出现了问题,当使用拦截器时,请求的 URL 中,HOST 会被替换成 HTTPDNS 解析出来的 IP。当服务器存在多域名和证书的情况下,服务器在建立 SSL/TLS 握手时,无法区分到底应该返回那个证书,此时的策略可能返回默认证书或者不返回,这就有可能导致客户端在证书验证 domain 时,出现不匹配的情况,最终导致 SSL/TLS 握手失败。


这就引发出来 SNI 方案,SNI(Server Name Indication)是为了解决一个服务器使用多个域名和证书的 SSL/TLS 扩展。


SNI 的工作原理,在连接到服务器建立 SSL 连接之前,先发送要访问站点的域名(hostname),服务器根据这个域名返回正确的证书。现在,大部分操作系统和浏览器,都已经很好的支持 SNI 扩展。


3. 拦截器 + HTTPDNS 的解决方案


这个问题,其实也有解决方案,这里简单介绍一下。


针对 "domain 不匹配" 的问题,可以通过 hook 证书验证过程中的第二步,将 IP 直接替换成原来的域名,再执行证书验证。


而 HttpURLConnect,提供了一个 HostnameVerifier 接口,实现它即可完成替换。


public interface HostnameVerifier {
public boolean verify(String hostname, SSLSession session);
}
复制代码

如果使用 OkHttp,可以参考 OkHostnameVerifier (source://src/main/java/okhttp3/internal/tls/OkHostnameVerifier.java) 的实现,进行替换。


本身 OkHttp 就不建议通过拦截器去做 HTTPDNS 的支持,所以这里就不展开讨论了,这里只提出解决的思路,有兴趣可以研究研究源码


# OkHttp 标准 Api 接入



OkHttp 其实本身已经暴露了一个 Dns 接口,默认的实现是使用系统的 InetAddress 类,发送 UDP 请求进行 DNS 解析



我们只需要实现 OkHttp 的 Dns 接口,即可获得 HTTPDNS 的支持。


在我们实现的 Dns 接口实现类中,解析 DNS 的方式,换成 HTTPDNS,将解析结果返回。


class HttpDns : Dns {
override fun lookup(hostname: String): List<InetAddress> {
val ip = HttpDnsHelper.getIpByHost(hostname)
if (!TextUtils.isEmpty(ip)) {
//返回自己解析的地址列表
return InetAddress.getAllByName(ip).toList()
} else {
// 解析失败,使用系统解析
return Dns.SYSTEM.lookup(hostname)
}
}
}
复制代码

使用也非常的简单,在 OkHttp.build() 时,通过 dns() 方法配置。


mOkHttpClient = httpBuilder
.dns(HttpDns())
.build();
复制代码

这样做的好处在于:




  • 还是用域名进行访问,只是底层 DNS 解析换成了 HTTPDNS,以确保解析的 IP 地址符合预期。




  • HTTPS 下的问题也得到解决,证书依然使用域名进行校验。




OkHttp 既然暴露出 dns 接口,我们就尽量使用它。


# WebView loadUrl() dns 劫持


Android Webview场景下防止dns劫持的探索


解决方案:




  • HttpDns




  • webViewClient 配置




  • 腾讯 x5 引擎 x5WebView 自带防劫持




# webView webViewClient


void setWebViewClient(WebViewClient client)
复制代码

@SuppressLint("NewApi")
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {

final String scheme = request.getUrl().getScheme().trim();
final String url = request.getUrl().toString();
final Map<String, String> headerFields = request.getRequestHeaders();

// #1 只拦截get方法
if (request.getMethod().equalsIgnoreCase("get") && (scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) {
try {
final URL oldUrl = new URL(url);
HttpURLConnection conn;

// #2 通过httpdns替换ip
final String ip = mService.getIpByHostAsync(oldUrl.getHost());
if (!TextUtils.isEmpty(ip)) {
final String host = oldUrl.getHost();
final String newUrl = url.replaceFirst(host, ip);

// #3 设置HTTP请求头Host域
conn = (HttpURLConnection) new URL(newUrl).openConnection();
conn.setRequestProperty("Host", host);

// #4 设置HTTP请求header
for (String header : headerFields.keySet()) {
conn.setRequestProperty(header, headerFields.get(header));
}

// #5 处理https场景
if (conn instanceof HttpsURLConnection) {
((HttpsURLConnection) conn).setHostnameVerifier(new HostnameVerifier() {
@Override
public boolean verify(String hostname, SSLSession session) {
return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session);
}
});
}

// #6 拿到MINE和encoding
final String contentType = conn.getContentType();
final String mine = getMine(contentType);
final String encoding = getEncoding(contentType);

// #7 MINE和encoding拿不到的情况下,不拦截
if (TextUtils.isEmpty(mine) || TextUtils.isEmpty(encoding)) {
return super.shouldInterceptRequest(view, request);
}

return new WebResourceResponse(mine, encoding, conn.getInputStream());
}
} catch (Exception e) {
e.printStackTrace();
}
}

return super.shouldInterceptRequest(view, request);
}
复制代码


作者:飞曼巴
链接:https://juejin.cn/post/6961198849327104036
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

想知道手指触摸屏幕的时候发生了什么吗?

1 前言 滑动对于android来说,是一个必不可少;它不复杂,大家都知道在onTouchEvent中,让它滑动就完事了,说它复杂,其嵌套处理复杂;在本系列文章,最终是为了熟悉嵌套滑动机制;对于滑动,分为下面几篇文章来完成解读: 滑动基础 ScrollVie...
继续阅读 »

1 前言


滑动对于android来说,是一个必不可少;它不复杂,大家都知道在onTouchEvent中,让它滑动就完事了,说它复杂,其嵌套处理复杂;在本系列文章,最终是为了熟悉嵌套滑动机制;对于滑动,分为下面几篇文章来完成解读:



  1. 滑动基础

  2. ScrollView滑动源码解读

  3. NestedScrollView嵌套滑动源码解读

  4. CoordinatorLayout-AppBarLayout-CollapsingToolbarLayout复杂滑动逻辑源码解读


在本章内,主要介绍实现的一些相关基础框架逻辑



  1. 平滑处理、滑翔处理

  2. View中对滑动的处理效果以及逻辑

  3. androidx中的滑动接口、嵌套滑动接口的理解


看到这里,你不再觉得仅仅是在OnTouchEvent中处理滑动事件吧,其实这样想也可以,不过效果什么的全自定义了


2 滑动常量


介绍滑动前,我们需要了解一些滑动常量,这些常量有利于我们实现更流畅的滑动效果


这些常量都是通过ViewConfiguration来获取的,其实例通过下面来获取


ViewConfiguration configuration = ViewConfiguration.get(mContext)
复制代码


  • 最小滑动距离:getScaledTouchSlop()

  • 最小滑翔速度:getScaledMinimumFlingVelocity(),像素每秒

  • 最大滑翔速度:getScaledMaximumFlingVelocity(),像素每秒

  • 手指滑动越界最大距离:getScaledOverscrollDistance()

  • 滑翔越界最大距离:getScaledOverflingDistance()


这里滑翔的速度,是为了处理惯性的快慢,这个做过的深有体会,总是感觉,快慢不是很舒服;所以我们一一般在滑翔时,获取滑翔距离时,要在最大和最小之间;


3 平滑滑动、滑翔


平滑滑动根据时间进行平缓的滑动,而滑翔需要对移动事件进行跟踪分析之后,再根据时间计算状态进而进行分析;而根据时间进行状态处理,使用Scroller或者OverScroller来处理,OverScroller可以处理回弹效果;对事件跟踪分析,使用VelocityTracker类处理


3.1 VelocityTracker类


这个类有下面用法


实例获取


mVelocityTracker = VelocityTracker.obtain()
复制代码

跟踪事件


mVelocityTracker.addMovement(ev)
复制代码

获取滑翔初始速度


mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int xVelocity = (int)mVelocityTracker.getXVelocity(mActivePointerId);
int yVelocity = (int)mVelocityTracker.getYVelocity(mActivePointerId);
复制代码

两个方法要跟着使用,减少误差;另外计算时;computeCurrentVelocity参数意义



  1. 多少毫秒,假设n

  2. 速度,单位由参数1的数值来确定,也即是像素每n毫秒


数据临时清理


mVelocityTracker.clear();
复制代码

当切换手指时,之前的数据就没有意义了,所以需要清理重新计算


对象回收


mVelocityTracker.recycle();
复制代码

3.2 OverScroller类


Scroller也可以处理,只是不能处理回弹而已;这里就只是解释OverScroller类,它仅仅只是一个状态计算的类,对view并没有进行操作;下面就是一些使用


初始化


mScroller = new OverScroller(getContext());
复制代码

滑动


public void startScroll(int x, int y, int dx, int dy, int duration)
复制代码

单位时间内,x增加dx,y增加dy;默认时间250ms


计算


public boolean computeScrollOffset()
复制代码

计算当前时间对应状态,返回true表示,仍在进行,可通过下面获取当前状态



  • getCurrX():当前x位置

  • getCurrY(): 当前y位置

  • getCurrVelocity():当前速度


回弹


public boolean springBack(int startX, int startY, int minX, int maxX, int minY, int maxY)
复制代码


  • 当前x值

  • 当前y值

  • x最小值

  • y最小值

  • x最大值

  • y最大值


如果运用在滑动中,则表示已滑动距离,滑动的最小距离,滑动的最大距离;


滑翔


fling(int startX, int startY, int velocityX, int velocityY,int minX, int maxX, int minY, int maxY, int overX, int overY)
复制代码


  • 当前x位置

  • 当前y位置

  • 当前x速度,像素每秒

  • 当前y速度,像素每秒

  • x最小取值

  • y最小取值

  • x最大取值

  • y最大取值

  • x最大越界距离

  • y最大越界距离


有越界范围才有回弹效果


丢弃


mScroller.abortAnimation();
复制代码

完成判断


mScroller.isFinished()
复制代码

3.3 平滑移动


这个只需要调用OverScroller的startScroll方法进行触发,在View的computeScroll方法获取滑动状态调用scrollTo方法即可;


3.4 滑翔


滑翔就分为两种情况了



  1. 在手指离开时,未越界,则进行滑翔,如果可以回弹,也会进行回弹,调用OverScroller的fling方法

  2. 在手指离开时,已经越界,则进行回弹,调用OverScroller的springBack方法


同样需要在computeScroll根据计算状态,进行具体滑动


4 View类


View类中对于滑动,提供了滑动执行机制、滑动时指示条、滑动时fade蒙层、长按事件处理还有滑动的一些数据判断,这些和androidx中滑动接口ScrollingView


4.1 滑动具体执行


具体执行是通过View的变量mScrollX、mScrollY来完成的,这两个变量在绘制的时候,会对画布进行平移(详见View类中draw方法被调用的地方),进而导致其内绘制内容发生了变化;这个平移对当前view的背景并没有影响,由于在处理背景时再次进行了反方向平移(详见View类中drawBackground方法);而对这两个变量的操作方法有



  • scrollTo(int x, int y):移动到x、y

  • scrollBy(int x, int y):移动范围增加x、y

  • overScrollBy方法,此方法会自动处理越界时的处理,并调用onOverScrolled进行实际的移动处理


我称这两个方法为执行者;但是很多滑动控件中都有平滑移动,平滑移动基本都是利用OverScroller或Scroller的滑动方法来完成的;需要回弹用OverScroller,否则使用Scroller即可


protected boolean overScrollBy(int deltaX, int deltaY,
int scrollX, int scrollY,
int scrollRangeX, int scrollRangeY,
int maxOverScrollX, int maxOverScrollY,
boolean isTouchEvent)
复制代码

overScrollBy方法,返回结果,true标识越界了需要回弹,参数意思如下:



  1. x增量值

  2. y增量值

  3. x当前移动值

  4. y当前移动值

  5. x当前最大值

  6. y当前最大值

  7. x最大回弹值

  8. y最大回弹值

  9. 是手指移动还是滑翔


protected void onOverScrolled(int scrollX, int scrollY,
boolean clampedX, boolean clampedY)
复制代码

onOverScrolled方法,参数意义如下:



  1. 当前x滑动

  2. 当前y滑动

  3. x是否越界,true表示越界了

  4. y是否越界,true表示越界


4.2 长按事件


源码见View类中onTouchEvent方法、isInScrollingContainer方法


长按事件有一定的规则:



  • 是在down事件中进行触发发送延时回调长按处理任务,回调执行并不一定需要手指抬起

  • 在cancel、move、up事件中取消的


而对于普通非滑动容器内的view,长按事件的延迟时间为ViewConfiguration.getLongPressTimeout();而如果是滑动容器中,此时会再次触发发送一个触发长按的延期任务,这个延时为ViewConfiguration.getTapTimeout();我觉得这是考虑到滑动的特殊性增加一点时间,可以更精准的判断是否为长按事件;


是否滑动容器的判断方法,是由ViewGroup的shouldDelayChildPressedState方法来处理的;也就是滑动容器中此方法需要返回true


4.3 fade蒙层


源码详见View.draw方法,绘制分为两种情况,是根据mViewFlags标志来判断的;也即是否需要绘制水平的fade蒙层或者竖直的蒙层;


这个标志可以进行设置,两种方法改变,默认是none



  • xml中参数配置


android:requiresFadingEdge="horizontal|vertical|none"
复制代码


  • 代码设置


setHorizontalFadingEdgeEnabled(true);
setVerticalFadingEdgeEnabled(true);
复制代码

并不是这个设置了,水平或者竖直,这些地方就以一定出现蒙层,还有其它限制,蒙层分为4个,这四个方法,逻辑是一致的,方法略有区别;


蒙层有一个高度设置,同样有两种方法改变,默认是ViewConfiguration.getScaledFadingEdgeLength()



  • xml设置


android:fadingEdgeLength="16dp"
复制代码


  • 通过方法设置


setFadingEdgeLength(int length)
复制代码

具体绘制的高度基本是这个高度,除非高度超过了控件本身高度,其变控件高度的一半


蒙层还有一个每个边缘的参数比例,这个在0-1之间;返回的值不在区间会被忽略掉;方法默认实现如下:


    protected float getTopFadingEdgeStrength() {
return computeVerticalScrollOffset() > 0 ? 1.0f : 0.0f;
}

protected float getBottomFadingEdgeStrength() {
return computeVerticalScrollOffset() + computeVerticalScrollExtent() <
computeVerticalScrollRange() ? 1.0f : 0.0f;
}

protected float getLeftFadingEdgeStrength() {
return computeHorizontalScrollOffset() > 0 ? 1.0f : 0.0f;
}

protected float getRightFadingEdgeStrength() {
return computeHorizontalScrollOffset() + computeHorizontalScrollExtent() <
computeHorizontalScrollRange() ? 1.0f : 0.0f;
}
复制代码

那第二个条件就是:蒙层的比例 * 蒙层高度 > 1.0f 则这个位置边缘会绘制处理


蒙层是一个矩形的线性渐变蒙层,通过线性shade来处理的;渐变是从颜色的完全不透明到完全透明


shader = new LinearGradient(0, 0, 0, 1, color | 0xFF000000, color & 0x00FFFFFF, Shader.TileMode.CLAMP)
复制代码

这个颜色可以通过重写下面方法进行改变,默认是黑色


    public int getSolidColor() {
return 0;
}
复制代码

其实这个蒙层在android所有标准控件中,只有时间的控件直接采用了,其它的保留了特性;而且从系统的默认实现来看,这个就是为滑动实现的


4.4 滚动条


由两部分组成,一个是Track(滑道),一个是Thumb(滑块);滑道可以认为是可以滑动整体,固定的,而滑块只是其中一部分,位置可变动;


有显示和隐藏控制,源码见awakenScrollBars()、onDrawScrollBars方法;


4.4.1 显示


显示受参数控制,即显示位置,且显示位置方向是可以滑动的才可以显示;有两种方式



  1. xml中设置


android:scrollbars="vertical|horizontal"
复制代码


  1. 代码设置


public void setHorizontalScrollBarEnabled(boolean horizontalScrollBarEnabled)
public void setVerticalScrollBarEnabled(boolean verticalScrollBarEnabled)
复制代码

4.4.2 隐藏


受参数控制,可以通过xml布局中配置,也可以设置;默认为true,如下


android:fadeScrollbars="true"

public void setScrollbarFadingEnabled(boolean fadeScrollbars)
复制代码

淡出效果,是在显示操作后提交的延迟操作,延时时长,默认为ViewConfiguration.getScrollDefaultDelay(),可以通过两种方式改变



  • onDrawScrollBars方法调用不传递时间时,xml中配置可改变


android:scrollbarDefaultDelayBeforeFade="10"
复制代码


  • 代码中onDrawScrollBars传递时间控制


淡出效果为alpha变换,时长默认为ViewConfiguration.getScrollBarFadeDuration();同样可以通过两种方法改变



  1. xml配置


android:scrollbarFadeDuration="1000"
复制代码


  1. 方法设置


public void setScrollBarFadeDuration(int scrollBarFadeDuration)
复制代码

4.4.3 样式控制


样式也有两种形式的控制



  1. 圆形屏幕设备:主要针对是android手表等设备,这个我看不了效果,就不说它的显示控制了

  2. 其它设备:绘制的是ScrollBarDrawable图片类型


ScrollBarDrawable是个不对开发者公开的类,那么这里我们只介绍下其属性



  • android:scrollbarSize: 竖直时宽度,水平时高度

  • scrollbarThumbHorizontal/scrollbarThumbVertical:滑块颜色

  • scrollbarTrackVertical/scrollbarTrackHorizonta:滑道颜色

  • scrollbarStyle:滑块样式,默认值insideOverlay,还有三个值insideInset,outsideOverlay,outsideInset;insideXXX不考虑padding,也就是会覆盖在padding上,而outside不考虑margin,会覆盖在margin上


4.5 指示条


源码见onDrawScrollIndicators方法


其是否可见,由3个方面控制



  1. 指示条显示位置不为none;两种方法设置,xml和代码


android:scrollIndicators="none"

public void setScrollIndicators(@ScrollIndicators int indicators[, @ScrollIndicators int mask])
复制代码


  1. 指示条显示位置相应方向可滑动;top:向上滑动,bottom-向下滑动,left向左滑动,right-向右滑动


左右滑动判断;参数为负,表示左,正为右


  public boolean canScrollHorizontally(int direction) {
final int offset = computeHorizontalScrollOffset();
final int range = computeHorizontalScrollRange() - computeHorizontalScrollExtent();
if (range == 0) return false;
if (direction < 0) {
return offset > 0;
} else {
return offset < range - 1;
}
}
复制代码

上下滑动判断;参数为父表示上,为正表示下


  public boolean canScrollVertically(int direction) {
final int offset = computeVerticalScrollOffset();
final int range = computeVerticalScrollRange() - computeVerticalScrollExtent();
if (range == 0) return false;
if (direction < 0) {
return offset > 0;
} else {
return offset < range - 1;
}
}
复制代码

指示条图标,为R.drawable.scroll_indicator_material,不可改变;这是我查找到的图片情况:


<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:tint="?attr/colorForeground">
<solid android:color="#1f000000" />
<size
android:height="1dp"
android:width="1dp" />
</shape>
复制代码

指示条位置:以所在位置为其一边,垂直的两边,以及位置[-|+]为图片另外一边


唯一用途,指示你此时可以往哪个方向滑动;我觉得很不实用


4.6 回弹


默认是可以回弹,但是未进行回弹效果处理;对于回弹的开启关闭,可以通过两种方式



  1. xml中处理


android:overScrollMode="always"
复制代码


  1. 代码设置


public void setOverScrollMode(int overScrollMode)
复制代码

通过下面方法可以获取,为OVER_SCROLL_NEVER时不可回弹


public int getOverScrollMode()
复制代码

回弹长度,按照 2章节中 获取的相应常量设置即可


回弹效果,可以在手指移动、滑翔两个过程中出现;需要通过上述方法判断,进行进行处理;系统提供了默认的回弹效果类EdgeEffect;下面介绍下此类运用


EdgeEffect类


EdgeEffect mEdgeGlowTop = new EdgeEffect(getContext());  // 实例化
mEdgeGlowTop.setColor(color); // 改变回弹颜色
mEdgeGlowTop.onPull(deltaDistance, displacement) // 回弹参数,均为0-1,变化距离以及位置比例
mEdgeGlowTop.isFinished() // 状态判断
mEdgeGlowTop.onRelease() // 释放
mEdgeGlowTop.onAbsorb(velocity) // 放弃

mEdgeGlowTop.setSize(width, height) // 设置绘制的矩形范围,上面onPull传的参数比例,就是依据这个来绘制回弹的图形的
mEdgeGlowTop.draw(canvas) // 绘制,结果表示是否还需要继续处理
复制代码

需要特殊说明的是,这个类绘制的时候,默认绘制方向,以当前视图左上角为起点进行绘制的;所以要onPull的参数传递以及绘制时,要考虑坐标以及旋转的关系,进而达到正确的效果


4.7 嵌套滑动启动关闭配置


这个可以通过xml配置,或者代码设置


android:nestedScrollingEnabled="true"

public void setNestedScrollingEnabled(boolean enabled)
public boolean isNestedScrollingEnabled()
复制代码

4.8 测量


这里就是重写onMeasure方法,有两种情况



  1. 继承ViewGroup;需要完全自己重写逻辑

  2. 继承ViewGroup子类;可以依赖父类的测量逻辑,在其测量关键方法重写,也可以先进行父类测量


这两种情况都需要对子布局测量传递不限制模式MeasureSpec.UNSPECIFIED,以达到有滑动距离的可能


更具体的逻辑就需要自己来操作;不过在操作的时候,需要特殊注意一个对象,那就是ViewGroup.LayoutParams,也就是容器的布局参数,这个类是容器规定了一些功能,也是子view通过属性来通知父容器的一种重要途径


5 ScrollingView接口


如果你能理解上面的内容,那么这个接口方法就比较好理解了



  • computeHorizontalScrollRange()/computeVerticalScrollRange():相应方向滑动范围,[0, 此方法结果]

  • computeHorizontalScrollOffset()/computeVerticalScrollOffset():相应方向已滑动的距离

  • computeHorizontalScrollExtent()/computeVerticalScrollExtent():滑道的长度,也即容器的宽度或者高度


这些方法,都是进行滑动判断、fade蒙版、指示条、滑动条用到的核心方法;如果不实现,就无法拥有View已实现的效果,并且相应方法肯定是不可用了,比如:



  • 是否可滑动判断:canScrollHorizontally,canScrollVertically

  • 滚动条隐藏:awakenScrollBars


6 嵌套接口


接口也分为子视图方法和父容器方法;子视图方法用来通知父容器进行处理的,而父容器方法是高速子滑动视图其是否去处理以及处理的结果状态;


6.1 NestedScrollingParent3接口


其继承NestedScrollingParent2,NestedScrollingParent2又继承了NestedScrollingParent;方法如下



  1. onStartNestedScroll方法:父容器是否需要处理子view的滑动事件,true表示接受处理

  2. onNestedScrollAccepted方法:接受子视图的滑动事件询问

  3. onStopNestedScroll方法:得知子视图停止滑动时的通知

  4. onNestedScroll方法:子view已经处理滑动后,父容器进行滑动处理

  5. onNestedPreScroll方法:子view处理滑动前,父容器进行滑动处理

  6. onNestedFling方法:子view需要滑翔时,子view处理,父view进行处理

  7. onNestedPreFling方法:子view需要滑翔时,父view进行处理;返回结果表示是否处理

  8. getNestedScrollAxes方法:当前父容器在子view滑动时,处理滑动的维度


需要注意的是,嵌套时,手指滑动是可接力完成的,而滑翔一定是互斥完成的


其中涉及一下参数,说明如下:



  1. type:表示滑动或者滑翔,ViewCompat.TYPE_TOUCH滑动,ViewCompat.TYPE_NONE_TOUCH滑动

  2. consumed:包含x、y两个方向的数组;一般为输出变量,表明当前处理时,消费了多少

  3. dxConsumed/dyConsumed:表明传递到父容器时,子视图已经消耗了多少滑动距离

  4. dxUnconsumed/dyUnconsumed:表明传递到父容器时,还有多少滑动距离待消耗

  5. target:表明从那个子view传递而来

  6. dx/dy:此次事件滑动的距离

  7. child:包含target的,当前容器的直接子容器

  8. axes:滑动的方向,ViewCompat.SCROLL_AXIS_HORIZONTAL,ViewCompat.SCROLL_AXIS_VERTICAL两个值

  9. velocityX/velocityY: 滑翔时初始速度


6.2 NestedScrollingChild3


继承了NestedScrollingChild2, NestedScrollingChild2又继承了NestedScrollingChild;方法如下:



  1. setNestedScrollingEnabled/isNestedScrollingEnabled: 嵌套滑动是否支持

  2. startNestedScroll:通知嵌套滑动开始

  3. stopNestedScroll:通知嵌套滑动结束

  4. hasNestedScrollingParent:是否存在嵌套处理的直系长辈容器

  5. dispatchNestedScroll:自己处理后继续通知滑动事件

  6. dispatchNestedPreScroll:自己未处理滑动,通知滑动事件

  7. dispatchNestedFling:自己处理后,通知滑翔事件

  8. dispatchNestedPreFling:优先自己处理,通知滑翔事件


参数就不解释了,和6.1类似


6.3 辅助类


这两章中的方法在View和ViewGroup均有使用,androidx也提供了辅助类进行默认实现,这两个类就是NestedScrollingParentHelper、NestedScrollingChildHelper;这两个类主要是为了解决版本兼容问题


作者:众少成多积小致巨
链接:https://juejin.cn/post/6960876681892462623
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

还不会搭安卓架构?有这一篇就够了

前言用本篇文章理论知识和架构原则实践了一个 wanAndroid 项目,其中全部采用 kotlin 编写并抛弃了 Rxjava,因为 kotlin 可以完全替代他,github 本项目中汇总了业界知名的架构文章和一些项目帮你彻底理解架构。后续本项目将...
继续阅读 »

彻底理解Android架构

前言

用本篇文章理论知识和架构原则实践了一个 wanAndroid 项目,其中全部采用 kotlin 编写并抛弃了 Rxjava,因为 kotlin 可以完全替代他,github 本项目中汇总了业界知名的架构文章和一些项目帮你彻底理解架构。后续本项目将持续更新,并完善 wanAndorid 的所有功能。还会用 23 种设计模式在项目中实践,彻底理解设计模式在业务场景中的使用,欢迎持续关注 github

一、什么是架构

1.1 架构介绍

架构究竟是什么?如何更好的理解架构。我们知道中国文字博大精深可以说从文字的组成就能理解其含义。架构也不例外 “架构” 是由 “架” “构” 组成。

架:建造、搭设、支撑。 简称:整体结构
构:屋宇、供人居住的木、砖瓦构筑物。 简称:组件

整体结构和组件的组合就形成了架构。以 Android 架构为例子一个 APP 通常是有 class(类)组成,而这些 class 之间如何如何组合、相互之间如何发生作用,则是影响这个 APP 本身的关键点。细分的话可以分为类、接口(连接器)、任务流。所谓类就是组成架构的核心 “砖瓦”,而接口则是这些类之间通讯的路径、通讯的机制、通讯的期望结果。任务流则是描述系统如何使用类和接口完成某一项需求比如:一次网络请求。 上面介绍架构中提到了房屋、木头、砖瓦可见架构和建筑有着彼此的联系。

1.2 建筑学

上世纪 60 年代已经设计软件架构这个概念了,到了 90 年代软件架构这个概念才开始流行起来。而计算机的历史开始于上世纪五十年代相比建筑历史就非常短暂了,建筑工程从石器时代就开始了。人类在几千年的建筑设计实践中积累了大量的经验和教训,建筑设计基本上包含两点,一是建筑风格,二是建筑模式。独特的建筑风格和恰当选择的建筑模式,可以使它成为一个独一无二的建筑。

下图的照片显示了古代玛雅建筑:Chichen-Itza,九个巨大的石级堆垒而上,九十一级台阶(象征着四季的天数)夺路而出,塔顶的神殿耸入云天。所有的数字都如日历般严谨,风格雄浑。难以想象这是石器时代的建筑物。

1620390212_副本.png

英国首相丘吉尔说,我们构造建筑物,建筑也构造我们,英国下议院的会议厅较狭窄,无法使所有的下议院议员面向同一个方向入座,而必须分成两侧入座。丘吉尔认为,议员们入座的时候自然会选择与自己政见相同的人同时入座,而这就是英国政党制的起源。

二、架构设计目的

几乎所有的软件设计理念都可以在浩瀚的建筑学历史中找到。许多人认为 “形式必须服从功能”(你认同这种观点吗?欢迎在评论区留下你的看法)。而好的设计既有形式又有功能。比如我们的北京大兴国际机场大兴机场以航站楼为核心向四周延展从空中俯瞰就像是一只展翅欲飞的凤凰,以航站楼核心区为中心,分别向东北、东南、中南、西南、西北五个方向伸出了五条指廊,通往北京大兴国际机场的飞行区。这种从中心向四面八方延伸的设计,使航站楼中心点到最远端登机口的距离只有 600 米左右,旅客步行前往最多只需 8 分钟。

建筑的设计又有一定的目的性,而软件架构设计也同理。软件架构目的性大致可分为可扩展性、可定制化、可伸缩、可维护性:

1. 可扩展性: APP 必须能够在用户的 UV/PV 数量快速增加的情况下,保持软件合理的性能。只有这样在快速的从 0 到 1 的需求迭代中才能后顾无忧。

2. 可定制化: 在同一个软件系统中可能面向的用户群体是不同的、多样的,需要满足根据用户群的不同和市场需求的不同进行定制化。比如一个 APP 中某些功能只针对特定用户开放。

3. 可伸缩性: 在新技术出现的时候,一个软件系统应当允许接入新技术,从而对现有系统进行功能和性能的扩展。

4. 可维护性: 软件系统的维护包括两方面,一是修复现有的 bug,二是将新的迭代需求开发到现有系统中去。一个易于维护的系统可以有效地降低人力和物力。

三、实践一个 APP:玩 Android

1620390212_副本_副本.png

针对上面对架构的介绍,相信已经从陌生走向熟悉了。但是最重要的还是实践,伟大的毛主席曾经说过 你要想知道梨子的滋味,就要亲口尝一下。因此借用了 wanAndoird 开放 API 简单实现一个 APP 并概括上述架构的关键点,主要的功能点如下:

  • 首页是热搜文章的分类列表

  • 项目页面主要包括完整项目

  • 文章、项目点击可以查看详情

不知道还有没有印象上文提到了架构 “形式必须服从功能” 当然这不是权威的定义,可以作为参考。我们先不管是形式服从功能还是功能服从形式,可以结构化思维理解下这句话,架构大致可分为:形式、功能所以我们依次按照此两点进行搭建 wanAndroid 项目。

3.1 架构 - 形式

从形式本身而言包括两部分。一是事物外在的形状,二是内在的结构、组合方式。实际上,这两者为同一。内容如何内在组合,对外就自然有某种表现的形状。

我们打开项目的第一眼接触到和看到的就是我们项目的目录结构,更清晰更简洁的目录结构可以使我们更快的上手项目。这里主要分为两部分核心模块、业务功能模块:

核心模块主要有以下职责:

  • Dagger 依赖注入处理。
  • 扩展功能:各种 utils。
  • 基础层的抽象:BaseActivity、BaseViewModel 等
  • 第三库处理、网络异常处理等

业务功能模块主要有以下好处:

  • 高内聚性
  • 清晰的功能结构
  • 模块化
  • 功能隔离并封装

在主 APP 下进行了 core、features 的划分,业务模块并没有按照模块化的形式进行多 moudle 拆分而是聚合在 features 下,以包的形式进行了聚合,这样做的好处如下:

  • 更快的编译速度
  • 减少 maven 库的依赖冲突
  • 通用功能的重用性
  • 包的内聚力

可以看到我们并没有采用按照业务 module 进行模块化划分,因为我之前接触过一个项目拆分了 40 多个 module 可想而知项目一旦庞大起来坏处也就是暴露出来:

  • 编译一次项目高达 7/8 分钟,编译速度优化可以看我之前的文章(编译速度优化)
  • 项目中的 moudle 依赖纵横交错

当然我并不反对多 module 模块化的存在,因为任何模式都有利有弊,这取决于当前的项目的业务来抉择使用那种形式。此外项目中全部采用 kotlin 编写:

  • build.gradle.kts .kts 也是官方推崇的可以使 gradle 更加简化
  • buildSrc来处理 gradle 依赖

3.2 架构 - 功能

在玩 Android 中的业务点功能点主要有文章、项目获取,而这些功能点大部分都离不开网络请求和回调处理。这里不再描述 MVC、MVP、MVVM 的区别和如何选择,但是我可以说明一点是任何架构模式都没有最好、最优,只有最适合当前业务的才是好架构。现在 google 官方推崇的架构主要是 MVVM 所有我们主要说下 MVVM。更详细的可以查看官网文档 应用架构指南

MVVM 架构模式满足上文我们描述符合的架构设计的目的,同时也准守了官方给定的架构原则,架构原则大致有两点如下。可能光看这两个定义可能不太容易理解。所有我们用结构化思维的方式理解下,关注点分离就是将复杂问题做合理的分解,再研究分解的侧面,最后合成整体的解决方案。因此我们在 Activity 或 Fragment 不应该做业务逻辑而是把功能点拆分成需要最小的最优解,最后合并成整体方案。比如 mvvm 我们衍生出 ViewModel、LiveData、Model 等。

  1. 关注点分离 Activity 或 Fragment 中的代码应是处理界面和操作系统交互的逻辑应使这些类尽可能保持精简,这样可以避免许多与生命周期相关的问题。
  2. 通过模型驱动界面 模型是负责处理应用数据的组件。它们独立于应用中的 View 对象和应用组件,因此不受应用的生命周期以及相关的关注点的影响

MVVM 中每个组件仅依赖于其下一级的组件如:activity-->viewMoudle-->Repository。这时候你可能有疑惑,如果是单向依赖那网络请求的回调怎么处理?这里引出一个概念 “响应式编程” 结合 liveData 做处理其内部是观察者模式,并且关联视图的声明周期如:Activity、Fragment 或 Service。使用 LiveData 的好处如下:

  1. 不会发生内存泄漏 观察者会绑定到 Lifecycle 对象,并在其关联的生命周期遭到销毁后进行自我清理。
  2. 不会因 Activity 停止而导致崩溃 如果观察者的生命周期处于非活跃状态(如返回栈中的 Activity),则它不会接收任何 LiveData 事件。
  3. 不再需要手动处理生命周期 界面组件只是观察相关数据,不会停止或恢复观察。LiveData 将自动管理所有这些操作,因为它在观察时可以感知相关的生命周期状态变化。

3.3 UseCase

UseCase 是 Clean 架构中的一个概念,其中主要用于 UI 和数据层的连接同时也会进行 IO 的切换,这里可以看到本项目抛弃了 Rxjava 因为他完全可以用 Kotlin 来替代。

abstract class UseCase<out Type, in Params> where Type : Any {

abstract suspend fun run(params: Params): Either<Failure, Type>{

operator fun invoke(params: Params, onResult: (Either<Failure, Type>) -> Unit = {}) {
val job = GlobalScope.async(Dispatchers.IO) { run(params) }
GlobalScope.launch(Dispatchers.Main) { onResult(job.await()) }
}

class None
}
复制代码
复制代码

3.4 一个完整网络请求流程


  • View:一个网络请求的发送并订阅,处理 UI 数据。
  • ViewModel:为 View(Activity/Fragment) 提供数据,并处理业务逻辑。
  • LiveData:具有生命周期可观察的数据存储器类,LiveData 存储在 ViewModel 中
  • UseCases:用于连接 ViewModel 和 Model,并更新 LiveData。
  • Model:可以从网络、数据库或其他 API 获取数据

四、总结

我们可以体会到从架构理论定义到实践的过程相信你有了自己的理解和见解,但这只是一种实现方式,如果在满足架构设计目的和架构原则的情况下你有更好的实践方式或者有任何和架构项目的疑问点都可迎在评论区或者 Github 中留言讨论。这里我也有个疑问点就你认同形式必需服从功能?欢迎留下你的见解。

后续本项目将持续更新,并完善 wanAndorid 的所有功能。还会用 23 种设计模式在项目中实践,彻底理解设计模式在业务场景中的使用,欢迎持续关注。当其他的平台如后端、前端架构的搭建都是殊途同归的。但是我还是有几点建议:

  • 业务决定架构
  • 不要过度设计
  • 面向接口编程
  • 形式需服从功能

收起阅读 »

Android面试:80%的面试官关于Glide都会问这几个问题!【建议收藏】

Glide的三级缓存有了解过么? 先来了解一下我们常说的图片三级缓存 一般是强引用,软引用和文件系统,Android系统中提供了LruCache,通过维护一个LinkedHashMap来保存我们需要的各种类型数据,例如我们这里需要的Bitmap。Lr...
继续阅读 »



Glide的三级缓存有了解过么?



  • 先来了解一下我们常说的图片三级缓存


一般是强引用,软引用和文件系统,Android系统中提供了LruCache,通过维护一个LinkedHashMap来保存我们需要的各种类型数据,例如我们这里需要的Bitmap。LruCache一般我们会设置为系统最大存储空间的八分之一,而它的机制就是我们常说的最近最少使用原则,如果Lru中的图片大小超过了默认大小,则会把最久使用的图片移除。


当图片被Lru移除时,我们需要手动将图片添加到软引用(SoftRefrence)中。需要维护一个软应用的集合在我们的项目中。



  • 简单概括一下常用的三级缓存的流程:


先去Lru中找,有则直接取。
没有,则去SoftRefrence中找,有则取,同时将图片放回Lru中。
没有的话去文件系统找,有则取,同时将图片添加到Lru中。
没有就走下载图片逻辑,保存到文件系统中,并放到Lru中。

下面介绍一下Glide的缓存结构:


Glide缓存严格意义上说只有内存缓存和磁盘缓存,内存缓存中又分为Lru和弱引用缓存。


所以Glide的三级缓存可以分为:Lru缓存,弱引用缓存,磁盘缓存。


下面我们看一下Glide的读取顺序,这里有一点不同,我用的是Glide4.8版本,跟之前版本的写入顺序稍有不同。


截取部分源码:

@NonNull
Glide build(@NonNull Context context) {

if (memoryCache == null) {
memoryCache = new LruResourceCache(memorySizeCalculator.getMemoryCacheSize());
}

if (engine == null) {
engine =
new Engine(
memoryCache,
diskCacheFactory,
diskCacheExecutor,
sourceExecutor,
GlideExecutor.newUnlimitedSourceExecutor(),
GlideExecutor.newAnimationExecutor(),
isActiveResourceRetentionAllowed);
}


  • memoryCache就是Glide使用的内存缓存,LruResourceCache类继承了LruCache,这部分可以自行查看一下源码。


通过上面可以看到,GLide#build()方法中实例化memoryCache作为Glide的内存缓存,并将其传给Engine作为构造器的入参。



  • Engine.class 截取部分源码


{
//生成缓存key
EngineKey key = keyFactory.buildKey(model, signature, width, height, transformations,
resourceClass, transcodeClass, options);
//从弱应用中读取缓存
EngineResource active = loadFromActiveResources(key, isMemoryCacheable);
if (active != null) {
cb.onResourceReady(active, DataSource.MEMORY_CACHE);
if (VERBOSE_IS_LOGGABLE) {
logWithTimeAndKey("Loaded resource from active resources", startTime, key);
}
return null;
}
//从LruCache中读取缓存
EngineResource cached = loadFromCache(key, isMemoryCacheable);
if (cached != null) {
cb.onResourceReady(cached, DataSource.MEMORY_CACHE);
if (VERBOSE_IS_LOGGABLE) {
logWithTimeAndKey("Loaded resource from cache", startTime, key);
}
return null;
}
EngineJob engineJob =
engineJobFactory.build(
key,
isMemoryCacheable,
useUnlimitedSourceExecutorPool,
useAnimationPool,
onlyRetrieveFromCache);
jobs.put(key, engineJob);

engineJob.addCallback(cb);
//开启线程池,加载图片
engineJob.start(decodeJob);
}

从上可知,Glide加载过程中使用loadFromActiveResources方法和loadFromCache方法来获取内存缓存的。


大致总结一下: 首先从弱引用读取缓存,没有的话通过Lru读取,有则取,并且加到弱引用中,如果没有会开启EngineJob进行后面的图片加载逻辑。


下面直接看之后的缓存部分代码:



  • Engine#onEngineJobComplete()


public void onEngineJobComplete(EngineJob engineJob, Key key, EngineResource resource) {
Util.assertMainThread();
// A null resource indicates that the load failed, usually due to an exception.
if (resource != null) {
resource.setResourceListener(key, this);

if (resource.isCacheable()) {
activeResources.activate(key, resource);
}
}

jobs.removeIfCurrent(key, engineJob);
}
void activate(Key key, EngineResource resource) {
ResourceWeakReference toPut =
new ResourceWeakReference(
key,
resource,
getReferenceQueue(),
isActiveResourceRetentionAllowed);

ResourceWeakReference removed = activeEngineResources.put(key, toPut);
if (removed != null) {
removed.reset();
}
}

这里可以看到activeResources.activate(key, resource)把EngineResource放到了弱引用中,至于lru的放置逻辑如下:



  • EngineResource#release()


void release() {
if (acquired <= 0) {
throw new IllegalStateException("Cannot release a recycled or not yet acquired resource");
}
if (!Looper.getMainLooper().equals(Looper.myLooper())) {
throw new IllegalThreadStateException("Must call release on the main thread");
}
if (--acquired == 0) {
listener.onResourceReleased(key, this);
}
}

当acquired变量大于0的时候,说明图片正在使用中,也就应该放到activeResources弱引用缓存当中。而经过release()之后,如果acquired变量等于0了,说明图片已经不再被使用了,那么此时会调用listener的onResourceReleased()方法来释放资源。



  • Engine#onResourceReleased()


@Override
public void onResourceReleased(Key cacheKey, EngineResource resource) {
Util.assertMainThread();
activeResources.deactivate(cacheKey);
if (resource.isCacheable()) {
cache.put(cacheKey, resource);
} else {
resourceRecycler.recycle(resource);
}
}

这里首先会将缓存图片从activeResources中移除,然后再将它put到LruResourceCache当中。这样也就实现了正在使用中的图片使用弱引用来进行缓存,不在使用中的图片使用LruCache来进行缓存的功能。


接下来就是Glide的磁盘缓存,磁盘缓存简单来说就是根据Key去DiskCache中取缓存,有兴趣可以自行看一下源码。


为什么选择Glide不选择其他的图片加载框架?



  • Glide和Picasso


前者要更加省内存,可以按需加载图片,默认为ARGB_565,后者为ARGB_8888。


前者支持Gif,后者并不支持。



  • Glide和Fresco


Fresco低版本有优势,占用部分native内存,但是高版本一样是java内存。


Fresco加载对图片大小有限制,Glide基本没有。


Fresco推荐使用SimpleDraweeView,涉及到布局文件,这就不得不考虑迁移的成本。


Fresco有很多native的实现,想改源码成本要大的多。


Glide提供对中TransFormation帮助处理图片,Fresco并没有。


Glide版本迭代相对较快。


Glide的几个显著的优点:



  • 生命周期的管理


GLide#with


  @NonNull
public static RequestManager with(@NonNull Context context) {
return getRetriever(context).get(context);
}

@NonNull
public static RequestManager with(@NonNull Activity activity) {
return getRetriever(activity).get(activity);
}

@NonNull
public static RequestManager with(@NonNull FragmentActivity activity) {
return getRetriever(activity).get(activity);
}

@NonNull
public static RequestManager with(@NonNull Fragment fragment) {
return getRetriever(fragment.getActivity()).get(fragment);
}

@Deprecated
@NonNull
public static RequestManager with(@NonNull android.app.Fragment fragment) {
return getRetriever(fragment.getActivity()).get(fragment);
}

可以看到有多个重载方法,主要对两类不同的Context进行不同的处理



  • Application Context 图片加载的生命周期和应用程序一样,肯定是我们不推荐的写法。

  • 其余Context,会像当前Activity创建一个隐藏的Fragment,绑定生命周期。


以Activity为例:


 @NonNull
public RequestManager get(@NonNull Activity activity) {
if (Util.isOnBackgroundThread()) {
return get(activity.getApplicationContext());
} else {
//判断是否是销毁状态
assertNotDestroyed(activity);
android.app.FragmentManager fm = activity.getFragmentManager();
//绑定生命周期
return fragmentGet(
activity, fm, /*parentHint=*/ null, isActivityVisible(activity));
}
}

具体看#fragmentGet()


@NonNull
private RequestManager fragmentGet(@NonNull Context context,
@NonNull android.app.FragmentManager fm,
@Nullable android.app.Fragment parentHint,
boolean isParentVisible) {
//这就是绑定的Fragment,RequestManagerFragment
RequestManagerFragment current = getRequestManagerFragment(fm, parentHint, isParentVisible);
RequestManager requestManager = current.getRequestManager();

return requestManager;
}

接着看RequestManagerFragment


public class RequestManagerFragment extends Fragment {
@Override
public void onStart() {
super.onStart();
lifecycle.onStart();
}
@Override
public void onStop() {
super.onStop();
lifecycle.onStop();
}

@Override
public void onDestroy() {
super.onDestroy();
lifecycle.onDestroy();
unregisterFragmentWithRoot();
}

}

关联lifeCycle相应的方法。


简单来说就是通过#with()方法根据穿过来的不同的Context绑定生命周期。



  • Bitmap对象池


Glide提供了一个BitmapPool来保存Bitmap。 简单来说就是当需要加载一个bitmap的时候,会根据图片的参数去池子里找到一个合适的bitmap,如果没有就重新创建。BitMapPool同样是根据Lru算法来工作的。从而提高性能。



  • 高效缓存


缓存相关可以看上文描述,内存和磁盘,磁盘缓存也提供了几种缓存策略。



  1. NONE,表示不缓存任何内容

  2. SOURCE,表示只缓存原始图片

  3. RESULT,表示只缓存转换过后的图片(默认选项)

  4. ALL, 表示既缓存原始图片,也缓存转换过后的图片


文末


好了,今天的文章就到这里,感谢阅读,喜欢的话不要忘了三连。大家的支持和认可,是我分享的最大动力。


对文章有何见解,或者有何技术问题,都可以在评论区一起留言讨论,我会虔诚为你解答。


收起阅读 »

做了这么多年开发,TypedArray你该知道的东西

大家好,我是程序员双木L,后续会发专题类的文章,这是自定义控件的第一篇,之后也会陆续更新相关的文章,欢迎关注。 自定义属性在自定义控件过程中属于比较常见的操作,我们可以回想一下这样的场景:自定义view的过程中,我们需要在不同的情况下设置不同的文字大小,那么...
继续阅读 »




大家好,我是程序员双木L,后续会发专题类的文章,这是自定义控件的第一篇,之后也会陆续更新相关的文章,欢迎关注。


自定义属性在自定义控件过程中属于比较常见的操作,我们可以回想一下这样的场景:自定义view的过程中,我们需要在不同的情况下设置不同的文字大小,那么我们是不是就需要提供对外的方法来设置,这样就比较灵活操作。而我们自定义对外的方法,就是我们自定义的属性啦,那我们来分析一下其原理及作用。


下面我们根据例子来进行分析:


1、首先我们需要在res->values目录下新建attrs.xml文件,该文件就是用来声明属性名及其接受的数据格式的,如下:


<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="view_int" format="integer" />
<attr name="view_str" format="string" />
<attr name="view_bool" format="boolean" />
<attr name="view_color" format="color" />
<attr name="view_ref" format="reference" />
<attr name="view_float" format="float" />
<attr name="view_dim" format="dimension" />
<attr name="view_frac" format="fraction" />

<attr name="view_enum">
<enum name="num_one" value="1" />
<enum name="num_two" value="2" />
<enum name="num_three" value="3" />
<enum name="num_four" value="4" />
</attr>

<attr name="view_flag">
<flag name="top" value="0x1" />
<flag name="left" value="0x2" />
<flag name="right" value="0x3" />
<flag name="bottom" value="0x4" />
</attr>

</resources>

attr名词解析:


name表示属性名,上面的属性名是我自己定义的。


format表示接受的输入格式,format格式集合如下:


color:颜色值;
boolean:布尔值;
dimension:尺寸值,注意,这里如果是dp那就会做像素转换;
float:浮点值;
integer:整型值;
string:字符串;
fraction:百分数;
enum:枚举值;
flag:是自己定义的,就是里面对应了自己的属性值;
reference:指向其它资源;
reference|color:颜色的资源文件;
reference|boolean:布尔值的资源文件.

2、自定义属性的使用,这里我们使用两种方式进行对比解析


最最最原始的使用方式


(1)、自定义文件如下:


public class TestAttrsView extends View {
private final String TAG = "TestAttrsView:";

public TestAttrsView(Context context) {
this(context, null);
}

public TestAttrsView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public TestAttrsView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

//最原始使用方式
for (int i = 0; i < attrs.getAttributeCount(); i++) {
Log.i(TAG, "name:" + attrs.getAttributeName(i) + " value:" + attrs.getAttributeValue(i));
}
}
}

我们可以在TestAttrsView方法的参数AttributeSet是个xml解析工具类,帮助我们从布局的xml里提取属性名和属性值。


(2)、在布局文件xml中的使用


<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">

<com.example.viewdemo.customView.TestAttrsView
android:layout_width="200dp"
android:layout_height="200dp"
app:view_bool="true"
app:view_color="#e5e5e5"
app:view_dim="10px"
app:view_float="5.0"
app:view_frac="100%"
app:view_int="10"
app:view_ref="@dimen/dp_15"
app:view_str="test attrs view" />

</FrameLayout>

这里使用自定义属性需要声明xml的命名空间,其中app是命名空间,用来加在自定义属性前面。


xmlns:app=“http://schemas.android.com/apk/res-auto”
声明xml命名空间,xmlns意思为“xml namespace”.冒号后面是给这个引用起的别名。
schemas是xml文档的两种约束文件其中的一种,规定了xml中有哪些元素(标签)、
元素有哪些属性及各元素的关系,当然从面向对象的角度理解schemas文件可以
认为它是被约束的xml文档的“类”或称为“模板”。


(3)、将属性名与属性值打印结果如下:


在这里插入图片描述


从打印结果我们可以看出,AttributeSet将布局文件xml下的属性全部打印出来了,细心的童鞋可能已经看出来:


xml文件:
app:view_ref="@dimen/dp_15"

打印结果:
name:view_ref value:@2131034213

这个属性我们设置的是一个整数尺寸,可最后打印出来的是资源编号。


那如果我们想要输出我们设置的整数尺寸,需要怎么操作呢?


这个时候就该我们这篇的主角出场了,使用TypedArray方式。



  • 使用TypedArray方式


(1)、这里我们需要将attrs.xml使用“declare-styleable”标签进行改造,如下:


<?xml version="1.0" encoding="utf-8"?>
<resources>

<declare-styleable name="TestStyleable">
<attr name="view_int" format="integer" />
<attr name="view_str" format="string" />
<attr name="view_bool" format="boolean" />
<attr name="view_color" format="color" />
<attr name="view_ref" format="reference" />
<attr name="view_float" format="float" />
<attr name="view_dim" format="dimension" />
<attr name="view_frac" format="fraction" />

<attr name="view_enum">
<enum name="num_one" value="1" />
<enum name="num_two" value="2" />
<enum name="num_three" value="3" />
<enum name="num_four" value="4" />
</attr>

<attr name="view_flag">
<flag name="top" value="0x1" />
<flag name="left" value="0x2" />
<flag name="right" value="0x3" />
<flag name="bottom" value="0x4" />
</attr>
</declare-styleable>

</resources>

从改造后的attrs文件可以看出,我们将属性声明归结到TestStyleable里面,也就意味着这些属性是属于TestStyleable下的。


(2)、属性的解析:


public class TestAttrsView extends View {
private final String TAG = "TestAttrsView:";

public TestAttrsView(Context context) {
this(context, null);
}

public TestAttrsView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}

public TestAttrsView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);

//最原始使用方式
/* for (int i = 0; i < attrs.getAttributeCount(); i++) {
Log.i(TAG, "name:" + attrs.getAttributeName(i) + " value:" + attrs.getAttributeValue(i));
}*/


//使用TypeArray方式
//R.styleable.TestStyleable 指的是想要解析的属性
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TestStyleable);

int integerView = typedArray.getInt(R.styleable.TestStyleable_view_int, 0);
Log.i(TAG, "name:view_int" + " value:" + integerView);

boolean aBooleanView = typedArray.getBoolean(R.styleable.TestStyleable_view_bool, false);
Log.i(TAG, "name:view_bool" + " value:" + aBooleanView);

int colorView = typedArray.getColor(R.styleable.TestStyleable_view_color, Color.WHITE);
Log.i(TAG, "name:view_color" + " value:" + colorView);

String stringView = typedArray.getString(R.styleable.TestStyleable_view_str);
Log.i(TAG, "name:view_str" + " value:" + stringView);

float refView = typedArray.getDimension(R.styleable.TestStyleable_view_ref, 0);
Log.i(TAG, "name:view_ref" + " value:" + refView);

float aFloatView = typedArray.getFloat(R.styleable.TestStyleable_view_float, 0);
Log.i(TAG, "name:view_float" + " value:" + aFloatView);

float dimensionView = typedArray.getDimension(R.styleable.TestStyleable_view_dim, 0);
Log.i(TAG, "name:view_dim" + " value:" + dimensionView);

float fractionView = typedArray.getFraction(R.styleable.TestStyleable_view_frac, 1, 1, 0);
Log.i(TAG, "name:view_frac" + " value:" + fractionView);

//typedArray存放在缓存池,使用完需要释放缓存池
typedArray.recycle();
}
}

这里我直接打印出解析结果,这里可以获取我们想要的自定义属性,而系统有的属性可以忽略。


(3)、运行结果如下


在这里插入图片描述


从解析的结果可以看出,尺寸的结果已经转换为实际值了:


xml文件:
app:view_ref="@dimen/dp_15"

打印结果:
name:view_ref value:41.25

这个时候有童鞋又问了,我设置的是15dp,为啥最后打印是41.25了呢?其实解析出来的值单位是px,所以这里输出的是转换后的值。


解析的过程中用到了这个方法:


context.obtainStyledAttributes(attrs, R.styleable.TestStyleable);

我们来看一下这个方法的源码:


   public final TypedArray obtainStyledAttributes(
@Nullable AttributeSet set, @NonNull @StyleableRes int[] attrs) {
return getTheme().obtainStyledAttributes(set, attrs, 0, 0);
}

源码中我们可以看到这个方法有两个参数:


AttributeSet set:表示当前xml声明的属性集合

int[] attrs:表示你想挑选的属性,你想得到哪些属性,你就可以将其写到这个int数组中


obtainStyledAttributes方法返回值类型为TypedArray。该类型记录了获取到的属性值集合,而通过数组下标索引即可找到对应的属性值。索引下标通过R.styleable.TestStyleable_xx获取,“xx"表示属性名,一般命名为"styleable名” + “_” + “属性名”。


而TypedArray提供了各种Api,如getInteger,getString,getDimension等方法来获取属性值,这些方法都需要传入对应属性名在obtainStyledAttributes中的int数组的位置索引,通过下标获取数组里属性值。


这个TypedArray的作用就是资源的映射作用,把自定义属性在xml设置值映射到class,这样怎么获取都很简单啦。


到这里就分析完啦!

收起阅读 »

想要进阶高级开发?快看画布的基础使用

【Android 自定义控件】2.画布的基础使用 1.设置画布的背景颜色 2.画圆形 基本语法 参数说明 3.画直线 单条直线: 基本语法 ...
继续阅读 »





1.设置画布的背景颜色


void  drawColor(int color)
void drawARGB(int a, int r, int g, int b)
void drawRGB(int r, int g, int b)

2.画圆形


//画笔
Paint paint=new Paint() ;
paint.setColor(OxFFFFOOOO );
paint.setStyle(Paint.Style.FILLANDSTROKE);
paint.setStrokeWidth(50);

//画布(画圆形)
canvas.drawCircle(l90, 200, 150, paint);

基本语法


void drawCircle (float cx, float cy, float radius, Paint paint)


参数说明


cx:圆心的x坐标。
cy:圆心的y坐标。
radius:圆的半径。
paint:绘制时所使用的画笔。


3.画直线


单条直线:


//画笔
Paint paint=new Paint() ;
paint.setColor(OxFFFFOOOO );
paint.setStrokeWidth(50);

//画布(画直线)
canvas.drawLine(100, 100, 200, 200, paint);

基本语法


void drawLine (float startX, float startY, float stopX, float stopY , Paint paint)


参数说明


startX:起始点 坐标。
startY:起始点 坐标
stopX:终点 坐标。
stopY:终点 坐标。
paint:绘制时所使用的画笔。


多条直线:


Paint paint = new Paint();
paint.setColor(color.RED);
paint.setStrokeWidth(5);

float []pts={10,10,100, 100, 200, 200,400,400};
canvas.drawLines(pts, 2,4,paint); //表示从pts 数组中索引为2的数字开始绘图,有4个数值参与绘图,也就是点(100,100)和(200,200),所以效果图就是这两个点的连线。

基本语法


void drawLines(float[] pts,Paint paint)
void drawLines(float [ ] pts,int offset, int count,Paint paint)


参数说明


pts:点的集合,pts的组织方式为{x1,y1,x2,y2,x3,y3,…}。
offset:集合中跳过的数值个数。注意不是点的个数!一个点有两个数值。
count:参与绘制的数值个数,指pts数组中数值的个数,而不是点的个数,因为一个点有两个数值。
paint:绘制时所使用的画笔。


4.画点


单个点


//画笔
Paint paint=new Paint() ;
paint.setColor(OxFFFFOOOO );
paint.setStrokeWidth(50);

//画布(画点)
canvas.drawPoint(100, 100, paint);

基本语法


void drawPoint(float x, float y, Paint paint)


参数说明


x:点的X坐标。
y:点的Y坐标。
paint:绘制时所使用的画笔。


多个点


Paint paint = new Paint();
paint.setColor(Color.RED);
paint.setStrokeWidth(25);

float[] pts = {10,10,100,100,200,200,400,400};
canvas.drawPoints(pts, 2, 4, paint); //4个点:(10,10)、(100,100)、(200,200)和(400,400),在 drawPoints()函数里跳过前两个数值,即第一个点的横、纵坐标,画出后面4个数值代表的点,即第二、三个点,第四个点没画。

基本语法


void drawPoints (float [] pts,Paint paint)
void drawPoints(float[ ] pts,int offset,int count,Paint paint)


参数说明


pts:点的合集,与上面的直线一致,样式为{x1,y1,x2,y2,x3,y3,…}。
offset:集合中跳过的数值个数。注意不是点的个数!一个点有两个数值。
count:参与绘制的数值个数,指pts数组中数值的个数,而不是点的个数。
paint:绘制时所使用的画笔。


5.画矩形


区别:


RectF 所保存的数值类型是 float 类型
Rect 所保存的数值类型是 int 类型


构造矩形的两种方法:


//方法一 直接构造
Rect rect = new Rect(10, 10, 100, 100);
//方法二 间接构造
Rect rect = new Rect();
rect.set(10, 10, 100, 100);

绘制矩形:


Paint paint = new Paint(); 
paint.setColor(Color.RED);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(15);

//直接构造
canvas.drawRect(10, 10, 100, 100, paint);

//使用 RectF 构造
RectF rect = new RectF(210f, 10f, 300f, 100f);
canvas.drawRect(rect, paint);

6.画圆角矩形


Paint paint = new Paint();
paint.setColor(Color.RED);
paint.setStyle(Style.FILL);
paint.setStrokeWidth(15);

RectF rect = new RectF(100,10,300,100);
canvas.drawRoundRect(rect,20,10, paint);

基本语法


void drawRoundRect (RectF rect, float rx, float ry,Paint paint)


参数说明


rect:要绘制的矩形。
rx:生成圆角的椭圆的X轴半径。
ry:生成圆角的椭圆的Y轴半径。
paint:绘制时所使用的画笔。


7.画椭圆


Paint paint = new Paint();
paint.setColor(Color.RED);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(5);

RectF rect = new RectF(100,10,300,100);
canvas.drawRect(rect, paint);

canvas.drawOval(rect, paint);//根据同一个矩形画椭圆

基本语法


void drawOval(RectF oval, Paint paint)


参数说明


oval:用来生成椭圆的矩形。

收起阅读 »

40+场面试,100%通过率,我想分享的14条经验

作者 | 陈同学       责编 | 欧阳姝黎 这里是陈同学,首先来一个简单的自我介绍,和个人的经历分享吧。 我本科和硕士均就读于哈工大,是今年1月毕业。 我经历过3个专业,大一 船舶工程;大二-大四 车辆...
继续阅读 »

作者 | 陈同学       责编 | 欧阳姝黎


这里是陈同学,首先来一个简单的自我介绍,和个人的经历分享吧。


我本科和硕士均就读于哈工大,是今年1月毕业。


我经历过3个专业,大一 船舶工程;大二-大四 车辆工程;研一-研三 机械电子工程。


我拿过5个国家级竞赛的奖项 ,我在研究生期间从0开始,在1年时间,自学操作系统,计算机网络 ,C++,数据结构等等,累计学习30+本书,500+博客文章,100+小时的网课,30w+字的资料。


在实习阶段斩获了腾讯、阿里、华为等大厂的实习offer,在正式秋招阶段收割10+offer,包括但不限于


腾讯sp 

字节sp

阿里云 sp 

BAT大满贯

还有华为15a(应届生最高定级)

中兴蓝剑计划(应届生最高定级)

深信服大牛批(应届生最高定级)

vivo sp等等

均是40w+的总包

最高总包约50w

同时保持了一个在4月份以后

面试通过率100%的记录

今天和大家来分享一下,我从40+场面试中总结出来的14个应该避免的面试大坑。


我认为想面试互联网技术岗, 特别是像我一样的非科班同学,应该特别关注。


这些坑都是从我自身的经历以及从身边的同学的经历中总结出来的,我踩过的坑希望大家不要踩




No1.对简历上的每一个字负责


很多同学,包括我以前会犯的一个误区就是, 追求简历上技能点多多益善。


不论是不是自己真正掌握的 ,只要是接触过某个技术,都罗列在简历上。甚至有些技能点, 还蜜汁自信的写上“精通”但是面试官一深问, 就不会了。这就犯了写简历时候的一个大忌。


我们要对简历上的每一个字负责, 每一个写在简历上的技术点都应该是我们烂熟于心,经得起面试官深入追问的。


具体来说就是要避开下面两个坑



  1. 技术栈不要贪多把写上去的每一个点深入掌握就好。你在简历上写的内容相当于给面试官划定了一个出题范围。面试的时候面试官并不会特意的刁难你。他们主要还是会从你简历上写好的那些技术点去考你。好好对着自己写的简历一行一行看一遍,这都是你挖的坑。同时谨慎的使用熟练精通这些字眼 。


  2. 在描述项目的时候,不要过分夸张,比如把整个团队的活写成你一个人做的。言过其实,很容易会在面试中露馅,简历可以美化包装,但是过分夸张。





No2.技术宽度决定了你是否能够进入一家公司,


技术深度则决定了你offer的等级


对于互联网技术岗的主要问的东西有这样几块计算机学科基础+项目经历+刷题。这3块也就是整个面试的核心了,然后对于不同细分的技术岗位下对于这三块有不同的考察方向比如:



  • 对于开发岗可能考察的就是像操作系统计算机网络等等方面的知识


  • 对于算法岗考察的就是机器学习深度学习等等方面的内容



所以我觉得只有你先对应岗位必问的那些知识掌握,也就是先cover住技术宽度,才是拿到offer的前提。


在此基础上 如果你能在某一方面比较有优势,比如某一些知识领域比较精通或者做的项目比较有优势或者有大厂实习也就是技术深度达到了这样才能有更好的offer等级。


关于怎么提升宽度和深度,其实说真的短期内宽度是好补的,深度确实要看个人,是代价较高。


所以我的建议是,先把宽度提上来,把你能cover的知识点及原理搞懂是第一步。建议对自己之前的项目和技术积累做一个总结和分类。


然后对已经了解的方面尽可能延伸,对盲区或是一些面试重点考察的地方进行针对性的学习和练习。




No3.如果走技术研发岗,学历、成绩、奖学金 


学生组织活动都不会是决定性的因素


因为面试中只考查计算机基础+刷题+项目,只有在最后的HR面的时候才会问一下你的在校的一些经历、奖学金等等。


当然如果你如果前面的技术面都通过的话,最后的HR面其实问题不大,就算没有太多的学生组织经历、太高的绩点、各种奖学金等等。HR面大概率还是会通过的。


只有你的技术水平才是决定性因素像学历、绩点、奖学金等等这些东西只是一个锦上添花。


如果你的技术很拉跨,一个技术问题都回答不上来,我觉得算是清北,面试官也是大概率不会让你通过的。


互联网算是对学历最宽容的行业之一毕竟程序员是一个技术密集型工种。


学校的作用是抬高找工作的下限,很多大厂会认为一个出身名校的同学的基本功是扎实的,因此会很乐于接纳这样的同学。但是指望名校光环提高自己的上限是不切实际的。


有很多同学会因为自己是双非学校,感到自卑,不敢投递大厂会显得有点畏手畏脚。但我觉得我们完全没有必要妄自菲薄


说实话,我自己本科专业也和计算机一点不搭边,在面试的时候也和面试官提到这个问题,但面试官给我的答复是只要有能力,没有人会看你的学校或者专业。




No4.心态作为一个很重要的因子存在,


还是会对最后的结果有挺大影响的


这里给大家列出一个公式 是我在某一个帖子看到的


  offer = 心态 * (实力 + 面试技巧) + 运气

实力就是咱们刚才所说的 计算机基础+刷题+项目


秋招对大部分人来说都是一场难熬的经历,会有各种压力源的存在,真的很容易让我们心态崩溃



  • 可能有的同学开始准备的时间比较晚,快开始秋招了才开始准备,总暗示自己说什么时间不多了,怎么每天过这么快效率怎么这么低。


  • 到笔试了,跟自己说这个算法太难了,肯定做不出来;


  • 面试过程中面试官问的东西好多都不会怎么办?


  • 面完了又收到拒信,这次面试又凉凉了。


  • 周围的XX大佬又收割一个offer了 我还没上岸 太菜了 怎么办



不管是面试前、面试中、面试后的结果 已经周围环境 peer pressure 等等都牵动着我们的神经。


所以这里给大家提供几个调节心态的小建议



  • 要正视自己的能力,不轻视,不高估

    不轻视指的是我们都要对自己有信心,机会那么多,千千万万的初中创公司,各种拥有垂直领域稳定份额的二三线公司甚至有些已经上市,除此之外还有银行,投资,金融的IT岗

    不高估就是要清楚自己的能力范围,过高的期望会让你的心理变得脆弱,稍有不顺心态就有崩掉的趋势。因为面试毕竟有太大的偶然性,就算你达到了一定的水平,相应水平的岗位也不是百发百中的


  • 遭受到各种拒绝时,一定要沉得住气,坚信一切都会是最好的安排

    在确保自身没有问题(学习方法、知识积累或自身定位)的情况下,坚持下去,这个时候你差的就是一点点运气,该来的总会来。面试过程不要紧张,尤其是前几次,建议先从小公司入手锻炼下面试经验


  • 心态实在太差的时候反而要停下你重复而没有效率的工作,去调整一下,可以出去玩一玩,吃吃喝喝


  • 面试过程漫长:适当放松,面试很搞人心态的

    过了简历面等 一面 一面过了等二面 二面过了等三面。互联网面试流程少则三面 多则五六七八面。持续时间少则是、一个礼拜 多则一两个月 。在这个过程中建议大家专注于过程 不要太在意结果


  • 面试准备过程中 和周围同学多交流 不比较
    主要是要找一个能力和你差不多的同学,最好不要找那种比你强太多的,当你看到别人已经收割很多offer了自己还颗粒无收的时候 容易被搞心态,会怀疑人生。当然也不排除有些人拿到offer后在朋友圈装X、散布焦虑情绪,这种我建议屏蔽或拉黑,同时也希望大家拿到offer后能低调一些,以己度人。求职过程中别和身边的人对比 ,别自我怀疑,专注于过程,别在意结果,反思总结,心态别崩





No5.学会平等交流,别把自己身段放的太低


面试是个双选的过程,他可以拒绝你,你也可以拒绝他。回答的时候不用表现的太卑微,反而会影响自己正常的表达和逻辑,不卑不亢就行。


心态也放稳一点,大胆一点,duck不必害怕,互联网技术岗的面试不会像其他行业 其他岗位比如快销,地产等等那样子会在意你的仪表,谈吐等等,他在意的就是面试官问你的技术会不会。


和面试官谈笑风生就行了,而且1面面试官可能只比我们大几岁,如果进去了还是你mentor呢。




No6.回答问题的时候要有层次感 循序渐进


不要一口气把知道的全部说完,然后还毫无条理。学会一个知识点由浅入深讲解给面试官,并且留有余地给他进一步去问。


一个简单的基础问题可以一步一步有条理有层次的回答,每一层表达完抛个引子,让面试官可以继续问下去,这也算是一个引导的技巧,从而让面试官真正了解你的掌握的深度。




No7.如果真的被问到不会的,就直接说你不会


每个程序员都不是全能的大神,总会有知识漏洞,更何况是我们这些应届生所以面试中碰到不会的问题很正常。


不要觉得自己某个问题到不上来,这场面试就注定凉凉了,坦诚的告诉面试官自己不会,或者礼貌地说这方面可能我还要多学习。


对一个拿不准的问题千万不要猜,即使是二选一的那种问题,猜错了直接完蛋,猜对了被人看出来,再往深问还是完蛋。


另外,像可能,大概是,我觉得这种表达最好不要,一听就是对一个点没把握,有可能会让面试官觉得学习太浮躁不喜欢寻求原理。


那对于自己知道原理(确实是理解了的)但是没用过的东西,就讲讲原理,并承认自己实践不足,表现出好学的态度。


面试一定要真诚。不熟直接说不会,更多的展示自己擅长的一面,千万不懂装懂。




No8.手撕代码题的时候主动的和面试官交流


一般每一轮面试的最后一part保留节目是手撕代码。


关于手撕代码部分,不能面试官出完题,就一个人闷头在那里写。


因为面试官是会代入实际工作时的情景的,如果你写题的时侯和他一点交流也没有,那万一把你招进去了以后对需求交接的时侯是不是也是这样的状态?


这个也是我在面试的时侯听面试官提的意见。




No9.思路比答对题目更重要,题不会没关系,


你要体现你的解题思路和能力


当然纯概念不会就是不会,别瞎说。


这里更多的是比如一些开放性的题目,比如说,手撕代码题,项目中的一些优化 一些系统设计题、智力题。


面试官不一定非得要求有一个标准答案呢,主要是想看看你能不能主动的去拆解问题、主动思考,以及和面试官的交流。


这也是面试中考察的很重要的一部分,就是你解决问题的能力。


对于这种问题,还是要多打开思路,多结合自己已经学过的一些技术点进行思考。


自己能够先给出一个简单的方案,再一步一步的优化,到一个相对合理的方案 这样的回答面试官会非常喜欢。




No10.最好把每场面试录音,记录面经,反思总结


在电话面或者视频面的时候 ,最好利用手机的录音功能把每一场面试录下来哦 这样方便自己的复盘 。


发现自己那些模块比较薄弱,查漏补缺, 反思总结, 针对面试中出现的问题下次不要再出现。




No11.在面试中介绍项目的面试时候,


项目的一些描述要提前准备,而不是临场去组织语言


很多同学在面试中描述项目的时候,都是临场发挥,临场去组织语言。这样会往往会导致你在介绍的时候,不流畅不连贯 ,导致面试官抓不住你的重点。


也就会让它认为你的表达有问题,或者你的项目吗没有太核心能吸引他的东西,所以建议大家专门给自己做的项目整理一个类似演讲稿的稿子。


把项目的流程、项目的背景、项目碰到的问题。自己用到的方案,项目的亮点难点改进点,后续的优化方向等等都写在这个稿子上。


在每次面试前过一遍,这样的在面试中直接按照稿子上的描述去说就行。


面试官其实对你的项目业务流程不感兴趣,更感兴趣的是你项目中



  • 自己解决的问题,


  • 所采用的方案,


  • 为什么采用这个方案,


  • 有没有更好的方案,


  • 你的方案和别人的方案的对比,


  • 你的思考在哪里,


  • 你的难点亮点创新点,


  • 以及在项目中所涉到的技术点的一些提问,



这里面最好可以涉及一些数据,比如数据量、响应速度等等来量化的表达。




No12.把握好反问环节


面试官最后一般会问你你有什么想问我的,这个其实就是反问环节。


这个其实是面试官想了解你对公司的一个关注度或者对自身发展的一个关注度。


所以大家可以从这些角度去问新人培养机制?进去以后负责哪些业务?学习建议?


表现出自己的好学求知,以及对公司的关注 这也能看出你对工作的一个诚意,以及对发展的一个预期。


最好不要去问那些比如 “我什么时候会有下一面 ” “我刚刚面的怎么样这种话题”。




No13. HR面的时候 看起来像聊人生 


实际是在考察你的价值观


到HR面的时候就不会在有技术问题了,而是一些看起来无关痛痒的聊生活聊兴趣。


比如,家里人都是干嘛的,有没有女朋友,有没有什么兴趣爱好,有没有拿到别的offer,为什么会来我们呢公司等等。


其实这些问题看起来都很无足轻重,实际上是想看看你的稳定性,是不是适合公司的氛围,是不是接受公司的文化等等。


比如,是不是会因为家里条件好,吃不了苦,加不了班,会不会女朋友异地,过几年就会离职跳槽,稳定性差,会不会有更好的offer放弃这家等等。


所以大家在HR面的时候要摸清楚HR真正想考察你的指标是什么避免跳坑里就行了。


对于互联网技术岗来讲 通过了前面的3、4轮的技术面 一般问题都不大,HR面只要不是回答得得太离谱,offer八成是可以到手的




No14.不要把鸡蛋都放在一个篮子里


这句话的意思是, 尽量多拿几个offer,不要只拿一个offer就躺平了,不要把赌注都压在一个offer上。


因为互联网的秋招一般是面试通过了,先发两方,然后过两个月左右到11月份再谈薪资。


如果你最后只拿了一个offer,然后那个公司又只给你开了一个白菜价你就血亏了,都没有别的选择。


尽量多拿一些offer。事实证明,部分企业会根据你手里offer的情况来定薪资,还有一点,万一后面提前去实习发现不太合适,想违约跑路 没有别的offer在手,根本没有选择。


hr们会养备胎,你也可以多拿几个offer ,算是给自己多养几个备胎,抵抗风险。


收起阅读 »

震惊:从头开发一个RPC是种怎样的体验?

RPC
对于开发人员来说,调用远程服务就像是调用本地服务一样便捷。尤其是在微服务盛行的今天,了解RPC的原理过程是十分有必要的。 作者 | Alex Ellis       译者 | 弯月 出品 | CSDN(ID:CS...
继续阅读 »


对于开发人员来说,调用远程服务就像是调用本地服务一样便捷。尤其是在微服务盛行的今天,了解RPC的原理过程是十分有必要的。


作者 | Alex Ellis       译者 | 弯月


出品 | CSDN(ID:CSDNnews)


以下为译文:


计算机之间的通信方式多种多样,其中最常用的一种方法是远程过程调用(Remote Procedure Call,即RPC)。该协议允许一台计算机调用另一个计算机上的程序,就像调用本地程序一样,并负责所有传输和通信。


假设我们需要在一台计算机上编写一些数学程序,并且有一个判断数字是否为质数的程序或函数。在使用这个函数的时候,我们只需传递数字进去,就可以获得答案。这个函数保存在我们的计算机上。



很多时候,程序保存在本地非常方便调用,而且由于这些程序与我们其余的代码在一起,因此调用的时候几乎不会产生延迟。


但是,在有些情况下,将这些程序保留在本地也不见得是好事。有时,我们需要在拥有大量核心和内存的计算机上运行这些程序,这样它就可以检查非常大的数字。但这也不是什么难事,我们可以将主程序也放到大型计算机上运行,即使其余的程序可能并没有这种需求,质数查找函数也可以自由利用计算机上的资源。如果我们想让其他程序重用质数查找函数,该怎么办?我们可以将其转换成一个库,然后在各个程序之间共享,但是每一台运行质数查找库的计算机,都需要大量的内存资源。


如果我们将质数查找函数单独放在一台计算机上,然后在需要检查数字时与该计算机对话,怎么样呢?如此一来,我们就只需提高质数查找函数所在的计算机的性能,而且其他计算机上程序也可以共享这个函数。



这种方式的缺点是更加复杂。计算机可能会出现故障,网络也有可能出问题,而且我们还需要担心数据的来回传递。如果你只想编写一个简单的数学程序,那么可能无需担心网络状况,也不用考虑如何重新发送丢失的数据包,甚至不用担心如何查找运行质数查找函数的计算机。如果你的工作是编写最佳质数查找程序,那么你可能并不关心如何监听请求或检查已关闭的套接字。


这时就可以考虑远程过程调用。我们可以将计算机间通信的复杂性包装起来,然后在通信的任意一侧建立一个简单的接口(stub)。对于编写数学程序的人来说,看上去就像在调用同一台计算机上的函数;而对于编写质数查找程序的人来说,看上去就像是自己的函数被调用了。如果我们将中间部分抽象化,那么两侧都可以专心做好自己的细节,同时仍然可以享受将计算拆分到多台计算机的优势。



RPC调用的主要工作就是处理中间部分。它的一部分必须存在数学程序的计算机上,负责接受并打包参数,然后发送到另一台计算机。此外,在收到响应后,还需要解析响应,并传递回去。而质数查找函数计算机则必须等待请求,解析参数,然后将其传递给函数,此外,还需要获取结果,将其打包,然后再返回结果。这里的关键之处是数学程序和质数查找程序间,以及它们的stub之间都有一个清晰的接口。



更多详细信息,请参见 Andrew D. Birrell和Bruce Jay Nelson1 于1981年发表的论文《Implementing Remote Procedure Calls》。



从头编写RPC


下面,我们来试试看能不能编写一个RPC。


首先,我们来编写基本的数学程序。为了简单起见,我们编写一个命令行工具,接受输入,然后检查是否为质数。它有一个单独的方法is_prime,处理实际的检查。


// basic_math_program.c
#include <stdio.h>
#include <stdbool.h>


// Basic prime checker. This uses the 6k+-1 optimization
// (see https://en.wikipedia.org/wiki/Primality_test)
bool is_prime(int number) {
// Check first for 2 or 3
if (number == 2 || number == 3) {
return true;
}
// Check for 1 or easy modulos
if (number == 1 || number % 2 == 0 || number % 3 == 0) {
return false;
}
// Now check all the numbers up to sqrt(number)
int i = 5;
while (i * i <= number) {
// If we've found something (or something + 2) that divides it evenly, it's not
// prime.
if (number % i == 0 || number % (i + 2) == 0) {
return false;
}
i += 6;
}
return true;
}


int main(void) {
// Prompt the user to enter a number.
printf("Please enter a number: ");
// Read the user's number. Assume they're entering a valid number.
int input_number;
scanf("%d", &input_number);


// Check if it's prime
if (is_prime(input_number)) {
printf("%d is prime\n", input_number);
} else {
printf("%d is not prime\n", input_number);
}


return 0;
}

这段代码有一些潜在的问题,我们没有处理极端情况。但这里只是为了说明,无伤大雅。



目前一切顺利。下面,我们将代码拆分成多个文件,is_prime 可供同一台计算机上的程序重用。首先,我们为 is_prime 创建一个单独的库:


// is_prime.h
#ifndef IS_PRIME_H
#define IS_PRIME_H


#include <stdbool.h>


bool is_prime(int number);


#endif

// is_prime.c
#include "is_prime.h"


// Basic prime checker. This uses the 6k+-1 optimization
// (see https://en.wikipedia.org/wiki/Primality_test)
bool is_prime(int number) {
// Check first for 2 or 3
if (number == 2 || number == 3) {
return true;
}
// Check for 1 or easy modulos
if (number == 1 || number % 2 == 0 || number % 3 == 0) {
return false;
}
// Now check all the numbers up to sqrt(number)
int i = 5;
while (i * i <= number) {
// If we've found something (or something + 2) that divides it evenly, it's not
// prime.
if (number % i == 0 || number % (i + 2) == 0) {
return false;
}
i += 6;
}
return true;
}

下面,从主程序中调用:


// basic_math_program_refactored.c
#include <stdio.h>
#include <stdbool.h>


#include "is_prime.h"


int main(void) {
// Prompt the user to enter a number.
printf("Please enter a number: ");
// Read the user's number. Assume they're entering a valid number.
int input_number;
scanf("%d", &input_number);


// Check if it's prime
if (is_prime(input_number)) {
printf("%d is prime\n", input_number);
} else {
printf("%d is not prime\n", input_number);
}


return 0;
}

再试试,运行正常!当然,你也可以加一些测试:



下面,我们需要将这个函数放到其他计算机上。我们需要编写的功能包括:



  • 调用程序的 stub:




  • 打包参数


  • 传输参数


  • 接受结果


  • 解析结果




  • 被调用的 stub:




  • 接受参数


  • 解析参数


  • 调用函数


  • 打包结果


  • 传输结果



我们的示例非常简单,因为我们只需要打包并发送一个 int 参数,然后接收一个字节的结果。对于调用程序的库,我们需要打包数据、创建套接字、连接到主机(暂定 localhost)、发送数据、等待结果、解析,然后返回。调用程序库的头文件如下所示:


// client/is_prime_rpc_client.h
#ifndef IS_PRIME_RPC_CLIENT_H
#define IS_PRIME_RPC_CLIENT_H


#include <stdbool.h>


bool is_prime_rpc(int number);


#endif

可能有些读者已经发现了,实际上这个接口与上面的函数库一模一样,但关键就在于此!因为调用程序只需要关注业务逻辑,无需关心其他一切。但实现就稍复杂:


// client/is_prime_rpc_client.c


#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>


#define SERVERPORT "5005" // The port the server will be listening on.
#define SERVER "localhost" // Assume localhost for now


#include "is_prime_rpc_client.h"


// Packs an int. We need to convert it from host order to network order.
int pack(int input) {
return htons(input);
}


// Gets the IPv4 or IPv6 sockaddr.
void *get_in_addr(struct sockaddr *sa) {
if (sa->sa_family == AF_INET) {
return &(((struct sockaddr_in*)sa)->sin_addr);
} else {
return &(((struct sockaddr_in6*)sa)->sin6_addr);
}
}


// Gets a socket to connect with.
int get_socket() {
int sockfd;
struct addrinfo hints, *server_info, *p;
int number_of_bytes;


memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM; // We want to use TCP to ensure it gets there
int return_value = getaddrinfo(SERVER, SERVERPORT, &hints, &server_info);
if (return_value != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(return_value));
exit(1);
}


// We end up with a linked-list of addresses, and we want to connect to the
// first one we can
for (p = server_info; p != NULL; p = p->ai_next) {
// Try to make a socket with this one.
if ((sockfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) == -1) {
// Something went wrong getting this socket, so we can try the next one.
perror("client: socket");
continue;
}
// Try to connect to that socket.
if (connect(sockfd, p->ai_addr, p->ai_addrlen) == -1) {
// If something went wrong connecting to this socket, we can close it and
// move on to the next one.
close(sockfd);
perror("client: connect");
continue;
}


// If we've made it this far, we have a valid socket and can stop iterating
// through.
break;
}


// If we haven't gotten a valid sockaddr here, that means we can't connect.
if (p == NULL) {
fprintf(stderr, "client: failed to connect\n");
exit(2);
}


// Otherwise, we're good.
return sockfd;
}


// Client side library for the is_prime RPC.
bool is_prime_rpc(int number) {


// First, we need to pack the data, ensuring that it's sent across the
// network in the right format.
int packed_number = pack(number);


// Now, we can grab a socket we can use to connect see how we can connect
int sockfd = get_socket();


// Send just the packed number.
if (send(sockfd, &packed_number, sizeof packed_number, 0) == -1) {
perror("send");
close(sockfd);
exit(0);
}


// Now, wait to receive the answer.
int buf[1]; // Just receiving a single byte back that represents a boolean.
int bytes_received = recv(sockfd, &buf, 1, 0);
if (bytes_received == -1) {
perror("recv");
exit(1);
}


// Since we just have the one byte, we don't really need to do anything while
// unpacking it, since one byte in reverse order is still just a byte.
bool result = buf[0];


// All done! Close the socket and return the result.
close(sockfd);
return result;
}

如前所述,这段代码需要打包参数、连接到服务器、发送数据、接收数据、解析,并返回结果。我们的示例相对很简单,因为我们只需要确保数字的字节顺序符合网络字节顺序。


接下来,我们需要在服务器上运行被调用的库。它需要调用我们前面编写的 is_prime 库:


// server/is_prime_rpc_server.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <signal.h>
#include "is_prime.h"
#define SERVERPORT "5005" // The port the server will be listening on.
// Gets the IPv4 or IPv6 sockaddr.
void *get_in_addr(struct sockaddr *sa) {
if (sa->sa_family == AF_INET) {
return &(((struct sockaddr_in*)sa)->sin_addr);
} else {
return &(((struct sockaddr_in6*)sa)->sin6_addr);
}
}
// Unpacks an int. We need to convert it from network order to our host order.
int unpack(int packed_input) {
return ntohs(packed_input);
}
// Gets a socket to listen with.
int get_and_bind_socket() {
int sockfd;
struct addrinfo hints, *server_info, *p;
int number_of_bytes;
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM; // We want to use TCP to ensure it gets there
hints.ai_flags = AI_PASSIVE; // Just use the server's IP.
int return_value = getaddrinfo(NULL, SERVERPORT, &hints, &server_info);
if (return_value != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(return_value));
exit(1);
}
// We end up with a linked-list of addresses, and we want to connect to the
// first one we can
for (p = server_info; p != NULL; p = p->ai_next) {
// Try to make a socket with this one.
if ((sockfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) == -1) {
// Something went wrong getting this socket, so we can try the next one.
perror("server: socket");
continue;
}
// We want to be able to reuse this, so we can set the socket option.
int yes = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int)) == -1) {
perror("setsockopt");
exit(1);
}
// Try to bind that socket.
if (bind(sockfd, p->ai_addr, p->ai_addrlen) == -1) {
// If something went wrong binding this socket, we can close it and
// move on to the next one.
close(sockfd);
perror("server: bind");
continue;
}
// If we've made it this far, we have a valid socket and can stop iterating
// through.
break;
}
// If we haven't gotten a valid sockaddr here, that means we can't connect.
if (p == NULL) {
fprintf(stderr, "server: failed to bind\n");
exit(2);
}
// Otherwise, we're good.
return sockfd;
}
int main(void) {
int sockfd = get_and_bind_socket();
// We want to listen forever on this socket
if (listen(sockfd, /*backlog=*/1) == -1) {
perror("listen");
exit(1);
}
printf("Server waiting for connections.\n");
struct sockaddr their_addr; // Address information of the client
socklen_t sin_size;
int new_fd;
while(1) {
sin_size = sizeof their_addr;
new_fd = accept(sockfd, (struct sockaddr *)&their_addr, &sin_size);
if (new_fd == -1) {
perror("accept");
continue;
}
// Once we've accepted an incoming request, we can read from it into a buffer.
int buffer;
int bytes_received = recv(new_fd, &buffer, sizeof buffer, 0);
if (bytes_received == -1) {
perror("recv");
continue;
}
// We need to unpack the received data.
int number = unpack(buffer);
printf("Received a request: is %d prime?\n", number);
// Now, we can finally call the is_prime library!
bool number_is_prime = is_prime(number);
printf("Sending response: %s\n", number_is_prime ? "true" : "false");
// Note that we don't have to pack a single byte.
// We can now send it back.
if (send(new_fd, &number_is_prime, sizeof number_is_prime, 0) == -1) {
perror("send");
}
close(new_fd);
}
}

最后,我们更新一下我们的主函数,使用新的RPC库调用:


// client/basic_math_program_distributed.c
#include <stdio.h>
#include <stdbool.h>
#include "is_prime_rpc_client.h"
int main(void) {
// Prompt the user to enter a number.
printf("Please enter a number: ");
// Read the user's number. Assume they're entering a valid number.
int input_number;
scanf("%d", &input_number);
// Check if it's prime, but now via the RPC library
if (is_prime_rpc(input_number)) {
printf("%d is prime\n", input_number);
} else {
printf("%d is not prime\n", input_number);
}
return 0;
}

这个 RPC 实际的运行情况如下:


现在运行服务器,就可以运行客户端将质数检查的工作分布到其他计算机上运行!现在,程序调用 is_prime_rpc 时,所有网络业务都在后台进行。我们已经成功分发了计算,客户端实际上是在远程调用程序。



示例有待改进的方面


本文中的实现只是一个示例,虽然实现了一些功能,但只是一个玩具。真正的框架(例如 gRPC3)要复杂得多。我们的实现需要改进的方面包括:



  • 可发现性:在上述示例中,我们我们假定服务器在 localhost 上运行。RPC 库怎么知道将 RPC 发送到哪里呢?我们需要通过某种方式来发现可以处理此 RPC 调用的服务器在哪里。


  • RPC 的类型:我们的的服务器非常简单,只需处理一个 RPC 调用。如果我们希望服务器提供两个不同的RPC服务,比如 is_prime 和get_factors,那么该怎么办?我们需要一种方法来区分发送到服务器的两种请求。


  • 打包:打包整数很容易,打包一个字节更容易。如果我们需要发送一个复杂的数据结构,该怎么办?如果我们需要为了节省带宽而压缩数据,又该怎么办?


  • 自动生成代码:我们肯定不希望每次编写新的 RPC,都需要手动编写所有的打包和网络处理代码。理想情况下,我们只需定义一个接口,然后其余的接口都由计算机自动完成,并自动提供 stub。这里,我们需要考虑协议缓冲区等。


  • 多种语言:按照上面的思路,如果我们能够自动生成 stub,那么就可以考虑支持多种语言,如此一来,跨服务和跨语言的通信也只需调用一个函数。


  • 错误和超时处理:如果 RPC 失败怎么办?如果网络出现故障,服务器停止运行,wifi 掉线,该怎么办?我们需要考虑超时处理。


  • 版本控制:假设上述所有功能已全部实现,但你想修改某个正在多台计算机上运行的 RPC,那么该怎么办?


  • 其他有关服务器的注意事项:线程、阻塞、多路复用、安全性、加密、授权等等。



计算机科学就是要站在巨人的肩膀上,很多库已经为我们完成了大量工作。


原文链接:https://alexanderell.is/posts/rpc-from-scratch/


声明:本文由CSDN翻译,转载请注明来源。



收起阅读 »

Google和腾讯为什么都采用主干开发模式?

作者 | 黄国峰       责编 | 欧阳姝黎 摘要 本文介绍了两种常用的代码分支模式:特性分支开发模式、主干开发模式,分别阐述了其优缺点和适用环境;同时剖析了 Google 和腾讯采用主干开发模式的背景...
继续阅读 »

作者 | 黄国峰       责编 | 欧阳姝黎




摘要


本文介绍了两种常用的代码分支模式:特性分支开发模式、主干开发模式,分别阐述了其优缺点和适用环境;同时剖析了 Google 和腾讯采用主干开发模式的背景和决策因素,捎带分享了这2个巨头的实践,供读者在技术选型中参考。




背景


按之前的写作思路,本文应该叫《Google 工程效能三板斧之三:主干开发》,但我改变了主意,希望能同时提供国内互联网公司的实践,供读者参考,因此文章标题也随之更改。


软件开发过程中,开发人员通过版本管理工具对源码进行存储,追踪目录和文件的修改历史。为了区隔不同状态的源代码,会采用分支进行管理。不同的软件开发模式,对应着不同的分支模式。


软件业界常用的软件分支模式有多种,但本质上可以分为两类:



  • 主干开发模式(Trunk Based Development)


  • 特性分支开发模式(Feature Branch Development)





两种模式的定义及优缺点分析


特性分支开发模式


特性分支开发模式是指为一个或多个特定的需求/缺陷/任务创建代码分支(branch),在其上完成相应的开发(一般经过增量测试)后,把它合并(merge)到主干/集成分支的开发模式。


通常这种分支生命期会持续一段时间,从几天到几周不等,极少数情况甚至以月算。


特性分支开发模式中常用的有 Git-Flow 模式、Github-Flow 模式和 Gitlab-Flow 模式等。这些模式只有细节上的差异,以 Git-Flow为例:



优点:



  • 特性开发周期宽松:因为生命期可以较长,较大的需求特性可以在宽松的时间内完成再合入主干;


  • 分支测试的时间宽松:因为生命期可以较长,可以有较多时间对分支进行测试,甚至手工测试;



缺点:



  • 分支管理复杂:原因在于大量采用代码分支,且来源分支和合入目标分支各异,操作复杂 —— 以上图为例,可以从master(Tag 1.0.0) 拉出 hotfix 1.0.2 分支,然后合入到 develop 分支,开发阶段结束后合入到 release branches,发布后合入 master,非常复杂,很容易出错;


  • 合并冲突多、解决难:分支生命期越长,意味着与主干的代码差异越大,冲突概率越高,冲突的解决难度越大(甚至成为不可能);


  • 迭代速度慢:特性分支生命期长(数天至数周)意味着特性上线速度慢,相应的迭代速度也慢;


  • 需要较多测试环境:每个特性分支都需要分配至少1个测试环境,且长期占用(有状态);



适用环境:



  • 对版本迭代速度要求不高


  • 测试自动化程度低,或说主要靠人工测试的



主干开发模式


主干开发,是指开发人员直接向主干(习惯上主干分支通常为:trunk 或 master)提交/推送代码。通常,开发团队的成员1天至少1次地将代码提交到主干分支。在到达发布条件时,从主干拉出发布分支(通常为 release),用于发布。若发现缺陷,直接在主干上修复,并根据需要 cherry pick 到对应版本的发布分支。


流程:



优点:



  • 分支模型简单高效,开发人员易于掌握不容易出现错误操作


  • 避免了分支合并、冲突解决的困扰


  • 随时拥有可发布的版本


  • 有利于持续集成和持续交付



缺点:



  • 基础架构要求高:合入到主干的代码若质量不过关将直接阻塞整个团队的开发工作,因此需要高效的持续集成平台进行把关;


  • 自动化测试要求高:需有完备单元测试代码,确保在代码合入主干前能在获得快速和可靠的质量反馈;


  • 最好有代码评审:若代码质量要求高,需要配套代码评审(CR)机制,在代码提交到主干时,触发CR,通过 Peer Review 后才能正式合入;


  • 最好有特性开关:主干开发频发合入主干的情况下,特性拆分得很小,可能是半成品特性,需要配套特性开关(Feature Toggle),只有当特性整体开发完才通过灰度发布等手段逐步打开;



适用环境:



  • 对迭代速度要求高,希望需求快速交付上线


  • 基础架构强,持续集成工具高效;


  • 团队成员习惯TDD(测试驱动开发),代码自动化测试覆盖率高(至少增量代码的自动化测试覆盖率高);





为什么 Google 和腾讯采用主干开发模式


互联网巨头 Google 大部分业务开发都采用主干开发模式,国内巨头腾讯也在推行主干开发(试点业务团队大部分已经采用)。


他们采用主干开发的原因在于对主干开发的优点有强烈诉求,而且有能力和资源弥补其缺点:



  • 都是互联网企业,竞争激烈,因此对迭代速度要求高;


  • 基础架构能力强:都能自研强大的持续集成平台,Google 有自研的 Forge,腾讯有自研的蓝盾;


  • 自动化测试能力强:都推行TDD,强调开发负责质量,减少甚至取消手工测试人员(少量必要的手工测试转外包),自动化测试覆盖率高;


  • 都有严格的CR机制确保代码质量:Google 极其严苛的可读性认证(Readability)在业界已经是标杆,腾讯是国内少有正在采用类似实践的互联网企业。严格的代码可读性认证和根据此标准执行的严格代码评审制度,能有效的保证合入主干的代码质量不会降低。



主干开发的最大优点是:效率和质量,而这2者是软件和互联网企业的核心诉求。主干开发的缺点,巨头有能力和资源来填平这些坑。


因此,从ROI(Ratio of Investment)的角度来看,Google 和腾讯采用主干开发实属必然。




美中两巨头的实践


Google 在主干开发的实践


我们在之前的文章提到,Google 的工程效能(也叫研发效能)核心理念只有简单的3条:



  1. 使用单体代码仓库(参考:Google 工程效能三板斧之一:单体代码仓库


  2. 使用 Bazel 构建(参考:Google 工程效能三板斧之二:使用 Bazel 构建


  3. 主干开发;



其中的第3条,就是本文所述内容。


为了保证主干代码的质量,避免出现工程师合入到主干的代码 break 掉主干的情况,Google 采取了以下实践:



  • 代码合入事件触发通过持续集成,确保合入到主干的代码经过充分且必要测试;


  • 通过 Bazel 实现相关代码(指依赖变更代码的代码)的精准测试;


  • 至少 2 个合资格的 reviewer (代码评审人)的 LGTM(Look Good To Me),才允许代码合入主干;


  • 合资格的 reviewer 都是在 Google 内部通过 Readability (代码可读性)认证的员工;



腾讯在主干开发的实践


腾讯某 BG 在2018年开始的“930变革”后,在各试点团队推动主干开发(注:并未全公司普遍采用),具体的举措包括:



  1. 以度量牵引:通过对特性分支)的生命期监控和预警,实现非主干分支的生命期缩短,倒逼开发团队采用主干开发;


  2. 投大力气统一 BG 内的持续集成工具、开发自动化测试平台;


  3. 制定了 7 大编程语言的编码规范,并自研代码静态扫描工具;


  4. 并参考 Google 推行代码可读性(Readability)、可测试性(Testability)认证制度;


  5. 强力推行 CR (代码评审)制度,确保代码的可读性(命名、代码风格、设计、复杂度)。



效果:



  • 质量提升:代码质量从可测量的维度得到明显提升(代码规范率、单元测试覆盖率);


  • 迭代速度提升:试点团队的迭代周期从4周或2周提升至1周;


  • 代码从“私有”变“公有”:通过代码评审制度,提高了代码可读性,使代码从个人拥有(只有写代码的人能看懂),变成团队拥有(整个团队都能看懂);这一点对于企业非常重要,接手过别人代码的程序们都有感受;


  • 代码的自动化测试覆盖率提升明显,为未来的重构构筑了一张安全网;





中小企业能参考什么?


中小企业应该选择特性分支开发模式,还是主干开发模式?根据上文,相信大家已经足以自行判断。


有些中小企业的技术决策者非常认可持续集成/持续交付的理念,从而更希望采用主干开发,但对于主干开发的缺点(或说弥补缺点的成本)存在顾虑。


对此,我有如下建议:



  • 基础架构要求:可以考虑采用开源软件,如持续集成采用 Jenkins、Travis CI、Gitlab CI等,通过简单部署可以投入使用;同时配合代码静态分析工具(如 SonarQube、CheckStyle),确保代码基本质量过关;


  • 自动化测试要求:工具上不存在障碍,现代编程语言(如java、go、c++)都有内建或第三方的单元测试框架;难点只在于成员的开发习惯,可以通过测试覆盖率工具,以增量覆盖率指标保证新增代码都有完备的自动化测试,从而逐步改变团队的研发文化;


  • 代码评审要求:开源的Git服务器(如 Gitlab)基本都支持 push hook,配合开源的 Gerrit 等CR工具,可以实现在代码推送(push)或 pull request(合入请求)时触发1个代码评审请求,实现评审通过后,代码才正式合入的功能;剩下的就是研发文化问题了,需要在团队内部推行代码规范、代码可读性等宣导和教育工作;


  • 发布时的特性开关:如果要求不高,可以通过代码 hard code 一个常量作为特性开关;如果要求高,也有开源的特性开关(比如:unleash、piranha、flipper)工具可供选择。



参考上述建议,并充分认识到主干开发的成本和困难的情况下,中小企业开发团队也并非不可以考虑主干开发的实践。


收起阅读 »

我不是个黑客,但我就喜欢安全。快看如何开拓你的开发价值!!

安卓逆向4-使用AndroidKiller插入广告页文章目录 任务要求 1.安装配置AndroidKiller 2.反编译和拷贝替换 3.反编译 任务要求 利用Androidki...
继续阅读 »




安卓逆向4-使用AndroidKiller插入广告页

文章目录







任务要求


利用Androidkiller重新做一遍添加启动页作业。


1.安装配置AndroidKiller


下载v1.3.1压缩包,解压后运行AndroidKiller.exe,提示“未检测到Java SDK环境”。
此时打开app-debug.apk,软件提示“APK 反编译失败,无法继续下一步源码反编译!”。


配置Java环境:打开AK,选择左上角的“配置”-Java-安装路径,选择Java的bin目录。
再次打开apk文件,仍然提示“反编译失败”。


报错原因是软件自带的apktool版本过旧,点击软件左上角“Android”-APKTOOL管理器,添加本地的apktool。


在这里插入图片描述


在这里插入图片描述


在这里插入图片描述




2.反编译和拷贝替换


??打开apk-debug.apk文件,软件会自动编译,效果如图。
"工程信息"模块包含app名称、包名、程序入口点,"工程管理"模块包含目录结构。
在这里插入图片描述
本次插入的广告页与上次作业相同,回顾一下需要插入哪些文件。
1.拷贝图片hands_make_dream.jpg:把要插入的图片拷贝到AndroidKiller_v1.3.1\projects\app-debug\Project\res\drawable目录下。


2.添加布局文件activity_advert.xml:把要插入的布局文件拷贝到AndroidKiller_v1.3.1\projects\app-debug\Project\res\layout目录下。
修改布局文件的编号:在AndroidKiller_v1.3.1\projects\app-debug\Project\smali\com\example\four目录下的R$layout.smali文件中,添加advert布局文件的自定义编号,如.field public static final text_view_without_line_height:I = 0x7f0b005f


3.添加广告页的smail文件:把广告页的两个文件advert.smaliadvert$1.smali拷贝到AndroidKiller_v1.3.1\projects\app-debug\Project\smali\com\example\four目录下。
修改smail文件编号:打开advert.smali文件,把文件编号0x7f09001c修改为编号0x7f0b005f。


4.修改包名:目标apk的包名是com.example.four,修改广告页的两个smail文件advert.smali和advert$1.smail,把原包名retwo_login全部替换为目标apk的包名信息four


5.修改仓库文件:在AndroidKiller_v1.3.1\projects\app-debug\Project目录下找到AndroidManifest.xml文件,修改activity信息,修改后的activity信息如下。


	<activity android:name="com.example.four.MainActivity"></activity>
<activity android:name="com.example.four.advert">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>



3.反编译


??在AndroidKiller_v1.3.1中选择"Android"-“编译”,即可一键编译和签名。
在这里插入图片描述


安卓手机安装app-debug_killer.apk,运行效果如下。
首页映入眼帘的是插入的广告页文字和图片,停留5秒后跳到正常页面。


在这里插入图片描述
在这里插入图片描述

收起阅读 »

快来为你的照片添加个性标签吧!

1. 前言 PS:最近在项目执行过程中有这样一个需求,要求拍完照的图片必须达到以上的效果。需求分析: 使用用预览布局SurfaceView,在不局上方使用控件的方式来进行设计,最后通过截图的方式将画面进行保存。 使用图片添加水印的方式来完成。 ...
继续阅读 »

1. 前言


需求图.png


PS:最近在项目执行过程中有这样一个需求,要求拍完照的图片必须达到以上的效果。需求分析:




  1. 使用用预览布局SurfaceView,在不局上方使用控件的方式来进行设计,最后通过截图的方式将画面进行保存。




  2. 使用图片添加水印的方式来完成。




2. 方法1 使用SurfaceView


我心想这不简单吗?于是开始一顿balabala的操作,结果到最后一步时发现,SurfaceView居然不能进行截图,截图下来的图片居然是一张黑色的。简单地说这是因为SurfaceView的特性决定的,我们知道安卓中唯一可以在子线程中进行绘制的view就只有Surfaceview了。他可以独立于子线程中绘制,不会导致主线程的卡顿,至于造成surfaceView黑屏的原因,可以移步这里
Android视图SurfaceView的实现原理分析。如果非要使用此方式时还是有三种思路来进行解决:
采用三种思路:


1. 获取源头视频的截图作为SurfaceView的截图
2. 获取SurfaceView的画布canvas,将canvas保存成Bitmap
3. 直接截取整个屏幕,然后在截图SurfaceView位置的图
复制代码

但是我觉得这种方式太过繁琐,所以选择用添加水印的式来完成。


3. 方法2 给拍照下来的图片添加水印


第一步:获取拍照权限


<!--相机权限-->
<uses-permission android:name="android.permission.CAMERA" />
<!--访问外部权限-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
复制代码

这里使用到郭霖大佬的开源库PermissionX获取权限:


PermissionX.init(this)
.permissions(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
.onExplainRequestReason { scope, deniedList ->
val message = "需要您同意以下权限才能正常使用"
scope.showRequestReasonDialog(deniedList, message, "确定", "取消")
}
.request { allGranted, grantedList, deniedList ->
if (allGranted) {
openCamera()
} else {
Toast.makeText(activity, "您拒绝了如下权限:$deniedList", Toast.LENGTH_SHORT).show()
}
}
复制代码

第二步:拍照


android 6.0以后,相机权限需要动态申请。


 // 申请相机权限的requestCode
private static final int PERMISSION_CAMERA_REQUEST_CODE = 0x00000012;

/**
* 检查权限并拍照。
* 调用相机前先检查权限。
*/
private void checkPermissionAndCamera() {
int hasCameraPermission = ContextCompat.checkSelfPermission(getApplication(),
Manifest.permission.CAMERA);
if (hasCameraPermission == PackageManager.PERMISSION_GRANTED) {
//有调起相机拍照。
openCamera();
} else {
//没有权限,申请权限。
ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.CAMERA},
PERMISSION_CAMERA_REQUEST_CODE);
}
}

/**
* 处理权限申请的回调。
*/
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
if (requestCode == PERMISSION_CAMERA_REQUEST_CODE) {
if (grantResults.length > 0
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
//允许权限,有调起相机拍照。
openCamera();
} else {
//拒绝权限,弹出提示框。
Toast.makeText(this,"拍照权限被拒绝",Toast.LENGTH_LONG).show();
}
}
}
复制代码

调用相机进行拍照


申请权限后,就可以调起相机拍照了。调用相机只需要调用startActivityForResult传一个Intent就可以了,但是这个Intent需要传递一个uri,用于保存拍出来的图片,创建这个uri时,各个Android版本有所不同,需要进行版本兼容。


  //用于保存拍照图片的uri
private Uri mCameraUri;

// 用于保存图片的文件路径,Android 10以下使用图片路径访问图片
private String mCameraImagePath;

// 是否是Android 10以上手机
private boolean isAndroidQ = Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q;

/**
* 调起相机拍照
*/
private void openCamera() {
Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
// 判断是否有相机
if (captureIntent.resolveActivity(getPackageManager()) != null) {
File photoFile = null;
Uri photoUri = null;

if (isAndroidQ) {
// 适配android 10
photoUri = createImageUri();
} else {
try {
photoFile = createImageFile();
} catch (IOException e) {
e.printStackTrace();
}

if (photoFile != null) {
mCameraImagePath = photoFile.getAbsolutePath();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
//适配Android 7.0文件权限,通过FileProvider创建一个content类型的Uri
photoUri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", photoFile);
} else {
photoUri = Uri.fromFile(photoFile);
}
}
}

mCameraUri = photoUri;
if (photoUri != null) {
captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
captureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
startActivityForResult(captureIntent, CAMERA_REQUEST_CODE);
}
}
}

/**
* 创建图片地址uri,用于保存拍照后的照片 Android 10以后使用这种方法
*/
private Uri createImageUri() {
String status = Environment.getExternalStorageState();
// 判断是否有SD卡,优先使用SD卡存储,当没有SD卡时使用手机存储
if (status.equals(Environment.MEDIA_MOUNTED)) {
return getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new ContentValues());
} else {
return getContentResolver().insert(MediaStore.Images.Media.INTERNAL_CONTENT_URI, new ContentValues());
}
}

/**
* 创建保存图片的文件
*/
private File createImageFile() throws IOException {
String imageName = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date());
File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES);
if (!storageDir.exists()) {
storageDir.mkdir();
}
File tempFile = new File(storageDir, imageName);
if (!Environment.MEDIA_MOUNTED.equals(EnvironmentCompat.getStorageState(tempFile))) {
return null;
}
return tempFile;
}
复制代码

接收拍照结果


  @Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == CAMERA_REQUEST_CODE) {
if (resultCode == RESULT_OK) {
if (isAndroidQ) {
// Android 10 使用图片uri加载
ivPhoto.setImageURI(mCameraUri);
} else {
// 使用图片路径加载
ivPhoto.setImageBitmap(BitmapFactory.decodeFile(mCameraImagePath));
}
} else {
Toast.makeText(this,"取消",Toast.LENGTH_LONG).show();
}
}
}
复制代码

注意:


这两需要说明一下,Android 10由于文件权限的关系,显示手机储存卡里的图片不能直接使用图片路径,需要使用图片uri加载。


另外虽然我在这里对Android 10和10以下的手机使用了不同的方式创建uri 和加载图片,但其实Android 10创建uri的方式和使用uri加载图片的方式在10以下的手机是同样适用的。
android 7.0需要配置文件共享。


<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
复制代码

在res目录下创建文件夹xml ,放置一个文件file_paths.xml(文件名可以随便取),配置需要共享的文件目录,也就是拍照图片保存的目录。


<?xml version="1.0" encoding="utf-8"?>
<resources>
<paths>
<!-- 这个是保存拍照图片的路径,必须配置。 -->
<external-files-path
name="images"
path="Pictures" />
</paths>
</resources>
复制代码

第三步:给拍照后得到的图片添加水印


  @Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == CAMERA_REQUEST_CODE) {
if (resultCode == RESULT_OK) {
Bitmap mp;
if (isAndroidQ) {
// Android 10 使用图片uri加载
mp = MediaStore.Images.Media.getBitmap(this.contentResolver, t.uri);
} else {
// Android 10 以下使用图片路径加载
mp = BitmapFactory.decodeFile(uri);
}
//对图片添加水印 这里添加一张图片为示例:
ImageUtil.drawTextToLeftTop(this,mp,"示例文字",30,R.color.black,20,30)
} else {
Toast.makeText(this,"取消",Toast.LENGTH_LONG).show();
}
}
}
复制代码

这里使用到一个ImageUtil工具类,我在这里贴上。如果需要使用可以直接拿走~


public class ImageUtil {
/**
* 设置水印图片在左上角
*
* @param context 上下文
* @param src
* @param watermark
* @param paddingLeft
* @param paddingTop
* @return
*/
public static Bitmap createWaterMaskLeftTop(Context context, Bitmap src, Bitmap watermark, int paddingLeft, int paddingTop) {
return createWaterMaskBitmap(src, watermark,
dp2px(context, paddingLeft), dp2px(context, paddingTop));
}

private static Bitmap createWaterMaskBitmap(Bitmap src, Bitmap watermark, int paddingLeft, int paddingTop) {
if (src == null) {
return null;
}
int width = src.getWidth();
int height = src.getHeight();
//创建一个bitmap
Bitmap newb = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);// 创建一个新的和SRC长度宽度一样的位图
//将该图片作为画布
Canvas canvas = new Canvas(newb);
//在画布 0,0坐标上开始绘制原始图片
canvas.drawBitmap(src, 0, 0, null);
//在画布上绘制水印图片
canvas.drawBitmap(watermark, paddingLeft, paddingTop, null);
// 保存
canvas.save(Canvas.ALL_SAVE_FLAG);
// 存储
canvas.restore();
return newb;
}

/**
* 设置水印图片在右下角
*
* @param context 上下文
* @param src
* @param watermark
* @param paddingRight
* @param paddingBottom
* @return
*/
public static Bitmap createWaterMaskRightBottom(Context context, Bitmap src, Bitmap watermark, int paddingRight, int paddingBottom) {
return createWaterMaskBitmap(src, watermark,
src.getWidth() - watermark.getWidth() - dp2px(context, paddingRight),
src.getHeight() - watermark.getHeight() - dp2px(context, paddingBottom));
}

/**
* 设置水印图片到右上角
*
* @param context
* @param src
* @param watermark
* @param paddingRight
* @param paddingTop
* @return
*/
public static Bitmap createWaterMaskRightTop(Context context, Bitmap src, Bitmap watermark, int paddingRight, int paddingTop) {
return createWaterMaskBitmap(src, watermark,
src.getWidth() - watermark.getWidth() - dp2px(context, paddingRight),
dp2px(context, paddingTop));
}

/**
* 设置水印图片到左下角
*
* @param context
* @param src
* @param watermark
* @param paddingLeft
* @param paddingBottom
* @return
*/
public static Bitmap createWaterMaskLeftBottom(Context context, Bitmap src, Bitmap watermark, int paddingLeft, int paddingBottom) {
return createWaterMaskBitmap(src, watermark, dp2px(context, paddingLeft),
src.getHeight() - watermark.getHeight() - dp2px(context, paddingBottom));
}

/**
* 设置水印图片到中间
*
* @param src
* @param watermark
* @return
*/
public static Bitmap createWaterMaskCenter(Bitmap src, Bitmap watermark) {
return createWaterMaskBitmap(src, watermark,
(src.getWidth() - watermark.getWidth()) / 2,
(src.getHeight() - watermark.getHeight()) / 2);
}

/**
* 给图片添加文字到左上角
*
* @param context
* @param bitmap
* @param text
* @return
*/
public static Bitmap drawTextToLeftTop(Context context, Bitmap bitmap, String text, int size, int color, int paddingLeft, int paddingTop) {
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(color);
paint.setTextSize(dp2px(context, size));
Rect bounds = new Rect();
paint.getTextBounds(text, 0, text.length(), bounds);
return drawTextToBitmap(context, bitmap, text, paint, bounds,
dp2px(context, paddingLeft),
dp2px(context, paddingTop) + bounds.height());
}

/**
* 绘制文字到右下角
*
* @param context
* @param bitmap
* @param text
* @param size
* @param color
* @return
*/
public static Bitmap drawTextToRightBottom(Context context, Bitmap bitmap, String text, int size, int color, int paddingRight, int paddingBottom) {
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(color);
paint.setTextSize(dp2px(context, size));
Rect bounds = new Rect();
paint.getTextBounds(text, 0, text.length(), bounds);
return drawTextToBitmap(context, bitmap, text, paint, bounds,
bitmap.getWidth() - bounds.width() - dp2px(context, paddingRight),
bitmap.getHeight() - dp2px(context, paddingBottom));
}

/**
* 绘制文字到右上方
*
* @param context
* @param bitmap
* @param text
* @param size
* @param color
* @param paddingRight
* @param paddingTop
* @return
*/
public static Bitmap drawTextToRightTop(Context context, Bitmap bitmap, String text, int size, int color, int paddingRight, int paddingTop) {
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(color);
paint.setTextSize(dp2px(context, size));
Rect bounds = new Rect();
paint.getTextBounds(text, 0, text.length(), bounds);
return drawTextToBitmap(context, bitmap, text, paint, bounds,
bitmap.getWidth() - bounds.width() - dp2px(context, paddingRight),
dp2px(context, paddingTop) + bounds.height());
}

/**
* 绘制文字到左下方
*
* @param context
* @param bitmap
* @param text
* @param size
* @param color
* @param paddingLeft
* @param paddingBottom
* @return
*/
public static Bitmap drawTextToLeftBottom(Context context, Bitmap bitmap, String text, int size, int color, int paddingLeft, int paddingBottom) {
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(color);
paint.setTextSize(dp2px(context, size));
Rect bounds = new Rect();
paint.getTextBounds(text, 0, text.length(), bounds);
return drawTextToBitmap(context, bitmap, text, paint, bounds,
dp2px(context, paddingLeft),
bitmap.getHeight() - dp2px(context, paddingBottom));
}

/**
* 绘制文字到中间
*
* @param context
* @param bitmap
* @param text
* @param size
* @param color
* @return
*/
public static Bitmap drawTextToCenter(Context context, Bitmap bitmap, String text, int size, int color) {
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(color);
paint.setTextSize(dp2px(context, size));
Rect bounds = new Rect();
paint.getTextBounds(text, 0, text.length(), bounds);
return drawTextToBitmap(context, bitmap, text, paint, bounds,
(bitmap.getWidth() - bounds.width()) / 2,
(bitmap.getHeight() + bounds.height()) / 2);
}

//图片上绘制文字
private static Bitmap drawTextToBitmap(Context context, Bitmap bitmap, String text, Paint paint, Rect bounds, int paddingLeft, int paddingTop) {
android.graphics.Bitmap.Config bitmapConfig = bitmap.getConfig();

paint.setDither(true); // 获取跟清晰的图像采样
paint.setFilterBitmap(true);// 过滤一些
if (bitmapConfig == null) {
bitmapConfig = android.graphics.Bitmap.Config.ARGB_8888;
}
bitmap = bitmap.copy(bitmapConfig, true);
Canvas canvas = new Canvas(bitmap);

canvas.drawText(text, paddingLeft, paddingTop, paint);
return bitmap;
}

/**
* 缩放图片
*
* @param src
* @param w
* @param h
* @return
*/
public static Bitmap scaleWithWH(Bitmap src, double w, double h) {
if (w == 0 || h == 0 || src == null) {
return src;
} else {
// 记录src的宽高
int width = src.getWidth();
int height = src.getHeight();
// 创建一个matrix容器
Matrix matrix = new Matrix();
// 计算缩放比例
float scaleWidth = (float) (w / width);
float scaleHeight = (float) (h / height);
// 开始缩放
matrix.postScale(scaleWidth, scaleHeight);
// 创建缩放后的图片
return Bitmap.createBitmap(src, 0, 0, width, height, matrix, true);
}
}

/**
* dip转pix
*
* @param context
* @param dp
* @return
*/
public static int dp2px(Context context, float dp) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dp * scale + 0.5f);
}
}

复制代码

4. 最终实现的效果如下:


效果.jpg


5.总结


整体来说没有什么太大的问题,添加水印的原理就是通过Canvas绘制的方式将文字/图片添加到图片上。最后再将修改之后的图片呈现给用户。同时也记录下SurfaceView截图黑屏的问题。


作者:LiChengZe_Blog
链接:https://juejin.cn/post/6960579316191068197
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Fragment问世这么久,你真的会用吗?

Fragment的简单用法 在一个Activity中添加两个Fragmet,并让这两个Fragment平分屏幕空间 首先新建一个左侧Fragmet布局left_fragment_xml,这里只放置一个按钮 <?xml version="1.0" enco...
继续阅读 »

Fragment的简单用法


在一个Activity中添加两个Fragmet,并让这两个Fragment平分屏幕空间


首先新建一个左侧Fragmet布局left_fragment_xml,这里只放置一个按钮


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="button"/>

</LinearLayout>
复制代码

新建一个右侧Fragment布局叫right_fragment_xml,一个文本框


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#00ff00">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textSize="24sp"
android:text="This is aright Fragment"/>
</LinearLayout>
复制代码

然后分别新建LeftFragmet和RightFragment两个类继承Fragment,并且重写onCreateView()方法


package com.example.fragmenttest

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment

class LeftFragment:Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.left_fragment,container,false)
}
}
复制代码

package com.example.fragmenttest

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment

class LeftFragment:Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.right_fragment,container,false)
}
}
复制代码

最后在man.xml标签中引入Fragment布局


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/leftFrag"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>


<fragment
android:id="@+id/rightFrag"
android:name="com.example.fragmenttest.RightFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>




</LinearLayout>
复制代码

最后运行


截屏2021-05-09 下午8.51.49.png


动态添加Fragment


意思就是在运行程序时候动态添加Fragment


首先我们新建一个要添加得Fragment叫anther_rigt_fragment.xml


这里只是把颜色改为黄色背景


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffff00">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textSize="24sp"
android:text="This is anther right fragment"/>
</LinearLayout>
复制代码

下一步也是一样新建一个AotherRightFragment类继承Fragment,重写onCreateView()方法


kage com.example.fragmenttest

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment

class AntherRightFrogment :Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.anther_right_fragement,container,false)
}
}
复制代码

然后修改man.xml代码,引入FrameLayout布局,把右边的Fragment布局替换


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/leftFrag"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>

<FrameLayout
android:id="@+id/rightFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
/>

</LinearLayout>
复制代码

最后我们修改ManActivity的代码,为button设置监听器,达到点击BUTTON按钮,更换Fragment


class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val button:Button=findViewById(R.id.button)
button.setOnClickListener{
repalce(AntherRightFrogment())
}
repalce(RightFragment())
}
private fun repalce(fragment:Fragment){
//就是获取所在fragment的父容器的管理器,
val fragementManager=supportFragmentManager
//开启一个事务
val transaction=fragementManager.beginTransaction()
//添加和替换Fragment
transaction.replace(R.id.rightFragment,fragment)
//返回栈
transaction.addToBackStack(null)
//提交事务
transaction.commit()

}
复制代码

在Fragment中实现返回栈


按下back建返回上一个Fragment


        //返回栈
transaction.addToBackStack(null)
复制代码

Fragment生命周期


onAttach():Fragment和Activity相关联时调用


onCreate():系统创建Fragment时调用


onCreateView():创建Fragment的布局(视图)调用


onActivityCreated():确保与Fragment相关联的Activity调用完时调用


首先我们修改RightFragment代码来看效果


package com.example.fragmenttest

import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment

class RightFragment :Fragment(){
companion object{
const val TAG="RightFragment"
}
//当Fragment和Activity建立关联时调用
override fun onAttach(context: Context) {
super.onAttach(context)
Log.d(TAG,"onAttach")
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG,"onCteate")
}
//为Fragment创建视图获加载布局时调用
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
Log.d(TAG,"onCreateView")
return inflater.inflate(R.layout.right_fragment,container,false)
}
//确保和Fragment相关联的Activity已经创建完毕时候调用
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)

Log.d(TAG,"onActivityCreated")
}

override fun onStart() {
super.onStart()
Log.d(TAG,"onStart")
}

override fun onResume() {
super.onResume()
Log.d(TAG,"onResume")
}

override fun onPause() {
super.onPause()
Log.d(TAG,"onPause")
}

override fun onStop() {
super.onStop()
Log.d(TAG,"onStop")
}
//当与Fragment先关联的视图移除时候调用
override fun onDestroyView() {
super.onDestroyView()
Log.d(TAG,"onDestroyView")
}

override fun onDestroy() {
super.onDestroy()
Log.d(TAG,"onDestroy")
}
//当Fragment与Activity解除关联时候调用
override fun onDetach() {
super.onDetach()
Log.d(TAG,"onDetach")
}

}
复制代码

首次加载RightFragment


截屏2021-05-09 下午8.56.39.png


按下Button按钮


截屏2021-05-09 下午8.57.02.png


按下Back键返回


截屏2021-05-09 下午8.57.18.png


再次按下Back键


截屏2021-05-09 下午8.57.34.png


使用限定符


这个作用比较大例如一般平板使用双页符,因为屏幕比较大任性,但是我们手机就不同了屏幕空间小,


只能显示一页,那么怎么才能让运行程序的程序自动判断到底用那页了,这时候就使用我们的限定符(qualifier)实现。


首先我们修改activity.main.xml代码,只留下我们左边的Fragment,就是带一个button按钮的,把它就用做我们手机的单页显示。


修改 android:layout_width="match_parent让它宽度和父布局一样占满空间


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="@+id/leftFrag"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"/>



</LinearLayout>
复制代码

接着在res目录下新建一个layout-large文件夹,然后在这个文件夹下添加一个新的activity_main.xml布局,代码就是前面双页布局,这里只修改屏幕占比,这里large就是一个限定符,认为是large的设备就加载这个个layout-large文件夹布局。


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">

<fragment
android:id="@+id/leftFrag"
android:name="com.example.fragmenttest.LeftFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"/>
<fragment
android:id="@+id/rightFrage"
android:name="com.example.fragmenttest.RightFragment"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3"/>

</LinearLayout>
复制代码

最后运行代码在平板上


截屏2021-05-09 下午8.58.11.png


在手机上


截屏2021-05-09 下午8.58.29.png


最小宽度限定符


large解决了单双页的问题,那么这个large到底多大了,这里我们映入了最小宽度限定符,它允许我们为屏幕指定一个最小值以dp为单位,超过这个最小值,则加载一个布局,那么小于则执行另外一个。


首先我们在res目录下兴建一个layout_sw600dp文件夹,再次兴建activity_main.xml布局


这里的600dp就是一个临界点,代码都一样就是把两个布局加载


运行的结果,当然看你运行的屏幕了,但宽度大于600dp就加载layout_sw600目录下的activity_main.xml布局,小与600dp就加载默认的,就是layout目录下activity_main.xml布局


作者:用户2069379142245
链接:https://juejin.cn/post/6960273662704353294
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

iOS-TCP网络框架(二)

现在我们已经有了TCP连接, Request, Response和Task, 接下来要做的就是把这一切串起来. 具体来说, 我们需要一个管理方建立并管理TCP连接, 提供接口让调用方通过Request向连接中写入数据, 监听连接中读取到的粘包数据并将数据拆分成...
继续阅读 »

现在我们已经有了TCP连接, Request, Response和Task, 接下来要做的就是把这一切串起来. 具体来说, 我们需要一个管理方建立并管理TCP连接, 提供接口让调用方通过Request向连接中写入数据, 监听连接中读取到的粘包数据并将数据拆分成单个Response返回给调用方.

TCP连接部分比较简单, 这里我们直接跳过, 从发起数据请求部分开始.

发起数据请求

站在调用方的角度, 发起一个TCP请求与发起一个HTTP请求并没有什么区别. 调用方通过Request提供URL和相应参数, 然后通过completionHandler回调处理请求对应的响应数据, 就像这样:


// SomeViewController.m

- (void)fetchData {

HHTCPSocketRequest *request = [HHTCPSocketRequest requestWithURL:aTCPUrl parameters:someParams header:someHeader];
HHTCPSocketTask *task = [[HHTCPSocketClient sharedInstance] dataTaskWithRequest:request completionHandler:^(NSError *error, id result) {
if (error) {
//handle error
} else {
//handle result
}
}
[task resume];
}
站在协议实现方的角度, 发起网络请求做的事情会多一些. 我们需要将调用方提供的Request和completionHandler打包成一个Task并保存起来, 当调用方调用Task.resume时, 我们再将Request.data写入Socket. 这部分的主要代码如下:

//HHTCPSocketClient.m

@interface HHTCPSocketClient()<HHTCPSocketDelegate>

@property (nonatomic, strong) HHTCPSocket *socket;

//任务派发表 以序列号为键保存所有已发出但还未收到响应的Request 待收到响应后再根据序列号一一分发
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, HHTCPSocketTask *> *dispatchTable;

...其他逻辑 略
@end

@implementation HHTCPSocketClient

...其他逻辑 略

#pragma mark - Interface(Public)

//新建数据请求任务 调用方通过此接口定义Request的收到响应后的处理逻辑
- (HHTCPSocketTask *)dataTaskWithRequest:(HHTCPSocketRequest *)request completionHandler:(HHNetworkTaskCompletionHander)completionHandler {

__block NSNumber *taskIdentifier;
//1\. 根据Request新建Task
HHTCPSocketTask *task = [HHTCPSocketTask taskWithRequest:request completionHandler:^(NSError *error, id result) {

//4\. Request已收到响应 从派发表中删除
dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
[self.dispatchTable removeObjectForKey:taskIdentifier];
dispatch_semaphore_signal(lock);

!completionHandler ?: completionHandler(error, result);
}];
//2\. 设置Task.client为HHTCPSocketClient 后续会通过Task.client向Socket中写入数据
task.client = self;
taskIdentifier = task.taskIdentifier;

//3\. 将Task保存到派发表中
dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
[self.dispatchTable setObject:task forKey:taskIdentifier];
dispatch_semaphore_signal(lock);

return task;
}

- (NSNumber *)dispatchTask:(HHTCPSocketTask *)task {
if (task == nil) { return @-1; }

[task resume];// 通过task.resume接口发起请求 task.resume会调用task.client.resumeTask方法 task.client就是HHTCPSocketClient
return task.taskIdentifier;
}

#pragma mark - Interface(Friend)

//最终向Socket中写入Request.data的地方 此接口只提供给HHTCPSocketTask使用 对外不可见
- (void)resumeTask:(HHTCPSocketTask *)task {

// 向Socket中写入Request格式化好的数据
if (self.socket.isConnected) {
[self.socket writeData:task.request.requestData];
} else {

NSError *error;
if (self.isNetworkReachable) {
error = HHError(HHNetworkErrorNotice, HHNetworkTaskErrorTimeOut);
} else {
error = HHError(HHNetworkErrorNotice, HHNetworkTaskErrorCannotConnectedToInternet);
}
[task completeWithResponseData:nil error:error];
}
}

@end

//HHTCPSocketTask.m

@interface HHTCPSocketTask ()

- (void)setClient:(id)client;//此接口仅提供给上面的HHTCPSocketClient使用 对外不可见

@end

//对外接口 调用方通过通过此接口发起Request
- (void)resume {
...其他逻辑 略

//通知client将task.request的数据写入Socket
[self.client resumeTask:self];
}

简单描述一下代码流程:

  1. 调用方提供Request和completionHandler回调从HHTCPSocketClient获得一个打包好的Task(通过dataTaskWithRequest:completionHandler:接口), HHTCPSocketClient内部会以(Request.serNum: Task)的形式将其保存在dispatchTable中.

  2. 调用方通过Task.resume发起TCP请求, 待收到服务端响应后HHTCPSocketClient会根据Response.serNum从dispatchTable取出Task然后执行调用方提供的completionHandler回调.(这里为了和系统的NSURLSessionTask保持一致的接口, 我给TCPClient和TCPTask加了一些辅助方法, 代码上绕了一个圈, 实际上, Task.resume就是Socket.writeData:Task.Request.Data).

处理请求响应

正常情况下, 请求发出后, 很快就就会收到服务端的响应二进制数据, 我们要做的就是, 从这些二进制数据中切割出单个Response报文, 然后一一进行分发. 代码如下:


//HHTCPSocketClient.m

@interface HHTCPSocketClient()<HHTCPSocketDelegate>

//保存所有收到的服务端数据 等待解析
@property (nonatomic, strong) NSMutableData *buffer;
...其他逻辑 略
@end

#pragma mark - HHTCPSocketDelegate

//从Socket从读取到数据
- (void)socket:(HHTCPSocket *)sock didReadData:(NSData *)data {
[self.buffer appendData:data]; //1\. 保存读取到的二进制数据

[self readBuffer];//2\. 根据协议解析二进制数据
}

#pragma mark - Parse

//递归截取Response报文 因为读取到的数据可能已经"粘包" 所以需要递归
- (void)readBuffer {
if (self.isReading) { return; }

self.isReading = YES;
NSData *responseData = [self getParsedResponseData];//1\. 从已读取到的二进制中截取单个Response报文数据
[self dispatchResponse:responseData];//2\. 将Response报文派发给对应的Task
self.isReading = NO;

if (responseData.length == 0) { return; }
[self readBuffer]; //3\. 递归解析
}

//根据定义的协议从buffer中截取出单个Response报文
- (NSData *)getParsedResponseData {

NSData *totalReceivedData = self.buffer;
//1\. 每个Response报文必有的16个字节(url+serNum+respCode+contentLen)
uint32_t responseHeaderLength = [HHTCPSocketResponseParser responseHeaderLength];
if (totalReceivedData.length < responseHeaderLength) { return nil; }

//2\. 根据定义的协议读取出Response.content的长度
NSData *responseData;
uint32_t responseContentLength = [HHTCPSocketResponseParser responseContentLengthFromData:totalReceivedData];
//3\. Response.content的长度加上必有的16个字节即为整个Response报文的长度
uint32_t responseLength = responseHeaderLength + responseContentLength;
if (totalReceivedData.length < responseLength) { return nil; }

//4\. 根据上面解析出的responseLength截取出单个Response报文
responseData = [totalReceivedData subdataWithRange:NSMakeRange(0, responseLength)];
self.buffer = [[totalReceivedData subdataWithRange:NSMakeRange(responseLength, totalReceivedData.length - responseLength)] mutableCopy];
return responseData;
}

//将Response报文解析Response 然后交由对应的Task进行派发
- (void)dispatchResponse:(NSData *)responseData {
HHTCPSocketResponse *response = [HHTCPSocketResponse responseWithData:responseData];
if (response == nil) { return; }

if (response.url > TCP_max_notification) {/** 请求响应 */

HHTCPSocketTask *task = self.dispatchTable[@(response.serNum)];
[task completeWithResponse:response error:nil];
} else {/** 推送或心跳 略 */
...
}
}

简单描述下代码流程:

  1. TCPClient监听Socket读取数据回调方法, 将读取到的服务端二进制数据添加到buffer中.

  2. 根据定义的协议从buffer头部开始, 不停地截取出单个Response报文, 直到buffer数据取无可取.

  3. 从2中截取到的Response报文中解析出Response.serNum, 根据serNum从dispatchTable中取出对应的Task(Response.serNum == Request.serNum), 将Response交付给Task. 至此, TCPClient的工作完成.

  4. Task拿到Response后通过completionHandler交付给调用方. 至此, 一次TCPTask完成.

这里需要注意的是, Socket的回调方法我这边默认都是在串行队列中执行的, 所以对buffer的操作并不没有加锁, 如果是在并行队列中执行Socket的回调, 请记得对buffer操作加锁.

处理后台推送

除了Request对应的Response, 服务端有时也会主动发送一些推送数据给客户端, 我们也需要处理一下:


//HHTCPSocketClient.m

- (void)dispatchResponse:(NSData *)responseData {
HHTCPSocketResponse *response = [HHTCPSocketResponse responseWithData:responseData];
if (response == nil) { return; }

if (response.url > TCP_max_notification) {/** 请求响应 略*/
//...
} else if (response.url == TCP_heatbeat) {/** 心跳 略 */
//...
} else {/** 推送 */
[self dispatchRemoteNotification:response];
}
}

//各种推送 自行处理
- (void)dispatchRemoteNotification:(HHTCPSocketResponse *)notification {

switch (notification.url) {
case TCP_notification_xxx: ...
case TCP_notification_yyy: ...
case TCP_notification_zzz: ...
default:break;
}
}

请求超时和取消

TCP协议的可靠性规定了数据会完整的, 有序的进行传输, 但并未规定数据传输的最大时长. 这意味着, 从发起Request到收到Response的时间间隔可能比我们能接受的时间间隔要长. 这里我们也简单处理一下, 代码如下:


//HHTCPSocketTask.m

#pragma mark - Interface

- (void)cancel {
if (![self canResponse]) { return; }

self.state = HHTCPSocketTaskStateCanceled;
[self completeWithResult:nil error:[self taskErrorWithResponeCode:HHNetworkTaskErrorCanceled]];
}

- (void)resume {
if (self.state != HHTCPSocketTaskStateSuspended) { return; }

//发起Request的同时也启动一个timer timer超时直接返回错误并忽略后续的Response
self.timer = [NSTimer scheduledTimerWithTimeInterval:self.request.timeoutInterval target:self selector:@selector(requestTimeout) userInfo:nil repeats:NO];
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];

self.state = HHTCPSocketTaskStateRunning;
[self.client resumeTask:self];
}

#pragma mark - Action

- (void)requestTimeout {
if (![self canResponse]) { return; }

self.state = HHTCPSocketTaskStateCompleted;
[self completeWithResult:nil error:[self taskErrorWithResponeCode:HHNetworkTaskErrorTimeOut]];
}

#pragma mark - Utils

- (BOOL)canResponse {
return self.state <= HHTCPSocketTaskStateRunning;
}

代码很简单, 只是在写入Task.Request的同时也开启一个timer, timer超时就直接忽略Response并返回错误给调用方而已. 对于类似HTTP的GET请求而言, 忽略和取消几乎是等价的. 但对于POST请求而言, 我们需要的可能就是直接断开连接了

心跳

目前为止, 我们已经有了一个简单的TCP客户端, 它可以发送数据请求, 接收数据响应, 还能处理服务端推送. 最后, 我们做一下收尾工作: 心跳

单向的心跳就不说了, 这里我们给到一张Ping-Pong的简易图:




当发送方为客户端时, Ping-Pong通常用来验证TCP连接的有效性. 具体来说, 如果Ping-Pong正常, 那么证明连接有效, 数据传输没有问题, 反之, 要么连接已断开, 要么连接还在但服务器已经过载无力进行恢复, 此时客户端可以选择断开重连或者切换服务器.

当发送方为服务端时, Ping-Pong通常用来验证数据传输的即时性. 具体来说, 当服务端向客户端发送一条即时性消息时通常还会马上Ping一下客户端, 如果客户端即时进行回应, 那么说明Ping之前的即时性消息已经到达, 反之, 消息不够即时, 服务端可能会走APNS再次发送该消息.

Demo中我简单实现了一下Ping-Pong, 代码如下:


//HHTCPSocketHeartbeat

static NSUInteger maxMissTime = 3;
@implementation HHTCPSocketHeartbeat

+ (instancetype)heartbeatWithClient:(id)client timeoutHandler:(void (^)(void))timeoutHandler {

HHTCPSocketHeartbeat *heartbeat = [HHTCPSocketHeartbeat new];
heartbeat.client = client;
heartbeat.missTime = -1;
heartbeat.timeoutHandler = timeoutHandler;
return heartbeat;
}

- (void)start {

[self stop];
self.timer = [NSTimer timerWithTimeInterval:60 target:self selector:@selector(sendHeatbeat) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}

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

- (void)reset {
self.missTime = -1;
[self start];
}

- (void)sendHeatbeat {

self.missTime += 1;
if (self.missTime >= maxMissTime && self.timeoutHandler != nil) {//心跳超时 执行超时回调
self.timeoutHandler();
self.missTime = -1;
}

HHTCPSocketRequest *request = [HHTCPSocketRequest requestWithURL:TCP_heatbeat parameters:@{@"ackNum": @(TCP_heatbeat)} header:nil];
[self.client dispatchDataTaskWithRequest:request completionHandler:nil];
}

- (void)handleServerAckNum:(uint32_t)ackNum {
if (ackNum == TCP_heatbeat) {//服务端返回的心跳回应Pong 不用处理
self.missTime = -1;
return;
}

//服务端发起的Ping 需要回应
HHTCPSocketRequest *request = [HHTCPSocketRequest requestWithURL:TCP_heatbeat parameters:@{@"ackNum": @(ackNum)} header:nil];
[self.client dispatchDataTaskWithRequest:request completionHandler:nil];
}

@end

HHTCPSocketHeartbeat每隔一段时间就会发起一个serNum固定为1的心跳请求Ping一下服务端, 在超时时间间隔内当收到任何服务端回应, 我们认为连接有效, 心跳重置, 否则执行调用方设置的超时回调. 另外, HHTCPSocketHeartbeat还负责回应服务端发起的serNum为随机数的即时性Response(这里的随机数我给的是时间戳).

//HHTCPSocketClient.m

- (void)configuration {

self.heatbeat = [HHTCPSocketHeartbeat heartbeatWithClient:self timeoutHandler:^{//客户端心跳超时回调
// [self reconnect];
SocketLog(@"heartbeat timeout");
}];
}

#pragma mark - HHTCPSocketDelegate

- (void)socket:(HHTCPSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port {
[self.heatbeat reset];//连接成功 客户端心跳启动
}

- (void)socketDidDisconnect:(HHTCPSocket *)sock error:(NSError *)error {
[self.heatbeat stop];//连接断开 客户端心跳停止
}

- (void)socket:(HHTCPSocket *)sock didReadData:(NSData *)data {
[self.heatbeat reset];//收到服务端数据 说明连接有效 重置心跳
//...其他 略
}

//获取到服务端Response
- (void)dispatchResponse:(NSData *)responseData {
HHTCPSocketResponse *response = [HHTCPSocketResponse responseWithData:responseData];
if (response == nil) { return; }

if (response.url == TCP_heatbeat) {/** 心跳 */
[self.heatbeat handleServerAckNum:response.serNum];//回复服务端心跳请求 如果有必要的话
}
}
文件下载/上传?

到目前为止, 我们讨论的都是类似DataTask的数据请求, 并未涉及到文件下载/上传请求, 事实上, 我也没打算在通讯协议上加上这两种请求的支持. 这部分我是这样考虑的:

如果传输的文件比较小, 那么仿照HTTP直接给协议加上ContentType字段, Content以特殊分隔符进行分隔即可.

如果传输的文件比较大, 那么直接在当前连接进行文件传输可能会阻塞其他的数据传输, 这是我们不希望看到的, 所以一定是另起一条连接专用于大文件传输. 考虑到文件传输不太可能像普通数据传输那样需要即时性和服务端推送, 为了节省服务端开销, 文件传输完成后连接也没有必要继续保持. 这里的"建立连接-文件传输-断开连接"其实已经由HTTP实现得很好了, 而且功能还多, 我们没必要再做重复工作.

基于以上考虑, 文件传输这块我更趋向于直接使用HTTP而不是自行实现.

至此, TCP部分的讨论就结束了.



作者:Cooci
链接:https://www.jianshu.com/p/c0df2690e9d4



收起阅读 »

iOS-TCP网络框架(一)

TCP概述TCP是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC793定义. 在因特网协议族中,TCP属于传输层, 位于网络层之上,应用层之下.需要注意的是, TCP只是协议声明, 仅对外声明协议提供的功能, 但本身并不进行任何实现. ...
继续阅读 »
TCP概述

TCP是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC793定义. 在因特网协议族中,TCP属于传输层, 位于网络层之上,应用层之下.

需要注意的是, TCP只是协议声明, 仅对外声明协议提供的功能, 但本身并不进行任何实现. 因此, 在介绍通信协议时, 通常我们还会提及另一个术语: Socket.
Socket并不是一种协议, 而是一组接口(即API). 协议的实现方通过Socket对外提供具体的功能调用. TCP协议的实现方提供的接口就是TCPSocket, UDP协议的实现方提供的接口就是UDPSocket...

通常, 协议的使用方并不直接面对协议的实现方, 而是通过对应的Socket使用协议提供的功能. 因此, 即使以后协议的底层实现进行了任何改动, 但由于对外的接口Socket不变, 使用方也不需要做出任何变更.

TCP协议基于IP协议, 而IP协议属于不可靠协议, 要在一个不可靠协议的的基础上实现一个可靠的数据传输协议是困难且复杂的, TCP的定义者也并不指望所有程序员都能自行实现一遍TCP协议. 所以, 与其说本文是在介绍TCP编程, 倒不如说是介绍TCPSocket编程.

建立通讯连接

通过Socket建立TCP连接是非常简单的, 连接方(客户端)只需要提供被连接方(服务端)的IP地址和端口号去调用连接接口即可, 被连接方接受连接的话, 接口会返回成功, 否则返回失败, 至于底层的握手细节, 双方完全不用关心. 但考虑到网络波动, 前后台切换, 服务器重启等等可能导致的连接主动/被动断开的情况, 客户端这边我会加上必要的重连处理. 主要代码如下:


//HHTCPSocket.h

@class HHTCPSocket;
@protocol HHTCPSocketDelegate <NSObject>

@optional
- (void)socket:(HHTCPSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port; //连接成功

- (void)socketCanNotConnectToService:(HHTCPSocket *)sock; //重连失败
- (void)socketDidDisconnect:(HHTCPSocket *)sock error:(NSError *)error; //连接失败并开始重连

@end

@interface HHTCPSocket : NSObject

@property (nonatomic, weak) id<HHTCPSocketDelegate> delegate;
@property (nonatomic, assign) NSUInteger maxRetryTime; //最大重连次数

- (instancetype)initWithService:(HHTCPSocketService *)service; //service提供ip地址和端口号

- (void)close;
- (void)connect; //连接
- (void)reconnect; //重连
- (BOOL)isConnected;

@end

//HHTCPSocket.m

@implementation HHTCPSocket

- (instancetype)initWithService:(HHTCPSocketService *)service {
if (self = [super init]) {
self.service = service ?: [HHTCPSocketService defaultService];

//1\. 初始化Socket
const char *delegateQueueLabel = [[NSString stringWithFormat:@"%p_socketDelegateQueue", self] cStringUsingEncoding:NSUTF8StringEncoding];
self.reconnectTime = self.maxRetryTime;
self.delegateQueue = dispatch_queue_create(delegateQueueLabel, DISPATCH_QUEUE_SERIAL);
self.socket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:self.delegateQueue];

//2\. 初始化Socket连接线程
self.machPort = [NSMachPort port];
self.keepRuning = YES;
self.socket.IPv4PreferredOverIPv6 = NO; //支持ipv6
[NSThread detachNewThreadSelector:@selector(configSocketThread) toTarget:self withObject:nil];

//3\. 处理网络波动/前后台切换
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didReceivedNetworkChangedNotification:) name:kRealReachabilityChangedNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didReceivedAppBecomeActiveNotification:) name:UIApplicationDidBecomeActiveNotification object:nil];
}
return self;
}

- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}

#pragma mark - Interface

- (void)connect {
if (self.isConnecting || !self.isNetworkReachable) { return; }
self.isConnecting = YES;

[self disconnect];

//去Socket连接线程进行连接 避免阻塞UI
BOOL isFirstTimeConnect = (self.reconnectTime == self.maxRetryTime);
int64_t delayTime = isFirstTimeConnect ? 0 : (arc4random() % 3) + 1;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayTime * NSEC_PER_SEC)), dispatch_get_global_queue(2, 0), ^{
[self performSelector:@selector(connectOnSocketThread) onThread:self.socketThread withObject:nil waitUntilDone:YES];
});
}

- (void)reconnect {

self.reconnectTime = self.maxRetryTime;
[self connect];
}

- (void)disconnect {
if (!self.socket.isConnected) { return; }

[self.socket setDelegate:nil delegateQueue:nil];
[self.socket disconnect];
}

- (BOOL)isConnected {
return self.socket.isConnected;
}

#pragma mark - GCDAsyncSocketDelegate

- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port {
//连接成功 通知代理方
if ([self.delegate respondsToSelector:@selector(socket:didConnectToHost:port:)]) {
[self.delegate socket:self didConnectToHost:host port:port];
}

self.reconnectTime = self.maxRetryTime;
}

- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)error {

if ([self.delegate respondsToSelector:@selector(socketDidDisconnect:error:)]) {
[self.delegate socketDidDisconnect:self error:error];
}
[self tryToReconnect];//连接失败 尝试重连
}

#pragma mark - Action

- (void)configSocketThread {

if (self.socketThread == nil) {
self.socketThread = [NSThread currentThread];
[[NSRunLoop currentRunLoop] addPort:self.machPort forMode:NSDefaultRunLoopMode];
}
while (self.keepRuning) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
}

[[NSRunLoop currentRunLoop] removePort:self.machPort forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantPast]];
[self.socketThread cancel];
self.socket = nil;
self.machPort = nil;
self.socketThread = nil;
self.delegateQueue = nil;
}

- (void)connectOnSocketThread {//实际的调用连接操作在这里

[self.socket setDelegate:self delegateQueue:self.delegateQueue];
[self.socket connectToHost:self.service.host onPort:self.service.port error:nil];
self.isConnecting = NO;
}

#pragma mark - Notification

- (void)didReceivedNetworkChangedNotification:(NSNotification *)notif {
[self reconnectIfNeed];
}

- (void)didReceivedAppBecomeActiveNotification:(NSNotification *)notif {
[self reconnectIfNeed];
}

#pragma mark - Utils

- (void)tryToReconnect {
if (self.isConnecting || !self.isNetworkReachable) { return; }

self.reconnectTime -= 1;
if (self.reconnectTime >= 0) {
[self connect];
} else if ([self.delegate respondsToSelector:@selector(socketCanNotConnectToService:)]) {
[self.delegate socketCanNotConnectToService:self];
}
}

- (NSUInteger)maxRetryTime {
return _maxRetryTime > 0 ? _maxRetryTime : 5;
}

@end

这边因为需要添加重连操作, 所以我在GCDAsyncSocket的基础上又封装了一下, 但总体代码不多, 应该比较好理解. 这里需要注意的是GCDAsyncSocket的连接接口(connectToHost: onPort: error:)是同步调用的, 慢网情况下可能会阻塞线程一段时间, 所以这里我单开了一个线程来做连接操作.

连接建立以后, 就可以读写数据了, 写数据的接口如下:


- (void)writeData:(NSData *)data {
if (!self.isConnected || data.length == 0) { return; }

[self.socket writeData:data withTimeout:-1 tag:socketTag];
}

 //连接成功...
while (1) {

Error *error;
Data *readData = [socket readToLength:1024 error:&error];//同步 读不到数据就阻塞
if (error) { return; }

[self handleData:readData];//同步异步皆可 多为异步
}

具体到我们的代码中, 则是这个样子:
// HHTCPSocket.h

@protocol HHTCPSocketDelegate <NSObject>
//...其他回调方法
- (void)socket:(HHTCPSocket *)sock didReadData:(NSData *)data;//读取到数据回调方法
@end

// HHTCPSocket.m

- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port {
//Socket连接成功 开始读数据
[self.socket readDataWithTimeout:-1 tag:socketTag];
}

- (void)socket:(GCDAsyncSocket *)sock didWriteDataWithTag:(long)tag {
//Socket写数据成功 继续读取数据
[self.socket readDataWithTimeout:-1 tag:socketTag];
}

- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {

//从Socket中读到数据 交由调用方处理
if ([self.delegate respondsToSelector:@selector(socket:didReadData:)]) {
[self.delegate socket:self didReadData:data];
}
[self.socket readDataWithTimeout:-1 tag:socketTag];//继续读取数据
}

现在我们已经可以通过Socket建立一条会自动重连的TCP连接, 然后还可以通过Socket从连接中读写数据, 接下来要做的就是定义一套自己的通讯协议了.

定义通讯协议
  • 为什么需要定义通讯协议

TCP协议定义了连接双方以字节流而不是报文段的方式进行数据传输, 这意味着任何应用层报文(image/text/html...)想要通过TCP进行传输都必须先转化成二进制数据. 另外, TCP实现出于传输效率考虑, 往往会在连接两端各自开辟一个发送数据缓冲区和一个接收数据缓冲区. 因此, 有时应用层通过Socket向连接中写入数据时, 数据其实并没有立即被发送, 而是被放入缓冲区等待合适的时机才会真正的发送. 理想情况下, TCP进行传输数据的流程可能像这样:




但实际情况中, 因为Nagle算法/网络拥堵/拥塞控制/接收方读取太慢等等各种原因, 数据很有可能会在发送缓冲区/接收缓冲区被累积. 所以, 上面的流程更可能是这样:




或者这样:




上面的图都假设应用层报文不到一个MSS(一个MSS一般为1460字节, 这对大部分非文件请求来说都足够了), 当报文超过一个MSS时, TCP底层实现会对报文进行拆分后多次传输, 这会稍微复杂些(不想画图了), 但最后导致的问题是一致的, 解决方案也是一致的.

从上面的图容易看出, 无论数据在发送缓冲区还是接收缓冲区被累积, 对于接收方程序来说都是一样的: 多个应用层报文不分彼此粘作一串导致数据无法还原(粘包).

得益于TCP协议是可靠的传输协议(可靠意味着TCP实现会保证数据不会丢包, 也不会乱序), 粘包的问题很好处理. 我们只需要在发送方给每段数据都附上一份描述信息(描述信息主要包括数据的长度, 解析格式等等), 接收方就可以根据描述信息从一串数据流中分割出单独的每段应用层报文了.

被传输数据和数据的描述一起构成了一段应用层报文, 这里我们称实际想传输的数据为报文有效载荷, 而数据的描述信息为报文头部. 此时, 数据的传输流程就成了这样:




  • 定义一个简单的通讯协议

自定义通讯协议时, 往往和项目业务直接挂钩, 所以这块其实没什么好写的. 但为了继续接下来的讨论, 这里我会给到一个非常简单的Demo版协议, 它长这样:


因为客户端和服务端都可以发送和接收数据, 为了方便描述, 这里我们对客户端发出的报文统一称为Request, 服务端发出的报文统一称为Response.

这里需要注意的是, 这里的Request和Response并不总是一一对应, 比如客户端单向的心跳请求报文服务端是不会响应的, 而服务端主动发出的推送报文也不是客户端请求的.

Request由4个部分组成:

  1. url: 类似HTTP中的统一资源定位符, 32位无符号整数(4个字节). 用于标识客户端请求的服务端资源或对资源进行的操作. 由服务端定义, 客户端使用.

  2. content(可选): 请求携带的数据, 0~N字节的二进制数据. 用于携带请求传输的内容, 传输的内容目前是请求参数, 也可能什么都没有. 解析格式固定为JSON.

  3. serNum: 请求序列号, 32位无符号整数(4个字节). 用于标示请求本身, 每个请求对应一个唯一的序列号, 即使两个请求的url和content都相同. 由客户端生成并传输, 服务端解析并回传. 客户端通过回传的序列号和请求序列号之间的对应关系进行响应数据分发.

  4. contentLen: 请求携带数据长度, 32位无符号整数(4个字节). 用于标示请求携带的数据的长度. 服务端通过contentLen将粘包的数据进行切割后一一解析并处理.

Response由5个部分组成:

  1. url: 同Request.

  2. respCode: 类似HTTP状态码, 32位无符号整数(4个字节).

  3. content(可选): 响应携带的数据, 0~N字节的二进制数据. 携带的数据可能是某个Request的响应数据, 也可能是服务端主动发出的推送数据, 或者, 什么都没有. 解析格式固定为JSON.

  4. serNum: 该Response所对应的Request序列号, 32位无符号整数(4个字节). 若Response并没有对应的Request(比如推送), Response.serNum==Response.url.

  5. contentLen: Response携带的数据长度, 32位无符号整数(4个字节). 用于标示Response携带的数据的长度. 客户端通过contentLen将粘包的数据进行切割后一一解析并处理.

因为只是Demo用, 这个协议会比较随意. 但在实际开发中, 我们应该尽量参考那些成熟的应用层协议(HTTP/FTP...). 比如考虑到后续的业务变更, 应该加上Version字段. 加上ContentType字段以传输其他类型的数据, 压缩字段字节数以节省流量...等等.


实现通讯协议

有了协议以后, 就可以写代码进行实现了. Request部分主要代码如下:

//HHTCPSocketRequest.h

/** URL类型肯定都是后台定义的 直接copy过来即可 命名用后台的 方便调试时比对 */
typedef enum : NSUInteger {
TCP_heatbeat = 0x00000001,
TCP_notification_xxx = 0x00000002,
TCP_notification_yyy = 0x00000003,
TCP_notification_zzz = 0x00000004,

/* ========== */
TCP_max_notification = 0x00000400,
/* ========== */

TCP_login = 0x00000401,
TCP_weibo_list_public = 0x00000402,
TCP_weibo_list_followed = 0x00000403,
TCP_weibo_like = 0x00000404
} HHTCPSocketRequestURL;

+ (instancetype)requestWithURL:(HHTCPSocketRequestURL)url parameters:(NSDictionary *)parameters header:(NSDictionary *)header;


//HHTCPSocketRequest.m

+ (instancetype)requestWithURL:(HHTCPSocketRequestURL)url parameters:(NSDictionary *)parameters header:(NSDictionary *)header {

NSData *content = [parameters yy_modelToJSONData];
uint32_t requestIdentifier = [self currentRequestIdentifier];

HHTCPSocketRequest *request = [HHTCPSocketRequest new];
request.requestIdentifier = @(requestIdentifier);
[request.formattedData appendData:[HHDataFormatter msgTypeDataFromInteger:url]];/** 请求URL */
[request.formattedData appendData:[HHDataFormatter msgSerialNumberDataFromInteger:requestIdentifier]];/** 请求序列号 */
[request.formattedData appendData:[HHDataFormatter msgContentLengthDataFromInteger:(uint32_t)content.length]];/** 请求内容长度 */

if (content != nil) { [request.formattedData appendData:content]; }/** 请求内容 */
return request;
}

+ (uint32_t)currentRequestIdentifier {

static uint32_t currentRequestIdentifier;
static dispatch_semaphore_t lock;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{

currentRequestIdentifier = TCP_max_notification;
lock = dispatch_semaphore_create(1);
});

dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
if (currentRequestIdentifier + 1 == 0xffffffff) {
currentRequestIdentifier = TCP_max_notification;
}
currentRequestIdentifier += 1;
dispatch_semaphore_signal(lock);

return currentRequestIdentifier;
}


HHTCPSocketRequest主要做两件事: 1.为每个Request生成唯一序列号; 2. 根据协议定义将应用层数据转化为相应的二进制数据.

应用层数据和二进制数据间的转化由HHDataFormatter完成, 它负责统一数据格式化接口和大小端问题

接下来是Response部分的代码:

//HHTCPSocketResponse.h

@interface HHTCPSocketResponse : NSObject

+ (instancetype)responseWithData:(NSData *)data;

- (HHTCPSocketRequestURL)url;

- (NSData *)content;
- (uint32_t)serNum;
- (uint32_t)statusCode;
@end

//HHTCPSocketResponse.m

+ (instancetype)responseWithData:(NSData *)data {
if (data.length < [HHTCPSocketResponseParser responseHeaderLength]) {
return nil;
}

HHTCPSocketResponse *response = [HHTCPSocketResponse new];
response.data = data;
return response;
}

- (HHTCPSocketRequestURL)url {
if (_url == 0) {
_url = [HHTCPSocketResponseParser responseURLFromData:self.data];
}
return _url;
}

- (uint32_t)serNum {
if (_serNum == 0) {
_serNum = [HHTCPSocketResponseParser responseSerialNumberFromData:self.data];
}
return _serNum;
}

- (uint32_t)statusCode {
if (_statusCode == 0) {
_statusCode = [HHTCPSocketResponseParser responseCodeFromData:self.data];
}
return _statusCode;
}

- (NSData *)content {
return [HHTCPSocketResponseParser responseContentFromData:self.data];
}

@end

HHTCPSocketResponse比较简单, 它只做一件事: 根据协议定义将服务端返回的二进制数据解析为应用层数据.

最后, 为了方便管理, 我们再抽象出一个Task. Task将负责请求状态, 请求超时, 请求回调等等的管理. 这部分和协议无关, 但很有必要.
Task部分的代码如下:


//HHTCPSocketTask.h

typedef enum : NSUInteger {
HHTCPSocketTaskStateSuspended = 0,
HHTCPSocketTaskStateRunning = 1,
HHTCPSocketTaskStateCanceled = 2,
HHTCPSocketTaskStateCompleted = 3
} HHTCPSocketTaskState;

@interface HHTCPSocketTask : NSObject

- (void)cancel;
- (void)resume;

- (HHTCPSocketTaskState)state;
- (NSNumber *)taskIdentifier;

@end

//HHTCPSocketTask.m

//保存Request和completionHandler Request用于将调用方数据写入Socket completionHandler用于将Response交付给调用方
+ (instancetype)taskWithRequest:(HHTCPSocketRequest *)request completionHandler:(HHNetworkTaskCompletionHander)completionHandler {

HHTCPSocketTask *task = [HHTCPSocketTask new];
task.request = request;
task.completionHandler = completionHandler;
task.state = HHTCPSocketTaskStateSuspended;
...其他 略
return task;
}

//处理服务端返回的Response Socket读取到相应的Response报文数据后会调用此接口
- (void)completeWithResponse:(HHTCPSocketResponse *)response error:(NSError *)error {
if (![self canResponse]) { return; }

NSDictionary *result;
if (error == nil) {

if (response == nil) {
error = [self taskErrorWithResponeCode:HHTCPSocketResponseCodeUnkonwn];
} else {

error = [self taskErrorWithResponeCode:response.statusCode];
result = [NSJSONSerialization JSONObjectWithData:response.content options:0 error:nil];
}
}

[self completeWithResult:result error:error];
}

//将处理后的数据交付给调用方
- (void)completeWithResult:(id)result error:(NSError *)error {

...其他 略
dispatch_async(dispatch_get_main_queue(), ^{

!self.completionHandler ?: self.completionHandler(error, result);
self.completionHandler = nil;
});
}



作者:Cooci
链接:https://www.jianshu.com/p/c0df2690e9d4





收起阅读 »

drawable用Kotlin应该这样写

Kotlin应该这样写系列 SharedPreferences用Kotlin应该这样写 Glide用Kotlin应该这样封装(一) Glide用Kotlin应该这样封装(二) 前言 通常我们在res/drawable下面自定义shape和selector来满足...
继续阅读 »

Kotlin应该这样写系列


SharedPreferences用Kotlin应该这样写


Glide用Kotlin应该这样封装(一)


Glide用Kotlin应该这样封装(二)


前言


通常我们在res/drawable下面自定义shapeselector来满足一些UI的设计,但是由于xml最终转换为drawable需要经过IO或反射创建,会有一些性能损耗,另外随着项目的增大和模块化等,很多通用的样式并不能快速复用,需要合理的项目资源管理规范才能实施。那么通过代码直接创建这些drawable,可以在一定程度上降低这些副作用。本篇介绍用kotlin DSL简洁的语法特性来实现常见的drawable.


代码对应效果预览


shape_line
RECTANGLE
OVAL
LayerList
Selector

集成和使用



  1. 在项目级的build.gradle文件种添加仓库Jitpack:


allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
复制代码


  1. 添加依赖


dependencies {		
implementation 'com.github.forJrking:DrawableDsl:0.0.3’
}
复制代码


  1. 抛弃xml创建方式示例(其他参见demo)


// infix用法用于去掉括号更加简洁,详细后面说明
image src shapeDrawable {
//指定shape样式
shape(ShapeBuilder.Shape.RECTANGLE)
//圆角,支持4个角单独设置
corner(20f)
//solid 颜色
solid("#ABE2E3")
//stroke 颜色,边框dp,虚线设置
stroke(R.color.white, 2f, 5f, 8f)
}
//按钮点击样式
btn.background = selectorDrawable {
//默认样式
normal = shapeDrawable {
corner(20f)
gradient(90, R.color.F97794, R.color.C623AA2)
}
//点击效果
pressed = shapeDrawable {
corner(20f)
solid("#84232323")
}
}
复制代码

实现思路


xml如何转换成drawable


xml变成drawable,通过android.graphics.drawable.DrawableInflater这个类来IO解析标签创建,然后通过解析标签再设置属性:


//标签创建
private Drawable inflateFromTag(@NonNull String name) {
switch (name) {
case "selector":
return new StateListDrawable();
case "level-list":
return new LevelListDrawable();
case "layer-list":
return new LayerDrawable();
....
case "color":
return new ColorDrawable();
case "shape":
return new GradientDrawable();
case "vector":
return new VectorDrawable();
...
}
}
//反射创建
private Drawable inflateFromClass(@NonNull String className) {
try {
Constructor<? extends Drawable> constructor;
synchronized (CONSTRUCTOR_MAP) {
constructor = CONSTRUCTOR_MAP.get(className);
if (constructor == null) {
final Class<? extends Drawable> clazz = mClassLoader.loadClass(className).asSubclass(Drawable.class);
constructor = clazz.getConstructor();
CONSTRUCTOR_MAP.put(className, constructor);
}
}
return constructor.newInstance();
} catch (NoSuchMethodException e) {
...
}
复制代码

代码实现


由于创建shape等需要设置各种属性来构建,比较符合build设计模式,那我们首先封装build模式的shapeBuilder,这样做虽然代码比起直接使用apply{}要多,但是可以让纯java项目用起来很舒服,其他实现请查看源码:


class ShapeBuilder : DrawableBuilder {
private var mRadius = 0f
private var mWidth = 0f
private var mHeight = 0f
...
private var mShape = GradientDrawable.RECTANGLE
private var mSolidColor = 0

/**分别设置四个角的圆角*/
fun corner(leftTop: Float,rightTop: Float,leftBottom: Float,rightBottom: Float): ShapeBuilder {
....if(dp)dp2px(leftTop) else leftTop
return this
}

fun solid(@ColorRes colorId: Int): ShapeBuilder {
mSolidColor = ContextCompat.getColor(context, colorId)
return this
}
// 省略其他参数设置方法 详细代码查看源码
override fun build(): Drawable {
val gradientDrawable = GradientDrawable()
gradientDrawable = GradientDrawable()
gradientDrawable.setColor(mSolidColor)
gradientDrawable.shape = mShape
....其他参数设置
return gradientDrawable
}
}
复制代码

把build模式转换为dsl


理论上所有的build模式都可以轻松转换为dsl写法:


inline fun shapeDrawable(builder: ShapeBuilder.() -> Unit): Drawable {
return ShapeBuilder().also(builder).build()
}
//使用方法
val drawable = shapeDrawable{
...
}
复制代码

备注:dsl用法参见juejin.cn/post/695318… 中dsl小节


函数去括号


通过上面封装已经实现了dsl的写法,通常setBackground可以通过setter简化,但是我发现由于有些api设计还需要加括号,这样不太kotlin:


//容易阅读
iv1.background = shapeDrawable {
shape(ShapeBuilder.Shape.RECTANGLE)
solid("#ABE2E3")
}
//多了括号看起来不舒服
iv2.setImageDrawable(shapeDrawable {
solid("#84232323")
})
复制代码

怎么去掉括号呢?🈶2种方式infix函数(中缀表达)和property setter



  1. infix函数特点和规范:



  • Kotlin允许在不使用括号和点号的情况下调用函数

  • 必须只有一个参数

  • 必须是成员函数或扩展函数

  • 不支持可变参数和带默认值参数


/**为所有ImageView添加扩展infix函数 来去掉括号*/
infix fun ImageView.src(drawable: Drawable?) {
this.setImageDrawable(drawable)
}
//使用如下
iv2 src shapeDrawable {
shape(ShapeBuilder.Shape.OVAL)
solid("#E3ABC2")
}
复制代码

当然了代码是用来阅读的。个人认为如果我们大量使用infix函数,阅读困难会大大增加,所以建议函数命名必须可以直击函数功能,而且函数功能简单且单一。



  1. property setter方式,主要使用kotlin可以简化setter变量 =来去括号:


/**扩展变量*/
var ImageView.src: Drawable
get() = drawable
set(value) {
this.setImageDrawable(value)
}
//使用如下
iv2.src = shapeDrawable {
shape(ShapeBuilder.Shape.OVAL)
solid("#E3ABC2")
}
复制代码

感谢@叮凛凛 指点,欢迎大家讨论一起学习,共同进步。


优缺点


优点:



  • 代码直接创建比起xml方式可以提升性能

  • dsl方式比起build模式和调用方法设置更加简洁符合kotlin风格

  • 通过合适的代码管理可以复用这些代码,比xml管理方便


缺点:



  • 没有as的预览功能,只有通过上机观测

  • api还没有覆盖所有drawable属性(例如shape = ring等)


后语


上面把的DrawableDsl基础用法介绍完了,欢迎大家使用,欢迎提Issues,记得给个star哦。Github链接:github.com/forJrking/D…


作者:forJrking
链接:https://juejin.cn/post/6953472037012635655
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Kotlin 高效解析数学表达式(支持函数)

需求 由于项目需求,需要在低性能设备高频率地解析计算数学表达式,所以重量级的比如词法分析,语法分析,抽象语法树🌲三件套就不太合适了。(当然也不是不行,只是有点大材小用,而个人能力又有限,对于ANTLR调优之类不太擅长) 说起数学公式解析,当然离不开老朋友逆波兰...
继续阅读 »

需求


由于项目需求,需要在低性能设备高频率地解析计算数学表达式,所以重量级的比如词法分析,语法分析,抽象语法树🌲三件套就不太合适了。(当然也不是不行,只是有点大材小用,而个人能力又有限,对于ANTLR调优之类不太擅长)


说起数学公式解析,当然离不开老朋友逆波兰表达式,也就是后缀表达式,他能忽略括号等优先级问题,按照顺序一一计算。本次也打算从这里入手,慢慢再加入其他一些特性。


先总结下需求:



  • 支持加减乘除与括号,以及括号嵌套。

  • 支持负数/负表达式以及连续负号。比如 1*-2=-2, 1*--2=2, 1*-(2+3)=-5

  • 支持固定的函数,以及函数与表达式的相互嵌套。比如 1+abs(-2-3)=6

  • 支持常量,例如 pi, e


因为使用场景频率较高,所以解析过程中不要使用临时对象,如果非要使用要建立对象池避免 GC 压力过大。


基础实现


首先是最基本的,中缀转后缀表达式。再捋一下基本思想:


准备一个 OP 栈存放操作符。从头开始扫描字符串,遇到数字则直接输出(暂且称之为输出),否则假设 OP 栈顶元素为 A,扫描到的标识符为 B:



  1. 若 B 是右括号,则将栈中元素依次出栈输出,直到左括号。括号出栈不输出。

  2. 若 A 为空 或 A 是左括号 或 B 是左括号,则 B 入栈。

  3. 若 B 优先级大于 A,则 B 入栈。

  4. 否则 A 出栈输出并循环,直到满足上述条件之一。


扫描结束后将 OP 元素全部出栈输出,得到完整的后缀表达式。


按照上述算法,表达式 2*(3+4) 转换后输出为 234+*。然后只需要按顺序扫描输出,遇到数字入栈,遇到运算符出栈操作数,然后将计算结果再入栈,最后栈中唯一元素就是答案了。


不难看出这种方法相当于遍历了两遍。其实我们可以再准备一个表达式 exp 栈,在输出的过程中直接入 exp,如果遇到操作符,则进行计算再将结果入 exp。这样只需要遍历一遍就能得到答案。



💡 Tips


复盘 B 入栈的条件,可以发现栈中可能存在的元素包含运算符与左括号。右括号永远不会入栈。也就是说 A 可能的值为运算符与左括号。反直觉地,我们只需要将左括号(定义为优先级最低,即可将括号也作为运算符看待一并计入优先级比较,避免一小段特殊逻辑代码。

但请注意 AB 都是左括号时,虽然优先级相等,但是 B 应当入栈而不是 A 出栈。 因此相比于直接定义 int 优先级,我更偏向于定义一个二维 bool 数组,用于快捷查找任意两个符号比较,应当出栈还是入栈。



到此为止已经解决了四则运算与括号的问题。


额外需求


函数


我的基本思想就是将函数作为一个特殊的操作符(和加号一样)处理。那么就会产生几个问题:



  1. 函数的操作数都在操作符的后面。

  2. 函数的优先级如何定义。

  3. 参数的分隔符,怎么办。

  4. 函数什么时候可以出栈?


第一个问题其实不影响什么。我们思考一下常规减号-的处理流程。首先将左边操作数输出,然后将减号入栈,最后输出右边操作数。对比一下函数:首先将函数(特殊操作符)入栈,然后将参数依次输出(当作操作数)。显然,两者最终的输出以及栈是一样的,即:操作数从左至右依次输出,操作符入栈。因此操作数与操作符的相对位置并不重要。


问题二则更简单,显然函数的优先级应该是最高,遇到直接入栈就完事了,但是两个函数之间该如何比较?其实这种情况根本不可能发生,因为函数后必定紧跟着一个左括号(,一旦左括号入栈,则后续的操作符必定直接入栈。因此函数入栈时,栈顶不可能也是函数。


至于参数分隔符,我将其视为一个表达式的结束。因为每一个参数都可能是一个嵌套的表达式,我们知道函数在执行前必须先计算出所有参数的值。如此一来,参数分隔符,和右括号)一样不需要入栈,同时要弹出栈中的其他元素,直到遇左括号(但是不要把左括号出栈,因为左括号和函数是一体的,分隔符只是参数的结束,并不是函数的结束。


最后,既然函数的优先级最高,那么什么时候才能轮到它出栈呢?显而易见肯定不能等最后集中出栈,否则就成了薛定谔的参数了。之前讨论到函数的操作数都在右边,换句话说,当操作数全部输出时,就应该立即让函数出栈。答案呼之欲出:当函数结束的时候函数本身应该出栈,而右括号)就是函数的结束符。这个符号在基础章节已经被定义过了,遇到右括号,我们要循环出栈直到左括号。此时应该加上一条:左括号出栈后如果栈顶元素是函数,那么将其出栈并输出。 这就能保证在后缀表达式中函数可以紧挨着它的所有参数。


负号


负号可以视为一个单目运算符,为了区别于减号(-),这里用波浪线(~)指代负号。那么只需解决两个问题:



  1. 什么情况下减号需要视为负号?

  2. 负号优先级如何?


先来解决简单的,负号优先级应该是最高(除了函数之外)。因为负号总是和后面的数或表达式结合,不受前方运算符的影响。比如 1*~2=2


下面为了解决问题一,我罗列了整个表达式中所有可能的数据类型,然后一一判断此时减号应该如何解释。



























前方符号语义
左括号 (负号
其他运算符 +-x/负号
数字减号
右括号)额外判断

右括号等情况稍微复杂,正常来说前面是个表达式,那么后边应该为减号。但是我们的语法允许空括号 (),这种表达式经过解析后其实什么都没有。所以遇到右括号)得额外判断 exp 栈,如果栈不为空且栈顶是数字则为负号~,否则为减号-


这样我们就需要用一个变量来记录上一个符号的类型,以便判断语义时使用。连续负号情况不需要额外处理。


常量


常量可以说是最简单的额外需求了,简单到甚至不需要额外处理。读取到常量后直接视为数字处理就行了。


解析问题


最后来说一下解析问题。因为一个 Token 可能由多个字符组成,例如数字 12.3 有4个字符,函数 abs 有3个字符。所以本质上我们要手写一个定制的词法解析器,因为语法相对简单,所以难度不算太大。考虑到使用场景,这里的宗旨是整个字符串只扫描一遍,不回退不预读。 容错就不过多考虑了,最外层之间 try 一下。


首先是最简单的常量与函数解析。先来明确下他们的语法定义:以字母开头,只能包含字母数字与下划线_,大小写敏感。我们就可以提炼出下面的特征:(为了便于描述,这里把常量和函数名统称为标识符id)



  • 遇到的第一个字母一定是 id 的开头。

  • 之后遇到的第一个非字母数字下划线一定表示 id 结束。


那么算法就水到渠成了。为了提高 id 拼凑效率,我在外部定义了一个 StringBuilder 用来拼接。当然,还有其他方案,例如记录 id 首末 index 然后一次性提取等。




接下来是识别数字,包括小数。数字可以包含数字和小数点.。类似的,遇到的一个数字或小数点.表示一个数字的开始,之后遇到的第一个非数字且非小数点表示数字的结束。


可以用上面 id 的方法来记录数字字符串,最后一次性地调用 kotlin 标准库函数转换为 double 类型。不过这里扣个字眼:标准库转换的时候必然要扫描字符串,而我们已经扫描一遍了,所以做了重复工作。为此,我定义一个 double 变量 num=0,以及一个表示小数位数的 double fraction=1。扫描到数字后将 num *10 然后加上当前数字。若在小数点之后,则先把 fraction / 10,然后乘以当前数字最后加到 num 上。


如此一来,在扫描的同时我们就实现了字符串到数字类型的转换。


作者:晨鹤
链接:https://juejin.cn/post/6957336568046714917
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Compose Preview 的 UX 设计之旅

本文由来自 Android Developer UX 团队的 Preethi Srinivas (UX 研究员) 和 Paris Hsu (交互设计师) 所撰写。 Jetpack Compose 刚刚进入 测试阶段 啦!🎉 在此激动人心的时刻,Android ...
继续阅读 »

本文由来自 Android Developer UX 团队的 Preethi Srinivas (UX 研究员) 和 Paris Hsu (交互设计师) 所撰写。


Jetpack Compose 刚刚进入 测试阶段 啦!🎉 在此激动人心的时刻,Android Developer UX 团队想邀请您进入我们的世界,走进我们设计 Compose Preview 的设计之旅,旅程将从理解我们面临的挑战、方向的形成,以及原型设计和评估开始。


背景: 理解挑战


Jetpack Compose 是新一代 Android 开发的 UI 工具包,它可更简单高效地构建出精美且性能卓越的 Android 应用。它使用了直观的 Kotlin API,能够做到 UI 随着应用状态的变化而自动更新。当我们的团队第一次听说这个项目时,我们无比期待 Compose 项目的无限可能,它具有将逻辑和数据混合绑定到 UI 的潜力,以及为开发者解锁新的能力。然而,这种新的构建 UI 方式也带来了新的设计挑战。


对于经典的 Android 视图,UI 是静态的,且主要是通过 XML 进行创建。这意味着对 XML 的更改几乎可以立即在 UI 中反映出来,我们可以根据这种特性来构建像 Layout Editor 这样的使用体验,让开发者们通过可视化的拖放操作来编辑他们应用的布局,相应的更改也会自动映射到对 XML 的更改。然而,使用 Compose 的每一次修改,都必须编译 Kotlin 代码才能反映出变化,这就意味着需要花费时间,从而减慢了迭代和创建的过程。


集思构想: 冲刺设计方案


为了探究如何在 Compose 中支持这种开发 UI 代码的新模式,我们团队和我们的软件工程师、开发者关系工程师和产品管理伙伴一起举办了一个研讨会,以解决一个设计挑战: 我们如何利用开发者对现有工具的使用经验来帮助他们创建和掌握 Compose UI?


我们基于 设计思维方法,从理解问题和调整问题的工作场景开始思考。这一过程需要团队在 "我们可以怎样... (How Might We…)" 这一框架下写出自己的想法,然后通过亲和图法 (affinitized) 去识别和提炼这些手头的设计问题。我们以之前的研究为出发点,将自己想象为开发者,结合实际开发过程会遇到的不同阶段,来引导小组进行思考,并绘制出解决方案的工具草图。





Compose 设计研讨会



这一设计研讨会帮助我们总结了几点核心原则,为 Compose Tooling 在 2020 年和之后的发展路线奠定了基础:



  • 基于以往为 XML 构建工具所积累的经验为基础

  • 围绕代码进行界面的绘制

  • 优化迭代和实验


这些原则构成了我们产品设计理念前进的基础。例如,Compose Preview 构建出的使用体验在外观和使用上都会让用户觉得很熟悉,在此之上还补充了 Compose 的范式,通过轻量且可重复利用的 Composable 来构建布局。设计研讨会还鼓励我们更多地以代码为中心构建出 REPL 的编程环境,使得开发者在预览代码时拥有更多的控制权和灵活性 — 这样在本质上就提供了一个支持迭代、实验和学习的交互式编程环境。我们还设想了提供超越 XML 之外的新体验,例如 Interactive Preview (互动预览),它可以支持在 IDE 内部被隔离的沙盒环境下的实时交互;Deploy Preview (部署预览),用于在不重新运行整个应用的前提下,将 Preview Composable 部署到模拟器或者真机上。


原型设计: 早期验证


为了验证我们的假设和设计路径,我们开始对研讨会中的想法进行原型设计,并在用户研究案例中对其进行测试。我们开设了一些研究,以便可以验证当前的方向是否正确,并获取关于未来想法和相关投入的反馈。我们选择了一种迭代方法来获取反馈,从而在涉及其他与 Compose 相关主题的多个研究中,将与 Preview 相关的主题进行了折叠。


例如,为了解 Compose Preview 的使用体验,我们首先列出开发者将会问出的问题:



  • 开发者该如何使用 Compose Preview?

  • 在什么情况下,开发者想要预览动态交互的效果?

  • 在真机或模拟器上部署隔离式 Composable 并与之交互的功能对开发者的帮助程度如何?


我们邀请了开发者来加入我们的 Coding Session,在一个以研究为目的而创建的 Compose 项目中完成一些简单的编程练习。这种方式节省了配置开发环境的时间和精力,尤其是 Compose 仍处于开发者预览版之前的阶段,这一方法还能够帮助我们关注开发者在使用 Preview 和其他 Compose API 时的体验。早期的研究确实需要围绕产品稳定性的问题进行展开,因为 Preview 并非总能按照预期正常工作。研究计划预见到了这些不可避免的问题,同时也能够提供非常早期的洞察。




通过 Coding Session 进行可用性研究



从这些 Session 中我们发现,一些开发者会在区分 Preview 工具栏上的 "Refresh" 图标和横幅中的 "Refresh & Build" 图标时感到困惑。大多数开发者不会意识到 "Refresh" 只更新代码而不需要完整构建,而 "Refresh & Build" 则会通过构建更新全部修改。



"如果 Refresh 和 Refresh & Build 希望保持一致,那么将它们放在一起会更好 — 我最初以为 Refresh 按钮只会刷新 UI 而不会构建项目。"



预览 Refresh & Build (之前和之后)



预览 Refresh & Build (之前和之后)



得到该反馈之后,我们决定将两者统一起来,并改进了体验,当用户点击图标或者横幅时,Preview 会根据代码变化的情况来确定是需要进行刷新还是重新构建。


从早期几轮开发者参与的研究中,产生了一个对于 Compose Preview 的深刻体会是,开发者在 Compose 中进行 UI 原型设计时,会感受到一种掌控感,以及工作效率的提升。



"Refresh 模式让我可以快速完成 UI 的原型设计。加上可以使用功能强大的 Kotlin 创建 UI,以及利用 @Preview 函数展示实例数据,比起老式的 XML 中提供的命名空间助手要好得多。"



我们还感受到了开发者在发现 Preview 中同 Composable 交互时能够导航到对应的代码这一功能时,他们所感到的惊讶和喜悦。



"我才发现这个功能,非常开心,我可以在 Preview 中点击不同的视图,直接跳转到绘制该视图的代码里。我很期待在 Jetpack Compose 中看到更多类似的功能。"



在可用性研究中,我们观察到开发者们会通过在 Preview 中点击不同的 UI 元素来跳转到项目的不同地方 — 这需要人们对 Preview 中的 UI 层次结构有着较为深刻的理解。一些开发者发现,当在 Compose Preview 和代码导航之间进行交互时,会有错位的问题出现。例如,在 Column 中的 Text Composable 区域之外点击,会跳转到代码编辑器中定义 Column 的那一行中去。因而我们通过提供 Composable outline 来增强 Preview 的使用体验,以便在布局中围绕 Composable 提供功能可见性。


Preview 代码跳转功能



Preview 代码跳转功能



沉浸式: 以日记形式进行记录


相对而言,在现场亲自参与可用性研究更容易创造价值,并激发出新的想法。然而由于时间的限制,很难深入地去对主题进行挖掘。因此,我们调整了研究方法,开始更多使用一种远程技术,让开发者自己对某个 Compose 项目进行几周的使用。这段时间内,开发者需要写日记,记录他们在指定项目或者自己项目中关于工作流程上的一些问题。通常我们还会在几周的探索之后,再搭配一次访谈,目的是为了更好了解开发者日记中的具体内容。在几天的探索之后,我们还邀请了一些开发者通过 Google Meet 的 Coding Session,来观察并确定哪些部分的工作是进展顺利的,以及一些可以被改进的地方。


通过提问式的日记来帮助反馈的获取



通过提问式的日记来帮助反馈的获取



在这些研究中出现了一点共性 — 开发者会使用 Preview 来创建工作流程,还会利用它进行一些故障排除/验证的工作。例如,在创建 UI 时,开发者会更倾向于使用 Refresh 模式,而在使用手势/交互时,他们会切换到 Interactive 模式,至于 Deploy 模式,则最常用于故障排除和验证检查。



"当我发现在 Interactive 模式下长按可以显示星星的动画时,我非常的开心。但是,之后的长按操作就不管用了 — 动画再也不出现了。通过在模拟器上部署 Preview 模式,我能确认动画是可以正常工作的。如果 Interactive 模式能够更加稳定的话,它将会成为我测试交互性功能和动画的首选模式。有趣的是,在创建新的 UI 并查看它们的渲染方式时,我大部分时间都不需要使用它。"



此外,我们从一些开发者那里得到反馈,在考虑整个布局之前,能够提取并集中实现一个单独的 Composable 的重要性。



"只部署 Preview 意味着我不需要为了测试一个新的组件,而把 UI 关联到实际的流程中 (包含多个界面和用户输入)。这样使得调试 + 改变复杂 UI 变得更加容易。"



将想法付诸于行动


我们在研究的基础之上确立了要前进的方向,这有助于将开发人员对我们工具的见解和遇到的问题反馈到我们的产品迭代中 — 同时能确保我们也能够捕获到新兴的主题来塑造我们的设计理念。以下是几个示例:


Preview 新用户的使用体验


我们发现开发者在探索如何开始创建 Preview 时会有困难 — 很多人在示例项目中留意到了 Preview,但是在自己的项目中就不能够复刻出类似的使用体验。不直观的设计往往导致在创建 Preview Composable 时,对 Compose 编译器到底支持什么功能而产生误解。例如,我们观察到一些开发者试图预览一个接受参数的 Composable,而这一功能 Compose 是不支持的。在这种情况下,编译器提供的错误信息往往会被忽略或遗漏。



"我无法在 Preview 中显示 Split 视图,即使我是直接从一个示例项目中复制过来的代码,它也无法让 Preview 注解正常工作。"



这一重要的发现使我们引入了默认状态,如果 Kotlin 文件尚未定义 Preview Composable,那么拆分编辑器 (这一概念源于 View/XML 世界中的 Preview) 则始终处于可见状态。我们相信该解决方案不仅可以提高对 Preview 的认知和发现能力,还可以提供创建和操作 Preview 相关的学习经验。


Preview 默认状态



Preview 默认状态



增强编码体验


在调查研究中,开发者问了我们这样几个问题:



  • 如何在浅色和深色主题背景中预览一个布局?

  • 如何利用样本数据预览一个布局?

  • 我如何利用 Preview 来确定我的代码中在哪定义了某个特定的 UI 元素?

  • 有没有一种方法可以让 Compose 模仿 View/XML 世界中的 Preview 使用体验,特别是在 Preview 中如何快速查看因为代码变化产生的视觉变化?


这些问题都指向了一点 — 开发者正在寻找一种快速简单的机制来操作 Preview,并期望它能更快地进行迭代。


我们将继续对开发者反馈的新功能进行原型设计和测试,例如 Preview Configuration Picker (Preview 配置选择器),它允许开发者可视化地配置他们的布局 (例如在不同的主题、设备、语言等),以提高 @Preview API 的可发现性和可学习性。


Preview 配置选择器



Preview 配置选择器



另一个例子是 Live literals (实时显示字面量类型),这是来自工程团队的解决方案,通过在 Preview 面板中对一些 Composable 值 (例如 Boolean、Char、String、Color 等) 引入实时更新,来优化迭代开发的速度。


Live Literal 的实际体验



Live Literal 的实际体验



PreviewParameterProvider 是我们将样本数据纳入 Preview 中来允许真实上下文测试的又一例子。


使用 PreviewParameterProvider



使用 PreviewParameterProvider



旅程仍未结束


我们希望这篇文章能让您了解我们是如何根据您的反馈来改进 Compose Preview 的。当然,我们的旅程并没有就此结束!我们还有很多继续改善 Compose Preview 及其工具使用体验的计划。例如,将 Live Literals 功能扩展到字面量类型之外,以继续优化迭代开发的速度。


如果您在使用 Compose 工具时遇到问题,或者是有任何可以改善使用体验的新功能的想法,请 告诉我们。我们也在寻找开发者参与到用户研究 Session 中,您可以 注册 参与。

作者:Android_开发者
链接:https://juejin.cn/post/6959416526562656292
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

CocoaAsyncSocket源码分析---Connect (八)

Connect流程,用一张图来概括总结一下吧:socketchildSocketFD = accept(parentSocketFD, (struct sockaddr *)&addr, &addrLen); 然后调用了newSocketQue...
继续阅读 »

客户端的整个Connect流程,用一张图来概括总结一下吧:



整个客户端连接的流程大致如上图,当然远不及于此,这里我们对地址做了IPV4IPV6的兼容处理,对一些使用socket而产生的网络错误导致进程退出的容错处理。以及在这个过程中,socketQueue、代理queue、全局并发queuestream常驻线程的管理调度等等。

当然其中绝大部分操作都是在socketQueue中进行的。而在socketQueue中,我们也分为两种操作dispatch_syncdispatch_async
因为socketQueue本身就是一个串行queue,所以我们所有的操作都在这个queue中进行保证了线程安全,而需要阻塞后续行为的操作,我们用了sync的方式。其实这样使用sync是及其容易死锁的,但是作者每次在调用sync之前都调用了这么一行判断:

if (dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey))

判断当前队列是否就是这个socketQueue队列,如果是则直接调用,否则就用sync的方式提交到这个queue中去执行。这种防死锁的方式,你学到了么?

接着我们来讲讲服务端Accept流程:

整个流程还是相对Connect来说还是十分简单的,因为这个方法很长,而且大多数是我们直接连接讲到过得内容,所以我省略了一部分的代码,只把重要的展示出来,大家可以参照着源码看。

//监听端口起点
- (BOOL)acceptOnPort:(uint16_t)port error:(NSError **)errPtr
{
return [self acceptOnInterface:nil port:port error:errPtr];
}

- (BOOL)acceptOnInterface:(NSString *)inInterface port:(uint16_t)port error:(NSError **)errPtr
{
LogTrace();

// Just in-case interface parameter is immutable.
//防止参数被修改
NSString *interface = [inInterface copy];

__block BOOL result = NO;
__block NSError *err = nil;

// CreateSocket Block
// This block will be invoked within the dispatch block below.
//创建socket的Block
int(^createSocket)(int, NSData*) = ^int (int domain, NSData *interfaceAddr) {

//创建TCP的socket
int socketFD = socket(domain, SOCK_STREAM, 0);

//一系列错误判断
...
// Bind socket
//用本地地址去绑定
status = bind(socketFD, (const struct sockaddr *)[interfaceAddr bytes], (socklen_t)[interfaceAddr length]);

//监听这个socket
//第二个参数是这个端口下维护的socket请求队列,最多容纳的用户请求数。
status = listen(socketFD, 1024);
return socketFD;
};

// Create dispatch block and run on socketQueue

dispatch_block_t block = ^{ @autoreleasepool {

//一系列错误判断
...

//判断ipv4 ipv6是否支持
...

//得到本机的IPV4 IPV6的地址
[self getInterfaceAddress4:&interface4 address6:&interface6 fromDescription:interface port:port];
...

//判断可以用IPV4还是6进行请求
...

// Create accept sources
//创建接受连接被触发的source
if (enableIPv4)
{
//接受连接的source
accept4Source = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, socket4FD, 0, socketQueue);

//事件句柄
dispatch_source_set_event_handler(accept4Source, ^{ @autoreleasepool {

//拿到数据,连接数
unsigned long numPendingConnections = dispatch_source_get_data(acceptSource);

LogVerbose(@"numPendingConnections: %lu", numPendingConnections);

//循环去接受这些socket的事件(一次触发可能有多个连接)
while ([strongSelf doAccept:socketFD] && (++i < numPendingConnections));

}});

//取消句柄
dispatch_source_set_cancel_handler(accept4Source, ^{
//...
//关闭socket
close(socketFD);

});

//开启source
dispatch_resume(accept4Source);
}

//ipv6一样
...

//在scoketQueue中同步做这些初始化。
if (dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey))
block();
else
dispatch_sync(socketQueue, block);

//...错误判断
//返回结果
return result;
}

这个方法省略完仍然有这么长,它主要做了这两件事(篇幅原因,尽量精简):

  1. 创建本机地址、创建socket、绑定端口、监听端口。
  2. 创建了一个GCD Source,来监听这个socket读source,这样连接事件一发生,就会触发我们的事件句柄。接着我们调用了doAccept:方法循环去接受所有的连接。

接着我们来看这个接受连接的方法(同样省略了一部分不那么重要的代码):

//连接接受的方法
- (BOOL)doAccept:(int)parentSocketFD
{
LogTrace();

int socketType;
int childSocketFD;
NSData *childSocketAddress;

//IPV4
if (parentSocketFD == socket4FD)
{
socketType = 0;

struct sockaddr_in addr;
socklen_t addrLen = sizeof(addr);
//调用接受,得到接受的子socket
childSocketFD = accept(parentSocketFD, (struct sockaddr *)&addr, &addrLen);
//NO说明没有连接
if (childSocketFD == -1)
{
LogWarn(@"Accept failed with error: %@", [self errnoError]);
return NO;
}
//子socket的地址数据
childSocketAddress = [NSData dataWithBytes:&addr length:addrLen];
}
//一样
else if (parentSocketFD == socket6FD)
{
...
}
//unix domin socket 一样
else // if (parentSocketFD == socketUN)
{
...
}

//socket 配置项的设置... 和connect一样

//响应代理
if (delegateQueue)
{
__strong id theDelegate = delegate;
//代理队列中调用
dispatch_async(delegateQueue, ^{ @autoreleasepool {

// Query delegate for custom socket queue

dispatch_queue_t childSocketQueue = NULL;

//判断是否实现了为socket 生成一个新的SocketQueue,是的话拿到新queue
if ([theDelegate respondsToSelector:@selector(newSocketQueueForConnectionFromAddress:onSocket:)])
{
childSocketQueue = [theDelegate newSocketQueueForConnectionFromAddress:childSocketAddress
onSocket:self];
}

// Create GCDAsyncSocket instance for accepted socket
//新创建一个本类实例,给接受的socket
GCDAsyncSocket *acceptedSocket = [[[self class] alloc] initWithDelegate:theDelegate
delegateQueue:delegateQueue
socketQueue:childSocketQueue];
//IPV4 6 un
if (socketType == 0)
acceptedSocket->socket4FD = childSocketFD;
else if (socketType == 1)
acceptedSocket->socket6FD = childSocketFD;
else
acceptedSocket->socketUN = childSocketFD;
//标记开始 并且已经连接
acceptedSocket->flags = (kSocketStarted | kConnected);

// Setup read and write sources for accepted socket
//初始化读写source
dispatch_async(acceptedSocket->socketQueue, ^{ @autoreleasepool {

[acceptedSocket setupReadAndWriteSourcesForNewlyConnectedSocket:childSocketFD];
}});

//判断代理是否实现了didAcceptNewSocket方法,把我们新创建的socket返回出去
if ([theDelegate respondsToSelector:@selector(socket:didAcceptNewSocket:)])
{
[theDelegate socket:self didAcceptNewSocket:acceptedSocket];
}

}});
}
return YES;
}

  • 这个方法很简单,核心就是调用下面这个函数,去接受连接,并且拿到一个新的socket
childSocketFD = accept(parentSocketFD, (struct sockaddr *)&addr, &addrLen);

  • 然后调用了newSocketQueueForConnectionFromAddress:onSocket:这个代理,可以为新的socket重新设置一个socketQueue
  • 接着我们用这个Socket重新创建了一个GCDAsyncSocket实例,然后调用我们的代理didAcceptNewSocket方法,把这个实例给传出去了。
  • 这里需要注意的是,我们调用didAcceptNewSocket代理方法传出去的实例我们需要自己保留,不然就会被释放掉,那么这个与客户端的连接也就断开了。
  • 同时我们还初始化了这个新socket的读写source,这一步完全和connect中一样,调用同一个方法,这样如果有读写数据,就会触发这个新的socketsource了。

建立连接之后的无数个新的socket,都是独立的,它们处理读写连接断开的逻辑就和客户端socket完全一样了。
而我们监听本机端口的那个socket始终只有一个,这个用来监听触发socket连接,并返回创建我们这无数个新的socket实例。

作为服务端的Accept流程就这么结束了,因为篇幅原因,所以尽量精简了一些细节的处理,不过这些处理在Connect中也是反复出现的,所以基本无伤大雅。如果大家会感到困惑,建议下载github中的源码注释,对照着再看一遍,相信会有帮助的。


接着我们来讲讲Unix Domin Socket建立本地进程通信流程:

基本上这个流程,比上述任何流程还要简单,简单的到即使不简化代码,也没多少行(当然这是建立在客户端Connect流程已经实现了很多公用方法的基础上)。

接着进入正题,我们来看看它发起连接的方法:


//连接本机的url上,IPC,进程间通信
- (BOOL)connectToUrl:(NSURL *)url withTimeout:(NSTimeInterval)timeout error:(NSError **)errPtr;
{
LogTrace();

__block BOOL result = NO;
__block NSError *err = nil;

dispatch_block_t block = ^{ @autoreleasepool {

//判断长度
if ([url.path length] == 0)
{
NSString *msg = @"Invalid unix domain socket url.";
err = [self badParamError:msg];

return_from_block;
}

// Run through standard pre-connect checks
//前置的检查
if (![self preConnectWithUrl:url error:&err])
{
return_from_block;
}

// We've made it past all the checks.
// It's time to start the connection process.

flags |= kSocketStarted;

// Start the normal connection process

NSError *connectError = nil;
//调用另一个方法去连接
if (![self connectWithAddressUN:connectInterfaceUN error:&connectError])
{
[self closeWithError:connectError];

return_from_block;
}

[self startConnectTimeout:timeout];

result = YES;
}};

//在socketQueue中同步执行
if (dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey))
block();
else
dispatch_sync(socketQueue, block);

if (result == NO)
{
if (errPtr)
*errPtr = err;
}

return result;
}

连接方法非常简单,就只是做了一些错误的处理,然后调用了其他的方法,包括一个前置检查,这检查中会去判断各种参数是否正常,如果正常会返回YES,并且把url转换成Uinix domin socket地址的结构体,赋值给我们的属性connectInterfaceUN
接着调用了connectWithAddressUN方法去发起连接。

我们接着来看看这个方法:

//连接Unix域服务器
- (BOOL)connectWithAddressUN:(NSData *)address error:(NSError **)errPtr
{
LogTrace();

NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");

// Create the socket

int socketFD;

LogVerbose(@"Creating unix domain socket");

//创建本机socket
socketUN = socket(AF_UNIX, SOCK_STREAM, 0);

socketFD = socketUN;

if (socketFD == SOCKET_NULL)
{
if (errPtr)
*errPtr = [self errnoErrorWithReason:@"Error in socket() function"];

return NO;
}

// Bind the socket to the desired interface (if needed)

LogVerbose(@"Binding socket...");

int reuseOn = 1;
//设置可复用
setsockopt(socketFD, SOL_SOCKET, SO_REUSEADDR, &reuseOn, sizeof(reuseOn));

// Prevent SIGPIPE signals

int nosigpipe = 1;
//进程终止错误信号禁止
setsockopt(socketFD, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, sizeof(nosigpipe));

// Start the connection process in a background queue

int aStateIndex = stateIndex;

dispatch_queue_t globalConcurrentQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_async(globalConcurrentQueue, ^{

const struct sockaddr *addr = (const struct sockaddr *)[address bytes];
//并行队列调用连接
int result = connect(socketFD, addr, addr->sa_len);
if (result == 0)
{
dispatch_async(socketQueue, ^{ @autoreleasepool {
//连接成功的一些状态初始化
[self didConnect:aStateIndex];
}});
}
else
{
// 失败的处理
perror("connect");
NSError *error = [self errnoErrorWithReason:@"Error in connect() function"];

dispatch_async(socketQueue, ^{ @autoreleasepool {

[self didNotConnect:aStateIndex error:error];
}});
}
});

LogVerbose(@"Connecting...");

return YES;
}

主要部分基本和客户端连接相同,并且简化了很多,调用了这一行完成了连接:

int result = connect(socketFD, addr, addr->sa_len);

同样也和客户端一样,在连接成功之后去调用下面这个方法完成了一些资源的初始化:

 [self didConnect:aStateIndex];

基本上连接就这么两个方法了(当然我们省略了一些细节),看完客户端的连接之后,到这就变得非常简单了。

接着我们来看看uinix domin socket作为服务端Accept。

这个Accpet,基本和我们普通Socket服务端的Accept相同。

//接受一个Url,uniex domin socket 做为服务端
- (BOOL)acceptOnUrl:(NSURL *)url error:(NSError **)errPtr;
{
LogTrace();

__block BOOL result = NO;
__block NSError *err = nil;

//基本和正常的socket accept一模一样
// CreateSocket Block
// This block will be invoked within the dispatch block below.
//生成一个创建socket的block,创建、绑定、监听
int(^createSocket)(int, NSData*) = ^int (int domain, NSData *interfaceAddr) {

//creat socket
...
// Set socket options

...
// Bind socket

...

// Listen
...
};

// Create dispatch block and run on socketQueue
//错误判断
dispatch_block_t block = ^{ @autoreleasepool {

//错误判断
...

//判断是否有这个url路径是否正确
...

//调用上面的Block创建socket,并且绑定监听。
...

//创建接受连接的source
acceptUNSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, socketUN, 0, socketQueue);

int socketFD = socketUN;
dispatch_source_t acceptSource = acceptUNSource;
//事件句柄,和accpept一样
dispatch_source_set_event_handler(acceptUNSource, ^{ @autoreleasepool {
//循环去接受所有的每一个连接
...
}});

//取消句柄
dispatch_source_set_cancel_handler(acceptUNSource, ^{

//关闭socket
close(socketFD);
});

LogVerbose(@"dispatch_resume(accept4Source)");
dispatch_resume(acceptUNSource);

flags |= kSocketStarted;

result = YES;
}};

if (dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey))
block();
else
dispatch_sync(socketQueue, block);
//填充错误
if (result == NO)
{
LogInfo(@"Error in accept: %@", err);

if (errPtr)
*errPtr = err;
}

return result;
}

因为代码基本雷同,所以我们省略了大部分代码,大家可以参照着之前的讲解或者源码去理解。这里和普通服务端socket唯一的区别就是,这里服务端绑定的地址是unix domin socket类型的地址,它是一个结构体,里面包含的是我们进行进程通信的纽带-一个本机文件路径。
所以这里服务端简单来说就是绑定的这个文件路径,当这个文件路径有数据可读(即有客户端连接到达)的时候,会触发初始化的source事件句柄,我们会去循环的接受所有的连接,并且新生成一个socket实例,这里和普通的socket完全一样。

就这样我们所有的连接方式已经讲完了,后面这两种方式,为了节省篇幅,确实讲的比较粗略,但是核心的部分都有提到。
另外如果你有理解客户端的Connect流程,那么理解起来应该没有什么问题,这两个流程比前者可简化太多了。


这个框架的Connect篇 全篇结束














收起阅读 »

CocoaAsyncSocket源码分析---Connect (七)

addStreamsToRunLoop这里方法做了两件事:CFStream读写回调的常驻线程,其中调用了好几个函数: + (void)startCFStreamThreadIfNeeded; + (void)cfstreamThread; 在这两个函数中,添...
继续阅读 »

接着我们来到流处理的第三步:addStreamsToRunLoop-添加到runloop上。

Stream相关方法三 -- 加到当前线程的runloop上:

//把stream添加到runloop上
- (BOOL)addStreamsToRunLoop
{
LogTrace();

NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");
NSAssert((readStream != NULL && writeStream != NULL), @"Read/Write stream is null");

//判断flag里是否包含kAddedStreamsToRunLoop,没添加过则添加。
if (!(flags & kAddedStreamsToRunLoop))
{
LogVerbose(@"Adding streams to runloop...");

[[self class] startCFStreamThreadIfNeeded];
//在开启的线程中去执行,阻塞式的
[[self class] performSelector:@selector(scheduleCFStreams:)
onThread:cfstreamThread
withObject:self
waitUntilDone:YES];

//添加标识
flags |= kAddedStreamsToRunLoop;
}

return YES;
}


这里方法做了两件事:

  1. 开启了一条用于CFStream读写回调的常驻线程,其中调用了好几个函数:
 + (void)startCFStreamThreadIfNeeded;
+ (void)cfstreamThread;

在这两个函数中,添加了一个runloop,并且绑定了一个定时器事件,让它run起来,使得线程常驻。

  1. 在这个常驻线程中去调用注册方法:
//注册CFStream
+ (void)scheduleCFStreams:(GCDAsyncSocket *)asyncSocket
{
LogTrace();

//断言当前线程是cfstreamThread,不是则报错
NSAssert([NSThread currentThread] == cfstreamThread, @"Invoked on wrong thread");

//获取到runloop
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
//如果有readStream
if (asyncSocket->readStream)
//注册readStream在runloop的kCFRunLoopDefaultMode上
CFReadStreamScheduleWithRunLoop(asyncSocket->readStream, runLoop, kCFRunLoopDefaultMode);

//一样
if (asyncSocket->writeStream)
CFWriteStreamScheduleWithRunLoop(asyncSocket->writeStream, runLoop, kCFRunLoopDefaultMode);
}

这里可以看到,我们流的回调都是在这条流的常驻线程中,至于为什么要这么做,相信大家楼主看过AFNetworking系列文章的会明白。我们之后文章也会就这个框架线程的问题详细讨论的,这里就暂时不详细说明了。
这里主要用了CFReadStreamScheduleWithRunLoop函数完成了runloop的注册:

CFReadStreamScheduleWithRunLoop(asyncSocket->readStream, runLoop, kCFRunLoopDefaultMode);
CFWriteStreamScheduleWithRunLoop(asyncSocket->writeStream, runLoop, kCFRunLoopDefaultMode);

这样,如果stream中有我们监听的事件发生了,就会在这个runloop中触发我们之前设置的读写回调函数。

我们完成了注册,接下来我们就需要打开stream了:

Stream相关方法四 -- 打开stream:

//打开stream
- (BOOL)openStreams
{
LogTrace();

NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");
//断言读写stream都不会空
NSAssert((readStream != NULL && writeStream != NULL), @"Read/Write stream is null");

//返回stream的状态

CFStreamStatus readStatus = CFReadStreamGetStatus(readStream);
CFStreamStatus writeStatus = CFWriteStreamGetStatus(writeStream);

//如果有任意一个没有开启
if ((readStatus == kCFStreamStatusNotOpen) || (writeStatus == kCFStreamStatusNotOpen))
{
LogVerbose(@"Opening read and write stream...");

//开启
BOOL r1 = CFReadStreamOpen(readStream);
BOOL r2 = CFWriteStreamOpen(writeStream);

//有一个开启失败
if (!r1 || !r2)
{
LogError(@"Error in CFStreamOpen");
return NO;
}
}

return YES;
}

方法也很简单,通过CFReadStreamGetStatus函数,获取到当前stream的状态,判断没开启则调用CFReadStreamOpen函数去开启,如果开启失败,错误返回。

到这里stream初始化相关的工作就做完了,接着我们还是回到本文方法十一 -- 连接成功后的初始化中

其中第5条,我们谈到了设置socket的I/O模式为非阻塞,相信很多朋友对socket的I/O:同步、异步、阻塞、非阻塞。这四个概念有所混淆。
简单的来说,同步、异步是对于客户端而言的。比如我发起一个调用一个函数,我如果直接去调用,那么就是同步的,否则新开辟一个线程去做,那么对于当前线程而言就是异步的。
阻塞和非阻塞是对于服务端而言。当服务端被客户端调用后,我如果立刻返回调用的结果(无论数据是否处理完)那么就是非阻塞的,又或者等待数据拿到并且处理完(总之一系列逻辑)再返回,那么这种情况就是阻塞的。

好了,有了这个概念,我们接下来看看Linux下的5种I/O模型:
1)阻塞I/O(blocking I/O)
2)非阻塞I/O (nonblocking I/O)

  1. I/O复用(select 和poll) (I/O multiplexing)
    4)信号驱动I/O (signal driven I/O (SIGIO))
    5)异步I/O (asynchronous I/O (the POSIX aio_functions))

我们来简单谈谈这5种模型:
1)阻塞I/O:
简单举个例子,比如我们调用read()去读取消息,如果是在阻塞模式下,我们会一直等待,直到有消息到来为止。
很多小伙伴可能又要说了,这有什么不可以,我们新开辟一条线程,让它等着不就行了,看起来确实没什么不可以。
那是因为你仅仅是站在客户端的角度上来看。试想如果我们服务端也这么做,那岂不是有多少个socket连接,我们得开辟多少个线程去做阻塞IO?
2)非阻塞I/O
于是就有了非阻塞的概念,当我们去read()的时候,直接返回结果,这样在很大概率下,是并没有消息给我们读的。这时候函数就会错误返回-1,并将errno设置为 EWOULDBLOCK,意为IO并没有数据。
这时候就需要我们自己有一个机制,能知道什么时候有数据,在去调用read()。有一个很傻的方式就是不停的循环去调用这个函数,这样有数据来,我们第一时间就读到了。
3)I/O复用模式
I/O复用模式阻塞I/O的改进版,它在read之前,会先去调用select去遍历所有的socket,看哪一个有消息。当然这个过程是阻塞的,直到有消息返回为止。然后在去调用read,阻塞的方式去读取从系统内核中去读取这条消息到进程中来。
4)信号驱动I/O
信号驱动I/O是一个半异步的I/O模式,它首先会调用一个系统sginal相关的函数,把socket和信号绑定起来,然后不管有没有消息直接返回(这一步非阻塞)。这时候系统内核会去检查socket是否有可用数据。有的话则发送该信号给进程,然后进程在去调用read阻塞式的从系统内核读取数据到进程中来(这一步阻塞)。
5)可能聪明的你已经想到了更好的解决方式,这就对了,这就是我们第5种IO模式:异步I/O ,它和第4步一样,也是调用sginal相关函数,把socket和信号绑定起来,同时绑定起来的还有一块数据缓冲区buffer。然后无论有没有数据直接返回(非阻塞)。而系统内核会去检查是否有可用数据,一旦有可用数据,则触发信号,并且把数据填充到我们之前提供的数据缓冲区buffer中。这样我们进程被信号触发,并且直接能从buffer中读取到数据,整个过程没有任何阻塞。
很显然,我们CocoaAyncSocket框架用的就是第5种I/O模式。

接着我们继续看本文方法十一 -- 连接成功后的初始化中第6条,读写source的初始化方法:

本文方法十二 -- 初始化读写source:
//初始化读写source
- (void)setupReadAndWriteSourcesForNewlyConnectedSocket:(int)socketFD
{
//GCD source DISPATCH_SOURCE_TYPE_READ 会一直监视着 socketFD,直到有数据可读
readSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, socketFD, 0, socketQueue);
//_dispatch_source_type_write :监视着 socketFD,直到写数据了
writeSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_WRITE, socketFD, 0, socketQueue);

// Setup event handlers

__weak GCDAsyncSocket *weakSelf = self;

#pragma mark readSource的回调

//GCD事件句柄 读,当socket中有数据流出现,就会触发这个句柄,全自动,不需要手动触发
dispatch_source_set_event_handler(readSource, ^{ @autoreleasepool {
#pragma clang diagnostic push
#pragma clang diagnostic warning "-Wimplicit-retain-self"

__strong GCDAsyncSocket *strongSelf = weakSelf;
if (strongSelf == nil) return_from_block;

LogVerbose(@"readEventBlock");
//从readSource中,获取到数据长度,
strongSelf->socketFDBytesAvailable = dispatch_source_get_data(strongSelf->readSource);
LogVerbose(@"socketFDBytesAvailable: %lu", strongSelf->socketFDBytesAvailable);

//如果长度大于0,开始读数据
if (strongSelf->socketFDBytesAvailable > 0)
[strongSelf doReadData];
else
//因为触发了,但是却没有可读数据,说明读到当前包边界了。做边界处理
[strongSelf doReadEOF];

#pragma clang diagnostic pop
}});

//写事件句柄
dispatch_source_set_event_handler(writeSource, ^{ @autoreleasepool {
#pragma clang diagnostic push
#pragma clang diagnostic warning "-Wimplicit-retain-self"

__strong GCDAsyncSocket *strongSelf = weakSelf;
if (strongSelf == nil) return_from_block;

LogVerbose(@"writeEventBlock");
//标记为接受数据
strongSelf->flags |= kSocketCanAcceptBytes;
//开始写
[strongSelf doWriteData];

#pragma clang diagnostic pop
}});

// Setup cancel handlers

__block int socketFDRefCount = 2;

#if !OS_OBJECT_USE_OBJC
dispatch_source_t theReadSource = readSource;
dispatch_source_t theWriteSource = writeSource;
#endif

//读写取消的句柄
dispatch_source_set_cancel_handler(readSource, ^{
#pragma clang diagnostic push
#pragma clang diagnostic warning "-Wimplicit-retain-self"

LogVerbose(@"readCancelBlock");

#if !OS_OBJECT_USE_OBJC
LogVerbose(@"dispatch_release(readSource)");
dispatch_release(theReadSource);
#endif

if (--socketFDRefCount == 0)
{
LogVerbose(@"close(socketFD)");
//关闭socket
close(socketFD);
}

#pragma clang diagnostic pop
});

dispatch_source_set_cancel_handler(writeSource, ^{
#pragma clang diagnostic push
#pragma clang diagnostic warning "-Wimplicit-retain-self"

LogVerbose(@"writeCancelBlock");

#if !OS_OBJECT_USE_OBJC
LogVerbose(@"dispatch_release(writeSource)");
dispatch_release(theWriteSource);
#endif

if (--socketFDRefCount == 0)
{
LogVerbose(@"close(socketFD)");
//关闭socket
close(socketFD);
}

#pragma clang diagnostic pop
});

// We will not be able to read until data arrives.
// But we should be able to write immediately.

//设置未读数量为0
socketFDBytesAvailable = 0;
//把读挂起的状态移除
flags &= ~kReadSourceSuspended;

LogVerbose(@"dispatch_resume(readSource)");
//开启读source
dispatch_resume(readSource);

//标记为当前可接受数据
flags |= kSocketCanAcceptBytes;
//先把写source标记为挂起
flags |= kWriteSourceSuspended;
}


这个方法初始化了读写source,这个方法主要是GCD source运用

这里GCD Source相关的主要是下面这3个函数

//创建source
dispatch_source_create(dispatch_source_type_t type,
uintptr_t handle,
unsigned long mask,
dispatch_queue_t _Nullable queue);
//为source设置事件句柄
dispatch_source_set_event_handler(dispatch_source_t source,
dispatch_block_t _Nullable handler);
//为source设置取消句柄
dispatch_source_set_cancel_handler(dispatch_source_t source,
dispatch_block_t _Nullable handler);


相信大家用至少用过GCD定时器,接触过这3个函数,这里创建source的函数,根据参数type的不同,可以处理不同的事件:


这里我们用的是DISPATCH_SOURCE_TYPE_READDISPATCH_SOURCE_TYPE_WRITE这两个类型。标识如果handle如果有可读或者可写数据时,会触发我们的事件句柄。

  • 而这里初始化的读写事件句柄内容也很简单,就是去读写数据。
  • 而取消句柄也就是去关闭socket
  • 初始化完成后,我们开启了readSource,一旦有数据过来就触发了我们readSource事件句柄,就可以去监听的socket所分配的缓冲区中去读取数据了,而wirteSource初始化完是挂起的。
  • 除此之外我们还初始化了当前source的状态,用于我们后续的操作。

至此我们客户端的整个Connect流程结束了 ,下章概括总结一下Connect

CocoaAsyncSocket源码分析---Connect (二)

CocoaAsyncSocket源码分析---Connect (三)

CocoaAsyncSocket源码分析---Connect (四)

CocoaAsyncSocket源码分析---Connect (五)

CocoaAsyncSocket源码分析---Connect (六)

CocoaAsyncSocket源码分析---Connect (七)

CocoaAsyncSocket源码分析---Connect (八)


作者:Cooci
链接:https://www.jianshu.com/p/b264eff1f326




收起阅读 »

CocoaAsyncSocket源码分析---Connect (六)

本文方法十一 -- 连接成功后的初始化原因是为了线程安全和socket相关的操作必须在queue中被回调。这个方法基本上很简单,就是关于两个stream函数的调用:这个函数创建了一对读写stream,并且把stream与这个scoket做了绑定。相信用过的朋友...
继续阅读 »

我们接着来看看连接成功后,初始化的方法:

本文方法十一 -- 连接成功后的初始化

//连接成功后调用,设置一些连接成功的状态
- (void)didConnect:(int)aStateIndex
{
LogTrace();

NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");

//状态不同
if (aStateIndex != stateIndex)
{
LogInfo(@"Ignoring didConnect, already disconnected");

// The connect operation has been cancelled.
// That is, socket was disconnected, or connection has already timed out.
return;
}

//kConnected合并到当前flag中
flags |= kConnected;
//停止连接超时
[self endConnectTimeout];

#if TARGET_OS_IPHONE
// The endConnectTimeout method executed above incremented the stateIndex.
//上面的endConnectTimeout,会导致stateIndex增加,所以需要重新赋值
aStateIndex = stateIndex;
#endif

// Setup read/write streams (as workaround for specific shortcomings in the iOS platform)
//
// Note:
// There may be configuration options that must be set by the delegate before opening the streams.
//打开stream之前必须用相关配置设置代理
// The primary example is the kCFStreamNetworkServiceTypeVoIP flag, which only works on an unopened stream.
//主要的例子是kCFStreamNetworkServiceTypeVoIP标记,只能工作在未打开的stream中?
//
// Thus we wait until after the socket:didConnectToHost:port: delegate method has completed.
//所以我们要等待,连接完成的代理调用完
// This gives the delegate time to properly configure the streams if needed.
//这些给了代理时间,去正确的配置Stream,如果是必要的话

//创建个Block来初始化Stream
dispatch_block_t SetupStreamsPart1 = ^{

NSLog(@"hello~");
#if TARGET_OS_IPHONE
//创建读写stream失败,则关闭并报对应错误
if (![self createReadAndWriteStream])
{
[self closeWithError:[self otherError:@"Error creating CFStreams"]];
return;
}

//参数是给NO的,就是有可读bytes的时候,不会调用回调函数
if (![self registerForStreamCallbacksIncludingReadWrite:NO])
{
[self closeWithError:[self otherError:@"Error in CFStreamSetClient"]];
return;
}

#endif
};
//part2设置stream
dispatch_block_t SetupStreamsPart2 = ^{
#if TARGET_OS_IPHONE
//状态不一样直接返回
if (aStateIndex != stateIndex)
{
// The socket has been disconnected.
return;
}
//如果加到runloop上失败
if (![self addStreamsToRunLoop])
{
//错误返回
[self closeWithError:[self otherError:@"Error in CFStreamScheduleWithRunLoop"]];
return;
}

//读写stream open
if (![self openStreams])
{
//开启错误返回
[self closeWithError:[self otherError:@"Error creating CFStreams"]];
return;
}

#endif
};

// Notify delegate
//通知代理
//拿到server端的host port
NSString *host = [self connectedHost];
uint16_t port = [self connectedPort];
//拿到unix域的 url
NSURL *url = [self connectedUrl];
//拿到代理
__strong id theDelegate = delegate;

//代理队列 和 Host不为nil 且响应didConnectToHost代理方法
if (delegateQueue && host != nil && [theDelegate respondsToSelector:@selector(socket:didConnectToHost:port:)])
{
//调用初始化stream1
SetupStreamsPart1();

dispatch_async(delegateQueue, ^{ @autoreleasepool {

//到代理队列调用连接成功的代理方法
[theDelegate socket:self didConnectToHost:host port:port];

//然后回到socketQueue中去执行初始化stream2
dispatch_async(socketQueue, ^{ @autoreleasepool {

SetupStreamsPart2();
}});
}});
}
//这个是unix domain 请求回调
else if (delegateQueue && url != nil && [theDelegate respondsToSelector:@selector(socket:didConnectToUrl:)])
{
SetupStreamsPart1();

dispatch_async(delegateQueue, ^{ @autoreleasepool {

[theDelegate socket:self didConnectToUrl:url];

dispatch_async(socketQueue, ^{ @autoreleasepool {

SetupStreamsPart2();
}});
}});
}
//否则只初始化stream
else
{
SetupStreamsPart1();
SetupStreamsPart2();
}

// Get the connected socket

int socketFD = (socket4FD != SOCKET_NULL) ? socket4FD : (socket6FD != SOCKET_NULL) ? socket6FD : socketUN;

//fcntl,功能描述:根据文件描述词来操作文件的特性。http://blog.csdn.net/pbymw8iwm/article/details/7974789
// Enable non-blocking IO on the socket
//使socket支持非阻塞IO
int result = fcntl(socketFD, F_SETFL, O_NONBLOCK);
if (result == -1)
{
//失败 ,报错
NSString *errMsg = @"Error enabling non-blocking IO on socket (fcntl)";
[self closeWithError:[self otherError:errMsg]];

return;
}

// Setup our read/write sources
//初始化读写source
[self setupReadAndWriteSourcesForNewlyConnectedSocket:socketFD];

// Dequeue any pending read/write requests
//开始下一个任务
[self maybeDequeueRead];
[self maybeDequeueWrite];
}

这个方法很长一大串,其实做的东西也很简单,主要做了下面几件事:

  1. 把当前状态flags加上已连接,并且关闭掉我们一开始连接开启的,连接超时的定时器。
  2. 初始化了两个BlockSetupStreamsPart1SetupStreamsPart2,这两个Block做的事都和读写流有关。SetupStreamsPart1用来创建读写流,并且注册回调。另一个SetupStreamsPart2用来把流添加到当前线程的runloop上,并且打开流。
  3. 判断是否有代理queuehost或者url这些参数是否为空、是否代理响应didConnectToHostdidConnectToUrl代理,这两种分别对应了普通socket连接和unix domin socket连接。如果实现了对应的代理,则调用连接成功的代理。
  4. 在调用代理的同时,调用了我们之前初始化的两个读写流相关的Block。这里值得说下的是这两个Block和代理之间的调用顺序:
  • 先执行SetupStreamsPart1后执行SetupStreamsPart2,没什么好说的,问题是代理的执行时间,想想如果我们放在SetupStreamsPart2后面是不是会导致个问题,就是用户收到消息了,但是连接成功的代理还没有被调用,这显然是不合理的。所以我们的调用顺序是SetupStreamsPart1->代理->SetupStreamsPart2

    所以出现了如下代码:


作者:Cooci
链接:https://www.jianshu.com/p/b264eff1f326
  //调用初始化stream1
SetupStreamsPart1();

dispatch_async(delegateQueue, ^{ @autoreleasepool {

//到代理队列调用连接成功的代理方法
[theDelegate socket:self didConnectToHost:host port:port];

//然后回到socketQueue中去执行初始化stream2
dispatch_async(socketQueue, ^{ @autoreleasepool {

SetupStreamsPart2();
}});
}});


原因是为了线程安全和socket相关的操作必须在socketQueue中进行。而代理必须在我们设置的代理queue中被回调。

  1. 拿到当前的本机socket,调用如下函数:
int result = fcntl(socketFD, F_SETFL, O_NONBLOCK);


而在这里,就是为了把socket的IO模式设置为非阻塞。很多小伙伴又要疑惑什么是非阻塞了,先别急,关于这个我们下文会详细的来谈。

  1. 我们初始化了读写source(很重要,所有的消息都是由这个source来触发的,我们之后会详细分析这个方法)。

  2. 我们做完了streamsource的初始化处理,则开始做一次读写任务(这两个方法暂时不讲,会放到之后的ReadWrite篇中去讲)。

我们接着来讲讲这个方法中对其他方法的调用,按照顺序来,先从第2条,两个Block中对stream的处理开始。和stream相关的函数一共有6个:

Stream相关方法一 -- 创建读写stream

//创建读写stream
- (BOOL)createReadAndWriteStream
{
LogTrace();

NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");

//如果有一个有值,就返回
if (readStream || writeStream)
{
// Streams already created
return YES;
}
//拿到socket,首选是socket4FD,其次socket6FD,都没有才是socketUN,socketUN应该是Unix的socket结构体
int socketFD = (socket4FD != SOCKET_NULL) ? socket4FD : (socket6FD != SOCKET_NULL) ? socket6FD : socketUN;

//如果都为空,返回NO
if (socketFD == SOCKET_NULL)
{
// Cannot create streams without a file descriptor
return NO;
}

//如果非连接,返回NO
if (![self isConnected])
{
// Cannot create streams until file descriptor is connected
return NO;
}

LogVerbose(@"Creating read and write stream...");

#pragma mark - 绑定Socket和CFStream
//下面的接口用于创建一对 socket stream,一个用于读取,一个用于写入:
CFStreamCreatePairWithSocket(NULL, (CFSocketNativeHandle)socketFD, &readStream, &writeStream);

// The kCFStreamPropertyShouldCloseNativeSocket property should be false by default (for our case).
// But let's not take any chances.

//读写stream都设置成不会随着绑定的socket一起close,release。 kCFBooleanFalse不一起,kCFBooleanTrue一起
if (readStream)
CFReadStreamSetProperty(readStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanFalse);
if (writeStream)
CFWriteStreamSetProperty(writeStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanFalse);

//如果有一个为空
if ((readStream == NULL) || (writeStream == NULL))
{
LogWarn(@"Unable to create read and write stream...");

//关闭对应的stream
if (readStream)
{
CFReadStreamClose(readStream);
CFRelease(readStream);
readStream = NULL;
}
if (writeStream)
{
CFWriteStreamClose(writeStream);
CFRelease(writeStream);
writeStream = NULL;
}
//返回创建失败
return NO;
}
//创建成功
return YES;
}

这个方法基本上很简单,就是关于两个stream函数的调用:

  1. 创建stream的函数:
CFStreamCreatePairWithSocket(NULL, (CFSocketNativeHandle)socketFD, &readStream, &writeStream);

这个函数创建了一对读写stream,并且把stream与这个scoket做了绑定。

  1. 设置stream属性:
CFReadStreamSetProperty(readStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanFalse);
CFWriteStreamSetProperty(writeStream, kCFStreamPropertyShouldCloseNativeSocket, kCFBooleanFalse);

这个函数可以给stream设置一个属性,这里是设置stream不会随着socket的生命周期(close,release)而变化。

接着调用了registerForStreamCallbacksIncludingReadWrite来给stream注册读写回调。

Stream相关方法二 -- 读写回调的注册:

//注册Stream的回调
- (BOOL)registerForStreamCallbacksIncludingReadWrite:(BOOL)includeReadWrite
{
LogVerbose(@"%@ %@", THIS_METHOD, (includeReadWrite ? @"YES" : @"NO"));

NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");
//判断读写stream是不是都为空
NSAssert((readStream != NULL && writeStream != NULL), @"Read/Write stream is null");

//客户端stream上下文对象
streamContext.version = 0;
streamContext.info = (__bridge void *)(self);
streamContext.retain = nil;
streamContext.release = nil;
streamContext.copyDescription = nil;

// The open has completed successfully.
// The stream has bytes to be read.
// The stream can accept bytes for writing.
// An error has occurred on the stream.
// The end of the stream has been reached.

//设置一个CF的flag 两种,一种是错误发生的时候,一种是stream事件结束
CFOptionFlags readStreamEvents = kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered ;
//如果包含读写
if (includeReadWrite)
//仍然有Bytes要读的时候 The stream has bytes to be read.
readStreamEvents |= kCFStreamEventHasBytesAvailable;

//给读stream设置客户端,会在之前设置的那些标记下回调函数 CFReadStreamCallback。设置失败的话直接返回NO
if (!CFReadStreamSetClient(readStream, readStreamEvents, &CFReadStreamCallback, &streamContext))
{
return NO;
}

//写的flag,也一样
CFOptionFlags writeStreamEvents = kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered;
if (includeReadWrite)
writeStreamEvents |= kCFStreamEventCanAcceptBytes;

if (!CFWriteStreamSetClient(writeStream, writeStreamEvents, &CFWriteStreamCallback, &streamContext))
{
return NO;
}
//走到最后说明读写都设置回调成功,返回YES
return YES;
}

相信用过CFStream的朋友,应该会觉得很简单,这个方法就是调用了一些CFStream相关函数,其中最主要的这个设置读写回调函数:
Boolean CFReadStreamSetClient(CFReadStreamRef stream, CFOptionFlags streamEvents, CFReadStreamClientCallBack clientCB, CFStreamClientContext *clientContext);
Boolean CFWriteStreamSetClient(CFWriteStreamRef stream, CFOptionFlags streamEvents, CFWriteStreamClientCallBack clientCB, CFStreamClientContext *clientContext);


这个函数共4个参数:
第1个为我们需要设置的stream;
第2个为需要监听的事件选项,包括以下事件:

typedef CF_OPTIONS(CFOptionFlags, CFStreamEventType) {
kCFStreamEventNone = 0, //没有事件发生
kCFStreamEventOpenCompleted = 1, //成功打开流
kCFStreamEventHasBytesAvailable = 2, //流中有数据可读
kCFStreamEventCanAcceptBytes = 4, //流中可以接受数据去写
kCFStreamEventErrorOccurred = 8, //流发生错误
kCFStreamEventEndEncountered = 16 //到达流的结尾
};

其中具体用法,大家可以自行去试试,这里作者只监听了了两种事件kCFStreamEventErrorOccurredkCFStreamEventEndEncountered,再根据传过来的参数去决定是否监听kCFStreamEventCanAcceptBytes

//如果包含读写
if (includeReadWrite)
//仍然有Bytes要读的时候 The stream has bytes to be read.
readStreamEvents |= kCFStreamEventHasBytesAvailable;

而这里我们传过来的参数为NO,导致它并不监听可读数据。显然,我们正常的连接,当有消息发送过来,并不是由stream回调来触发的。这个框架中,如果是TLS传输的socket是用stream来触发的,这个我们后续文章会讲到。

那么有数据的时候,到底是什么来触发我们的读写呢,答案就是读写source,我们接下来就会去创建初始化它。

这里绑定了两个函数,分别对应读和写的回调,分别为:

//读的回调
static void CFReadStreamCallback (CFReadStreamRef stream, CFStreamEventType type, void *pInfo)
//写的回调
static void CFWriteStreamCallback (CFWriteStreamRef stream, CFStreamEventType type, void *pInfo)

关于这两个函数,同样这里暂时不做讨论,等后续文章再来分析。

还有一点需要说一下的是streamContext这个属性,它是一个结构体,包含流的上下文信息,其结构如下:

typedef struct {
CFIndex version;
void *info;
void *(*retain)(void *info);
void (*release)(void *info);
CFStringRef (*copyDescription)(void *info);
} CFStreamClientContext;


这个流的上下文中info指针,其实就是前面所对应的读写回调函数中的pInfo指针,每次回调都会传过去。其它的version就是流的版本标识,之外的3个都需要的是一个函数指针,对应我们传递的pInfo的持有以及释放还有复制的描述信息,这里我们都赋值给nil

下一章我们来到流处理的第三步


CocoaAsyncSocket源码分析---Connect (一)
CocoaAsyncSocket源码分析---Connect (八)
作者:Cooci
链接:https://www.jianshu.com/p/b264eff1f326







收起阅读 »

CocoaAsyncSocket源码分析---Connect (五)

上文我们提到了GCDAsyncSocket的初始化,以及最终connect之前的准备工作,包括一些错误检查;本机地址创建以及socket创建;服务端地址的创建;还有一些本机socket可选项的配置,例如禁止网络出错导致进程关闭的信号等我们去用之前创建的本机地址...
继续阅读 »
上文我们提到了GCDAsyncSocket的初始化,以及最终connect之前的准备工作,包括一些错误检查;本机地址创建以及socket创建;服务端地址的创建;还有一些本机socket可选项的配置,例如禁止网络出错导致进程关闭的信号等

言归正传,继续上文往下讲
上文讲到了本文方法八--创建Socket,其中有这么一行代码:
//和connectInterface绑定
if (![self bindSocket:socketFD toInterface:connectInterface error:errPtr])
{
//绑定失败,直接关闭返回
[self closeSocket:socketFD];

return SOCKET_NULL;
}

我们去用之前创建的本机地址去做socket绑定,接着会调用到如下方法中:

本文方法九--给Socket绑定本机地址
//绑定一个Socket的本地地址
- (BOOL)bindSocket:(int)socketFD toInterface:(NSData *)connectInterface error:(NSError **)errPtr
{
// Bind the socket to the desired interface (if needed)
//无接口就不绑定,connect会自动绑定到一个不冲突的端口上去。
if (connectInterface)
{
LogVerbose(@"Binding socket...");

//判断当前地址的Port是不是大于0
if ([[self class] portFromAddress:connectInterface] > 0)
{
// Since we're going to be binding to a specific port,
// we should turn on reuseaddr to allow us to override sockets in time_wait.

int reuseOn = 1;

//设置调用close(socket)后,仍可继续重用该socket。调用close(socket)一般不会立即关闭socket,而经历TIME_WAIT的过程。
setsockopt(socketFD, SOL_SOCKET, SO_REUSEADDR, &reuseOn, sizeof(reuseOn));
}

//拿到地址
const struct sockaddr *interfaceAddr = (const struct sockaddr *)[connectInterface bytes];
//绑定这个地址
int result = bind(socketFD, interfaceAddr, (socklen_t)[connectInterface length]);

//绑定出错,返回NO
if (result != 0)
{
if (errPtr)
*errPtr = [self errnoErrorWithReason:@"Error in bind() function"];

return NO;
}
}

//成功
return YES;
}

这个方法也非常简单,如果没有connectInterface则直接返回YES,当socket进行连接的时候,会自动绑定一个端口,进行连接。
如果有值,则我们开始绑定到我们一开始指定的地址上。
这里调用了两个和scoket相关的函数:
第一个是我们之前提到的配置scoket参数的函数:

setsockopt(socketFD, SOL_SOCKET, SO_REUSEADDR, &reuseOn, sizeof(reuseOn));

这里调用这个函数的主要目的是为了调用close的时候,不立即去关闭socket连接,而是经历一个TIME_WAIT过程。在这个过程中,socket是可以被复用的。我们注意到之前的connect流程并没有看到复用socket的代码。注意,我们现在走的连接流程是客户端的流程,等我们讲到服务端accept进行连接的时候,我们就能看到这个复用的作用了。

第二个是bind函数
int result = bind(socketFD, interfaceAddr, (socklen_t)[connectInterface length]);

这个函数倒是很简单,就3个参数,socket、需要绑定的地址、地址大小。这样就把socket和这个地址(其实就是端口)捆绑在一起了。

这样我们就做完了最终连接前所有准备工作,本机socket有了,服务端的地址也有了。接着我们就可以开始进行最终连接了:

本文方法十 -- 建立连接的最终方法

//连接最终方法 3 finnal。。。
- (void)connectSocket:(int)socketFD address:(NSData *)address stateIndex:(int)aStateIndex
{
// If there already is a socket connected, we close socketFD and return
//已连接,关闭连接返回
if (self.isConnected)
{
[self closeSocket:socketFD];
return;
}

// Start the connection process in a background queue
//开始连接过程,在后台queue中
__weak GCDAsyncSocket *weakSelf = self;

//获取到全局Queue
dispatch_queue_t globalConcurrentQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
//新线程
dispatch_async(globalConcurrentQueue, ^{
#pragma clang diagnostic push
#pragma clang diagnostic warning "-Wimplicit-retain-self"
//调用connect方法,该函数阻塞线程,所以要异步新线程
//客户端向特定网络地址的服务器发送连接请求,连接成功返回0,失败返回 -1。
int result = connect(socketFD, (const struct sockaddr *)[address bytes], (socklen_t)[address length]);

//老样子,安全判断
__strong GCDAsyncSocket *strongSelf = weakSelf;
if (strongSelf == nil) return_from_block;

//在socketQueue中,开辟线程
dispatch_async(strongSelf->socketQueue, ^{ @autoreleasepool {
//如果状态为已经连接,关闭连接返回
if (strongSelf.isConnected)
{
[strongSelf closeSocket:socketFD];
return_from_block;
}

//说明连接成功
if (result == 0)
{
//关闭掉另一个没用的socket
[self closeUnusedSocket:socketFD];
//调用didConnect,生成stream,改变状态等等!
[strongSelf didConnect:aStateIndex];
}
//连接失败
else
{
//关闭当前socket
[strongSelf closeSocket:socketFD];

// If there are no more sockets trying to connect, we inform the error to the delegate
//返回连接错误的error
if (strongSelf.socket4FD == SOCKET_NULL && strongSelf.socket6FD == SOCKET_NULL)
{
NSError *error = [strongSelf errnoErrorWithReason:@"Error in connect() function"];
[strongSelf didNotConnect:aStateIndex error:error];
}
}
}});

#pragma clang diagnostic pop
});
//输出正在连接中
LogVerbose(@"Connecting...");
}

这个方法主要就是做了一件事,调用下面一个函数进行连接:
int result = connect(socketFD, (const struct sockaddr *)[address bytes], (socklen_t)[address length]);


这里需要注意的是这个函数是阻塞,直到结果返回之前,线程会一直停在这行。所以这里用的是全局并发队列,开辟了一个新的线程进行连接,在得到结果之后,又调回socketQueue中进行后续操作。

如果result为0,说明连接成功,我们会关闭掉另外一个没有用到的socket(如果有的话)。然后调用另外一个方法做一些连接成功的初始化操作。
否则连接失败,我们会关闭socket,填充错误并且返回。

我们下一章来看看连接成功后初始化的方法


CocoaAsyncSocket源码分析---Connect (一)
CocoaAsyncSocket源码分析---Connect (八)
作者:Cooci
链接:https://www.jianshu.com/p/b264eff1f326





收起阅读 »

CocoaAsyncSocket源码分析---Connect (四)

//根据host、port + (NSMutableArray *)lookupHost:(NSString *)host port:(uint16_t)port error:(NSError **)errPtr { LogTrace(); ...
继续阅读 »
本文方法五--创建服务端server地址数据:

//根据host、port
+ (NSMutableArray *)lookupHost:(NSString *)host port:(uint16_t)port error:(NSError **)errPtr
{
LogTrace();

NSMutableArray *addresses = nil;
NSError *error = nil;

//如果Host是这localhost或者loopback
if ([host isEqualToString:@"localhost"] || [host isEqualToString:@"loopback"])
{
// Use LOOPBACK address
struct sockaddr_in nativeAddr4;
nativeAddr4.sin_len = sizeof(struct sockaddr_in);
nativeAddr4.sin_family = AF_INET;
nativeAddr4.sin_port = htons(port);
nativeAddr4.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
//占位置0
memset(&(nativeAddr4.sin_zero), 0, sizeof(nativeAddr4.sin_zero));

//ipv6
struct sockaddr_in6 nativeAddr6;
nativeAddr6.sin6_len = sizeof(struct sockaddr_in6);
nativeAddr6.sin6_family = AF_INET6;
nativeAddr6.sin6_port = htons(port);
nativeAddr6.sin6_flowinfo = 0;
nativeAddr6.sin6_addr = in6addr_loopback;
nativeAddr6.sin6_scope_id = 0;

// Wrap the native address structures

NSData *address4 = [NSData dataWithBytes:&nativeAddr4 length:sizeof(nativeAddr4)];
NSData *address6 = [NSData dataWithBytes:&nativeAddr6 length:sizeof(nativeAddr6)];

//两个添加进数组
addresses = [NSMutableArray arrayWithCapacity:2];
[addresses addObject:address4];
[addresses addObject:address6];
}
else
{
//拿到port String
NSString *portStr = [NSString stringWithFormat:@"%hu", port];

//定义三个addrInfo 是一个sockaddr结构的链表而不是一个地址清单

struct addrinfo hints, *res, *res0;

//初始化为0
memset(&hints, 0, sizeof(hints));

//相当于 AF_UNSPEC ,返回的是适用于指定主机名和服务名且适合任何协议族的地址。
hints.ai_family = PF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;

//根据host port,去获取地址信息。

int gai_error = getaddrinfo([host UTF8String], [portStr UTF8String], &hints, &res0);

//出错
if (gai_error)
{ //获取到错误
error = [self gaiError:gai_error];
}
//正确获取到addrInfo
else
{
//
NSUInteger capacity = 0;
//遍历 res0
for (res = res0; res; res = res->ai_next)
{
//如果有IPV4 IPV6的,capacity+1
if (res->ai_family == AF_INET || res->ai_family == AF_INET6) {
capacity++;
}
}
//生成一个地址数组,数组为capacity大小
addresses = [NSMutableArray arrayWithCapacity:capacity];

//再去遍历,为什么不一次遍历完,仅仅是为了限制数组的大小?
for (res = res0; res; res = res->ai_next)
{
//IPV4
if (res->ai_family == AF_INET)
{
// Found IPv4 address.
// Wrap the native address structure, and add to results.
//加到数组中
NSData *address4 = [NSData dataWithBytes:res->ai_addr length:res->ai_addrlen];
[addresses addObject:address4];
}
else if (res->ai_family == AF_INET6)
{
// Fixes connection issues with IPv6
// https://github.com/robbiehanson/CocoaAsyncSocket/issues/429#issuecomment-222477158

// Found IPv6 address.
// Wrap the native address structure, and add to results.
//强转
struct sockaddr_in6 *sockaddr = (struct sockaddr_in6 *)res->ai_addr;
//拿到port
in_port_t *portPtr = &sockaddr->sin6_port;
//如果Port为0
if ((portPtr != NULL) && (*portPtr == 0)) {
//赋值,用传进来的port
*portPtr = htons(port);
}
//添加到数组
NSData *address6 = [NSData dataWithBytes:res->ai_addr length:res->ai_addrlen];
[addresses addObject:address6];
}
}
//对应getaddrinfo 释放内存
freeaddrinfo(res0);

//如果地址里一个没有,报错 EAI_FAIL:名字解析中不可恢复的失败
if ([addresses count] == 0)
{
error = [self gaiError:EAI_FAIL];
}
}
}
//赋值错误
if (errPtr) *errPtr = error;
//返回地址
return addresses;
}

这个方法根据host进行了划分:

  1. 如果hostlocalhost或者loopback,则按照我们之前绑定本机地址那一套生成地址的方式,去生成IPV4和IPV6的地址,并且用NSData包裹住这个地址结构体,装在NSMutableArray中。
  2. 不是本机地址,那么我们就需要根据host和port去创建地址了,这里用到的是这么一个函数:
int getaddrinfo( const char *hostname, const char *service, const struct addrinfo *hints, struct addrinfo **result );


这个函数主要的作用是:根据hostname(IP)service(port),去获取地址信息,并且把地址信息传递到result中。
而hints这个参数可以是一个空指针,也可以是一个指向某个addrinfo结构体的指针,如果填了,其实它就是一个配置参数,返回的地址信息会和这个配置参数的内容有关,如下例:

举例来说:指定的服务既可支持TCP也可支持UDP,所以调用者可以把hints结构中的ai_socktype成员设置成SOCK_DGRAM使得返回的仅仅是适用于数据报套接口的信息。

这里我们可以看到result和hints这两个参数指针指向的都是一个addrinfo的结构体,这是我们继上面以来看到的第4种地址结构体了。它的定义如下:


struct addrinfo {
int ai_flags; /* AI_PASSIVE, AI_CANONNAME, AI_NUMERICHOST */
int ai_family; /* PF_xxx */
int ai_socktype; /* SOCK_xxx */
int ai_protocol; /* 0 or IPPROTO_xxx for IPv4 and IPv6 */
socklen_t ai_addrlen; /* length of ai_addr */
char *ai_canonname; /* canonical name for hostname */
struct sockaddr *ai_addr; /* binary address */
struct addrinfo *ai_next; /* next structure in linked list */
};

我们可以看到它其中包括了一个IPV4的结构体地址ai_addr,还有一个指向下一个同类型数据节点的指针ai_next

这里讲讲ai_next这个指针,因为我们是去获取server端的地址,所以很可能有不止一个地址,比如IPV4、IPV6,又或者我们之前所说的一个服务器有多个网卡,这时候可能就会有多个地址。这些地址就会用ai_next指针串联起来,形成一个单链表。

然后我们拿到这个地址链表,去遍历它,对应取出IPV4、IPV6的地址,封装成NSData并装到数组中去。

  1. 如果中间有错误,赋值错误,返回地址数组,理清楚这几个结构体与函数,这个方法还是相当容易读的,具体的细节可以看看注释。

接着我们回到本文方法二,就要用这个地址数组去做连接了。


//异步去发起连接
dispatch_async(strongSelf->socketQueue, ^{ @autoreleasepool {

[strongSelf lookup:aStateIndex didSucceedWithAddress4:address4 address6:address6];
}});

这里调用了我们本文方法六--开始连接的方法1
//连接的最终方法 1
- (void)lookup:(int)aStateIndex didSucceedWithAddress4:(NSData *)address4 address6:(NSData *)address6
{
LogTrace();

NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");
//至少有一个server地址
NSAssert(address4 || address6, @"Expected at least one valid address");

//如果状态不一致,说明断开连接
if (aStateIndex != stateIndex)
{
LogInfo(@"Ignoring lookupDidSucceed, already disconnected");

// The connect operation has been cancelled.
// That is, socket was disconnected, or connection has already timed out.
return;
}

// Check for problems
//分开判断。
BOOL isIPv4Disabled = (config & kIPv4Disabled) ? YES : NO;
BOOL isIPv6Disabled = (config & kIPv6Disabled) ? YES : NO;

if (isIPv4Disabled && (address6 == nil))
{
NSString *msg = @"IPv4 has been disabled and DNS lookup found no IPv6 address.";

[self closeWithError:[self otherError:msg]];
return;
}

if (isIPv6Disabled && (address4 == nil))
{
NSString *msg = @"IPv6 has been disabled and DNS lookup found no IPv4 address.";

[self closeWithError:[self otherError:msg]];
return;
}

// Start the normal connection process

NSError *err = nil;
//调用连接方法,如果失败,则错误返回
if (![self connectWithAddress4:address4 address6:address6 error:&err])
{
[self closeWithError:err];
}
}

这个方法也比较简单,基本上就是做了一些错误的判断。比如:

  1. 判断在不在这个socket队列。
  2. 判断传过来的aStateIndex和属性stateIndex是不是同一个值。说到这个值,不得不提的是大神用的框架,在容错处理上,做的真不是一般的严谨。从这个stateIndex上就能略见一二。
    这个aStateIndex是我们之前调用方法,用属性传过来的,所以按道理说,是肯定一样的。但是就怕在调用过程中,这个值发生了改变,这时候整个socket配置也就完全不一样了,有可能我们已经置空地址、销毁socket、断开连接等等...等我们后面再来看这个属性stateIndex在什么地方会发生改变。
  3. 判断config中是需要哪种配置,它的参数对应了一个枚举:

enum GCDAsyncSocketConfig
{
kIPv4Disabled = 1 << 0, // If set, IPv4 is disabled
kIPv6Disabled = 1 << 1, // If set, IPv6 is disabled
kPreferIPv6 = 1 << 2, // If set, IPv6 is preferred over IPv4
kAllowHalfDuplexConnection = 1 << 3, // If set, the socket will stay open even if the read stream closes
};

前3个大家很好理解,无非就是用IPV4还是IPV6。
而第4个官方注释意思是,我们即使关闭读的流,也会保持Socket开启。至于具体是什么意思,我们先不在这里讨论,等后文再说。

这里调用了我们本文方法七--开始连接的方法2

//连接最终方法 2。用两个Server地址去连接,失败返回NO,并填充error
- (BOOL)connectWithAddress4:(NSData *)address4 address6:(NSData *)address6 error:(NSError **)errPtr
{
LogTrace();

NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");

//输出一些东西?
LogVerbose(@"IPv4: %@:%hu", [[self class] hostFromAddress:address4], [[self class] portFromAddress:address4]);
LogVerbose(@"IPv6: %@:%hu", [[self class] hostFromAddress:address6], [[self class] portFromAddress:address6]);

// Determine socket type

//判断是否倾向于IPV6
BOOL preferIPv6 = (config & kPreferIPv6) ? YES : NO;

// Create and bind the sockets

//如果有IPV4地址,创建IPV4 Socket
if (address4)
{
LogVerbose(@"Creating IPv4 socket");

socket4FD = [self createSocket:AF_INET connectInterface:connectInterface4 errPtr:errPtr];
}
//如果有IPV6地址,创建IPV6 Socket
if (address6)
{
LogVerbose(@"Creating IPv6 socket");

socket6FD = [self createSocket:AF_INET6 connectInterface:connectInterface6 errPtr:errPtr];
}

//如果都为空,直接返回
if (socket4FD == SOCKET_NULL && socket6FD == SOCKET_NULL)
{
return NO;
}

//主选socketFD,备选alternateSocketFD
int socketFD, alternateSocketFD;
//主选地址和备选地址
NSData *address, *alternateAddress;

//IPV6
if ((preferIPv6 && socket6FD) || socket4FD == SOCKET_NULL)
{
socketFD = socket6FD;
alternateSocketFD = socket4FD;
address = address6;
alternateAddress = address4;
}
//主选IPV4
else
{
socketFD = socket4FD;
alternateSocketFD = socket6FD;
address = address4;
alternateAddress = address6;
}
//拿到当前状态
int aStateIndex = stateIndex;
//用socket和address去连接
[self connectSocket:socketFD address:address stateIndex:aStateIndex];

//如果有备选地址
if (alternateAddress)
{
//延迟去连接备选的地址
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(alternateAddressDelay * NSEC_PER_SEC)), socketQueue, ^{
[self connectSocket:alternateSocketFD address:alternateAddress stateIndex:aStateIndex];
});
}

return YES;
}

这个方法也仅仅是连接中过渡的一个方法,做的事也非常简单:

  1. 就是拿到IPV4和IPV6地址,先去创建对应的socket,注意这个socket是本机客户端的,和server端没有关系。这里服务端的IPV4和IPV6地址仅仅是用来判断是否需要去创建对应的本机Socket。这里去创建socket会带上我们之前生成的本地地址信息connectInterface4或者connectInterface6
  2. 根据我们的config配置,得到主选连接和备选连接。 然后先去连接主选连接地址,在用我们一开始初始化中设置的属性alternateAddressDelay,就是这个备选连接延时的属性,去延时连接备选地址(当然如果主选地址在此时已经连接成功,会再次连接导致socket错误,并且关闭)。

这两步分别调用了各自的方法去实现,接下来我们先来看创建本机Socket的方法:

本文方法八--创建Socket:

//创建Socket
- (int)createSocket:(int)family connectInterface:(NSData *)connectInterface errPtr:(NSError **)errPtr
{
//创建socket,用的SOCK_STREAM TCP流
int socketFD = socket(family, SOCK_STREAM, 0);
//如果创建失败
if (socketFD == SOCKET_NULL)
{
if (errPtr)
*errPtr = [self errnoErrorWithReason:@"Error in socket() function"];

return socketFD;
}

//和connectInterface绑定
if (![self bindSocket:socketFD toInterface:connectInterface error:errPtr])
{
//绑定失败,直接关闭返回
[self closeSocket:socketFD];

return SOCKET_NULL;
}

// Prevent SIGPIPE signals
//防止终止进程的信号?
int nosigpipe = 1;
//SO_NOSIGPIPE是为了避免网络错误,而导致进程退出。用这个来避免系统发送signal
setsockopt(socketFD, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, sizeof(nosigpipe));

return socketFD;
}

这个方法做了这么几件事:

  1. 创建了一个socket:

 //创建一个socket,返回值为Int。(注scoket其实就是Int类型)
//第一个参数addressFamily IPv4(AF_INET) 或 IPv6(AF_INET6)。
//第二个参数 type 表示 socket 的类型,通常是流stream(SOCK_STREAM) 或数据报文datagram(SOCK_DGRAM)
//第三个参数 protocol 参数通常设置为0,以便让系统自动为选择我们合适的协议,对于 stream socket 来说会是 TCP 协议(IPPROTO_TCP),而对于 datagram来说会是 UDP 协议(IPPROTO_UDP)。
int socketFD = socket(family, SOCK_STREAM, 0);

其实这个函数在之前那篇IM文章中也讲过了,大家参考参考注释看看就可以了,这里如果返回值为-1,说明创建失败。

  1. 去绑定我们之前创建的本地地址,它调用了另外一个方法来实现。
  2. 最后我们调用了如下函数

   setsockopt(socketFD, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, sizeof(nosigpipe));

而这里的目的是为了来避免网络错误而出现的进程退出的情况,调用了这行函数,网络错误后,系统不再发送进程退出的信号。
关于这个进程退出的错误可以参考这篇文章:Mac OSX下SO_NOSIGPIPE的怪异表现

未完总结:

connect篇还没有完结,奈何篇幅问题,只能断在这里。下一个方法将是socket本地绑定的方法。再下面就是我们最终的连接方法了,历经九九八十一难,马上就要取到真经了...(然而这仅仅是一个开始...)
下一篇将会承接这一篇的内容继续讲,包括最终连接、连接完成后的source和流的处理。
我们还会去讲讲iOS作为服务端的accpet建立连接的流程。
除此之外还有 unix domin socket(进程间通信)的连接。


CocoaAsyncSocket源码分析---Connect (一)

CocoaAsyncSocket源码分析---Connect (二)

CocoaAsyncSocket源码分析---Connect (三)

CocoaAsyncSocket源码分析---Connect (四)

CocoaAsyncSocket源码分析---Connect (五)

CocoaAsyncSocket源码分析---Connect (六)

CocoaAsyncSocket源码分析---Connect (七)

CocoaAsyncSocket源码分析---Connect (八)


作者:Cooci
链接:https://www.jianshu.com/p/9968ff0280e5

收起阅读 »

CocoaAsyncSocket源码分析---Connect (三)

interface本文方法四--本地地址绑定方法- (void)getInterfaceAddress4:(NSMutableData **)interfaceAddr4Ptr address6:(NSMutableDa...
继续阅读 »

至于有interface,我们所做的额外操作是什么呢,我们接下来看看这个方法:本文方法四--本地地址绑定方法

- (void)getInterfaceAddress4:(NSMutableData **)interfaceAddr4Ptr
address6:(NSMutableData **)interfaceAddr6Ptr
fromDescription:(NSString *)interfaceDescription
port:(uint16_t)port
{
NSMutableData *addr4 = nil;
NSMutableData *addr6 = nil;

NSString *interface = nil;

//先用:分割
NSArray *components = [interfaceDescription componentsSeparatedByString:@":"];
if ([components count] > 0)
{
NSString *temp = [components objectAtIndex:0];
if ([temp length] > 0)
{
interface = temp;
}
}
if ([components count] > 1 && port == 0)
{
//拿到port strtol函数,将一个字符串,根据base参数转成长整型,如base值为10则采用10进制,若base值为16则采用16进制
long portL = strtol([[components objectAtIndex:1] UTF8String], NULL, 10);
//UINT16_MAX,65535最大端口号
if (portL > 0 && portL <= UINT16_MAX)
{
port = (uint16_t)portL;
}
}

//为空则自己创建一个 0x00000000 ,全是0 ,为线路地址
//如果端口为0 通常用于分析操作系统。这一方法能够工作是因为在一些系统中“0”是无效端口,当你试图使用通常的闭合端口连接它时将产生不同的结果。一种典型的扫描,使用IP地址为0.0.0.0,设置ACK位并在以太网层广播。
if (interface == nil)
{

struct sockaddr_in sockaddr4;

//memset作用是在一段内存块中填充某个给定的值,它是对较大的结构体或数组进行清零操作的一种最快方法

//memset(void *s,int ch,size_t n);函数,第一个参数为指针地址,第二个为设置值,第三个为连续设置的长度(大小)
memset(&sockaddr4, 0, sizeof(sockaddr4));
//结构体长度
sockaddr4.sin_len = sizeof(sockaddr4);
//addressFamily IPv4(AF_INET) 或 IPv6(AF_INET6)。
sockaddr4.sin_family = AF_INET;
//端口号 htons将主机字节顺序转换成网络字节顺序 16位
sockaddr4.sin_port = htons(port);
//htonl ,将INADDR_ANY:0.0.0.0,不确定地址,或者任意地址 htonl 32位。 也是转为网络字节序

//ipv4 32位 4个字节 INADDR_ANY,0x00000000 (16进制,一个0代表4位,8个0就是32位) = 4个字节的
sockaddr4.sin_addr.s_addr = htonl(INADDR_ANY);
struct sockaddr_in6 sockaddr6;
memset(&sockaddr6, 0, sizeof(sockaddr6));

sockaddr6.sin6_len = sizeof(sockaddr6);
//ipv6
sockaddr6.sin6_family = AF_INET6;
//port
sockaddr6.sin6_port = htons(port);

//共128位
sockaddr6.sin6_addr = in6addr_any;

//把这两个结构体转成data
addr4 = [NSMutableData dataWithBytes:&sockaddr4 length:sizeof(sockaddr4)];
addr6 = [NSMutableData dataWithBytes:&sockaddr6 length:sizeof(sockaddr6)];
}
//如果localhost、loopback 回环地址,虚拟地址,路由器工作它就存在。一般用来标识路由器
//这两种的话就赋值为127.0.0.1,端口为port
else if ([interface isEqualToString:@"localhost"] || [interface isEqualToString:@"loopback"])
{
// LOOPBACK address

//ipv4
struct sockaddr_in sockaddr4;
memset(&sockaddr4, 0, sizeof(sockaddr4));

sockaddr4.sin_len = sizeof(sockaddr4);
sockaddr4.sin_family = AF_INET;
sockaddr4.sin_port = htons(port);

//#define INADDR_LOOPBACK (u_int32_t)0x7f000001
//7f000001->1111111 00000000 00000000 00000001->127.0.0.1
sockaddr4.sin_addr.s_addr = htonl(INADDR_LOOPBACK);

//ipv6
struct sockaddr_in6 sockaddr6;
memset(&sockaddr6, 0, sizeof(sockaddr6));

sockaddr6.sin6_len = sizeof(sockaddr6);
sockaddr6.sin6_family = AF_INET6;
sockaddr6.sin6_port = htons(port);

sockaddr6.sin6_addr = in6addr_loopback;
//赋值
addr4 = [NSMutableData dataWithBytes:&sockaddr4 length:sizeof(sockaddr4)];
addr6 = [NSMutableData dataWithBytes:&sockaddr6 length:sizeof(sockaddr6)];
}
//非localhost、loopback,去获取本机IP,看和传进来Interface是同名或者同IP,相同才给赋端口号,把数据封装进Data。否则为nil
else
{
//转成cString
const char *iface = [interface UTF8String];

//定义结构体指针,这个指针是本地IP
struct ifaddrs *addrs;
const struct ifaddrs *cursor;

//获取到本机IP,为0说明成功了
if ((getifaddrs(&addrs) == 0))
{
//赋值
cursor = addrs;
//如果IP不为空,则循环链表去设置
while (cursor != NULL)
{
//如果 addr4 IPV4地址为空,而且地址类型为IPV4
if ((addr4 == nil) && (cursor->ifa_addr->sa_family == AF_INET))
{
// IPv4

struct sockaddr_in nativeAddr4;
//memcpy内存copy函数,把src开始到size的字节数copy到 dest中
memcpy(&nativeAddr4, cursor->ifa_addr, sizeof(nativeAddr4));

//比较两个字符串是否相同,本机的IP名,和接口interface是否相同
if (strcmp(cursor->ifa_name, iface) == 0)
{
// Name match
//相同则赋值 port
nativeAddr4.sin_port = htons(port);
//用data封号IPV4地址
addr4 = [NSMutableData dataWithBytes:&nativeAddr4 length:sizeof(nativeAddr4)];
}
//本机IP名和interface不相同
else
{
//声明一个IP 16位的数组
char ip[INET_ADDRSTRLEN];

//这里是转成了10进制。。(因为获取到的是二进制IP)
const char *conversion = inet_ntop(AF_INET, &nativeAddr4.sin_addr, ip, sizeof(ip));

//如果conversion不为空,说明转换成功而且 ,比较转换后的IP,和interface是否相同
if ((conversion != NULL) && (strcmp(ip, iface) == 0))
{
// IP match
//相同则赋值 port
nativeAddr4.sin_port = htons(port);

addr4 = [NSMutableData dataWithBytes:&nativeAddr4 length:sizeof(nativeAddr4)];
}
}
}
//IPV6 一样
else if ((addr6 == nil) && (cursor->ifa_addr->sa_family == AF_INET6))
{
// IPv6

struct sockaddr_in6 nativeAddr6;
memcpy(&nativeAddr6, cursor->ifa_addr, sizeof(nativeAddr6));

if (strcmp(cursor->ifa_name, iface) == 0)
{
// Name match

nativeAddr6.sin6_port = htons(port);

addr6 = [NSMutableData dataWithBytes:&nativeAddr6 length:sizeof(nativeAddr6)];
}
else
{
char ip[INET6_ADDRSTRLEN];

const char *conversion = inet_ntop(AF_INET6, &nativeAddr6.sin6_addr, ip, sizeof(ip));

if ((conversion != NULL) && (strcmp(ip, iface) == 0))
{
// IP match

nativeAddr6.sin6_port = htons(port);

addr6 = [NSMutableData dataWithBytes:&nativeAddr6 length:sizeof(nativeAddr6)];
}
}
}

//指向链表下一个addr
cursor = cursor->ifa_next;
}
//和getifaddrs对应,释放这部分内存
freeifaddrs(addrs);
}
}
//如果这两个二级指针存在,则取成一级指针,把addr4赋值给它
if (interfaceAddr4Ptr) *interfaceAddr4Ptr = addr4;
if (interfaceAddr6Ptr) *interfaceAddr6Ptr = addr6;

这个方法中,主要是大量的socket相关的函数的调用,会显得比较难读一点,其实简单来讲就做了这么一件事:
interface变成进行socket操作所需要的地址结构体,然后把地址结构体包裹在NSMutableData中。

这里,为了让大家能更容易理解,我把这个方法涉及到的socket相关函数以及宏(按照调用顺序)都列出来:


//拿到port strtol函数,将一个字符串,根据base参数转成长整型,
//如base值为10则采用10进制,若base值为16则采用16进制
long strtol(const char *__str, char **__endptr, int __base);

//作用是在一段内存块中填充某个给定的值,它是对较大的结构体或数组进行清零操作的一种最快方法
//第一个参数为指针地址,第二个为设置值,第三个为连续设置的长度(大小)
memset(void *s,int ch,size_t n);

//最大端口号
#define UINT16_MAX 65535

//作用是把主机字节序转化为网络字节序
htons() //参数16位
htonl() //参数32位
//获取占用内存大小
sizeof()
//比较两个指针,是否相同 相同返回0
int strcmp(const char *__s1, const char *__s2)

//内存copu函数,把src开始到len的字节数copy到 dest中
memcpy(dest, src, len)

//inet_pton和inet_ntop这2个IP地址转换函数,可以在将IP地址在“点分十进制”和“二进制整数”之间转换
//参数socklen_t cnt,他是所指向缓存区dst的大小,避免溢出,如果缓存区太小无法存储地址的值,则返回一个空指针,并将errno置为ENOSPC
const char *inet_ntop(int af, const void *src, char *dst, socklen_t cnt);

//得到本机地址
extern int getifaddrs(struct ifaddrs **);
//释放本机地址
extern void freeifaddrs(struct ifaddrs *);
还有一些用到的作为参数的结构体:

//socket通信用的 IPV4地址结构体 
struct sockaddr_in {
__uint8_t sin_len; //整个结构体大小
sa_family_t sin_family; //协议族,IPV4?IPV6
in_port_t sin_port; //端口
struct in_addr sin_addr; //IP地址
char sin_zero[8]; //空的占位符,为了和其他地址结构体保持一致大小,方便转化
};
//IPV6地址结构体,和上面的类似
struct sockaddr_in6 {
__uint8_t sin6_len; /* length of this struct(sa_family_t) */
sa_family_t sin6_family; /* AF_INET6 (sa_family_t) */
in_port_t sin6_port; /* Transport layer port # (in_port_t) */
__uint32_t sin6_flowinfo; /* IP6 flow information */
struct in6_addr sin6_addr; /* IP6 address */
__uint32_t sin6_scope_id; /* scope zone index */
};

//用来获取本机IP的参数结构体
struct ifaddrs {
//指向链表的下一个成员
struct ifaddrs *ifa_next;
//接口名称
char *ifa_name;
//接口标识位(比如当IFF_BROADCAST或IFF_POINTOPOINT设置到此标识位时,影响联合体变量ifu_broadaddr存储广播地址或ifu_dstaddr记录点对点地址)
unsigned int ifa_flags;
//接口地址
struct sockaddr *ifa_addr;
//存储该接口的子网掩码;
struct sockaddr *ifa_netmask;

//点对点的地址
struct sockaddr *ifa_dstaddr;
//ifa_data存储了该接口协议族的特殊信息,它通常是NULL(一般不关注他)。
void *ifa_data;
};


这一段内容算是比较枯涩了,但是也是了解socket编程必经之路。

这里提到了网络字节序和主机字节序。我们创建socket之前,必须把port和host这些参数转化为网络字节序。那么为什么要这么做呢?

不同的CPU有不同的字节序类型 这些字节序是指整数在内存中保存的顺序 这个叫做主机序
最常见的有两种
1. Little endian:将低序字节存储在起始地址
2. Big endian:将高序字节存储在起始地址

这样如果我们到网络中,就无法得知互相的字节序是什么了,所以我们就必须统一一套排序,这样网络字节序就有它存在的必要了。


网络字节顺序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关。从而可以保证数据在不同主机之间传输时能够被正确解释。网络字节顺序采用big endian排序方式。

除此之外比较重要的就是这几个地址结构体了。它定义了我们当前socket的地址信息。包括IP、Port、长度、协议族等等。当然socket中标识为地址的结构体不止这3种,等我们后续代码来补充。


大家了解了我们上述说的知识点,这个方法也就不难度了。这个方法主要是做了本机IPV4IPV6地址的创建和绑定。当然这里分了几种情况:

  1. interface为空的,我们作为客户端不会出现这种情况。注意之前我们是这个参数不为空才会调入这个方法的。
    而这个一般是用于做服务端监听用的,这里的处理是给本机地址绑定0地址(任意地址)。那么这里这么做作用是什么呢?引用一个应用场景来说明:

如果你的服务器有多个网卡(每个网卡上有不同的IP地址),而你的服务(不管是在udp端口上侦听,还是在tcp端口上侦听),出于某种原因:可能是你的服务器操作系统可能随时增减IP地址,也有可能是为了省去确定服务器上有什么网络端口(网卡)的麻烦 —— 可以要在调用bind()的时候,告诉操作系统:“我需要在 yyyy 端口上侦听,所有发送到服务器的这个端口,不管是哪个网卡/哪个IP地址接收到的数据,都是我处理的。”这时候,服务器程序则在0.0.0.0这个地址上进行侦听。

  1. 如果interfacelocalhost或者loopback则把IP设置为127.0.0.1,这里localhost我们大家都知道。那么什么是loopback呢?
    loopback地址叫做回环地址,他不是一个物理接口上的地址,他是一个虚拟的一个地址,只要路由器在工作,这个地址就存在.它是路由器的唯一标识。
    更详细的内容可以看看百科:loopback

  2. 如果是一个其他的地址,我们会去使用getifaddrs()函数得到本机地址。然后去对比本机名或者本机IP。有一个能相同,我们就认为该地址有效,就进行IPV4和IPV6绑定。否则什么都不做。

至此这个本机地址绑定我们就做完了,我们前面也说过,一般我们作为客户端,是不需要做这一步的。如果我们不绑定,系统会自己绑定本机IP,并且选择一个空闲可用的端口。所以这个方法是iOS用来作为服务端调用的。


方法三--前置检查、方法四--本机地址绑定都说完了,我们继续接着之前的方法二往下看:

之前讲到第3点了:
3.这里把flag标记为kSocketStarted:

flags |= kSocketStarted;

源码中大量的运用了3个位运算符:分别是或(|)、与(&)、取反(~)、运算符。 运用这个标记的好处也很明显,可以很简单的标记当前的状态,并且因为flags所指向的枚举值是用左位移的方式:

enum GCDAsyncSocketFlags
{
kSocketStarted = 1 << 0, // If set, socket has been started (accepting/connecting)
kConnected = 1 << 1, // If set, the socket is connected
kForbidReadsWrites = 1 << 2, // If set, no new reads or writes are allowed
kReadsPaused = 1 << 3, // If set, reads are paused due to possible timeout
kWritesPaused = 1 << 4, // If set, writes are paused due to possible timeout
kDisconnectAfterReads = 1 << 5, // If set, disconnect after no more reads are queued
kDisconnectAfterWrites = 1 << 6, // If set, disconnect after no more writes are queued
kSocketCanAcceptBytes = 1 << 7, // If set, we know socket can accept bytes. If unset, it's unknown.
kReadSourceSuspended = 1 << 8, // If set, the read source is suspended
kWriteSourceSuspended = 1 << 9, // If set, the write source is suspended
kQueuedTLS = 1 << 10, // If set, we've queued an upgrade to TLS
kStartingReadTLS = 1 << 11, // If set, we're waiting for TLS negotiation to complete
kStartingWriteTLS = 1 << 12, // If set, we're waiting for TLS negotiation to complete
kSocketSecure = 1 << 13, // If set, socket is using secure communication via SSL/TLS
kSocketHasReadEOF = 1 << 14, // If set, we have read EOF from socket
kReadStreamClosed = 1 << 15, // If set, we've read EOF plus prebuffer has been drained
kDealloc = 1 << 16, // If set, the socket is being deallocated
#if TARGET_OS_IPHONE
kAddedStreamsToRunLoop = 1 << 17, // If set, CFStreams have been added to listener thread
kUsingCFStreamForTLS = 1 << 18, // If set, we're forced to use CFStream instead of SecureTransport
kSecureSocketHasBytesAvailable = 1 << 19, // If set, CFReadStream has notified us of bytes available
#endif
};


所以flags可以通过|的方式复合横跨多个状态,并且运算也非常轻量级,好处很多,所有的状态标记的意义可以在注释中清晰的看出,这里把状态标记为socket已经开始连接了。

4.然后我们调用了一个全局queue,异步的调用连接,这里又做了两件事:

  • 第一步是拿到我们需要连接的服务端server的地址数组:

//server地址数组(包含IPV4 IPV6的地址  sockaddr_in6、sockaddr_in类型)
NSMutableArray *addresses = [[self class] lookupHost:hostCpy port:port error:&lookupErr];





收起阅读 »

CocoaAsyncSocket源码分析---Connect (二)

connect也就是我们在截图中选中的方法,那我们就从这个方法作为起点,开始讲起吧。保证这个连接操作一定是在我们的接着把Block中连接过程产生的错误进行赋值,并且把连接的结果返回出去//如果有错误,赋值错误 if (errPtr) *errPtr =...
继续阅读 »

这里我们先作为客户端来看看connect



其中和connect相关的方法就这么多,我们一般这么来连接到服务端:

[socket connectToHost:Khost onPort:Kport error:nil];


也就是我们在截图中选中的方法,那我们就从这个方法作为起点,开始讲起吧。

本文方法二--connect总方法
/逐级调用
- (BOOL)connectToHost:(NSString*)host onPort:(uint16_t)port error:(NSError **)errPtr
{
return [self connectToHost:host onPort:port withTimeout:-1 error:errPtr];
}

- (BOOL)connectToHost:(NSString *)host
onPort:(uint16_t)port
withTimeout:(NSTimeInterval)timeout
error:(NSError **)errPtr
{
return [self connectToHost:host onPort:port viaInterface:nil withTimeout:timeout error:errPtr];
}

//多一个inInterface,本机地址
- (BOOL)connectToHost:(NSString *)inHost
onPort:(uint16_t)port
viaInterface:(NSString *)inInterface
withTimeout:(NSTimeInterval)timeout
error:(NSError **)errPtr
{
//{} 跟踪当前行为
LogTrace();

// Just in case immutable objects were passed
//拿到host ,copy防止值被修改
NSString *host = [inHost copy];
//interface?接口?
NSString *interface = [inInterface copy];

//声明两个__block的
__block BOOL result = NO;
//error信息
__block NSError *preConnectErr = nil;

//gcdBlock ,都包裹在自动释放池中
dispatch_block_t block = ^{ @autoreleasepool {

// Check for problems with host parameter

if ([host length] == 0)
{
NSString *msg = @"Invalid host parameter (nil or \"\"). Should be a domain name or IP address string.";
preConnectErr = [self badParamError:msg];

//其实就是return,大牛的代码真是充满逼格
return_from_block;
}

// Run through standard pre-connect checks
//一个前置的检查,如果没通过返回,这个检查里,如果interface有值,则会将本机的IPV4 IPV6的 address设置上。
if (![self preConnectWithInterface:interface error:&preConnectErr])
{
return_from_block;
}

// We've made it past all the checks.
// It's time to start the connection process.
//flags 做或等运算。 flags标识为开始Socket连接
flags |= kSocketStarted;

//又是一个{}? 只是为了标记么?
LogVerbose(@"Dispatching DNS lookup...");

// It's possible that the given host parameter is actually a NSMutableString.
//很可能给我们的服务端的参数是一个可变字符串
// So we want to copy it now, within this block that will be executed synchronously.
//所以我们需要copy,在Block里同步的执行
// This way the asynchronous lookup block below doesn't have to worry about it changing.
//这种基于Block的异步查找,不需要担心它被改变

//copy,防止改变
NSString *hostCpy = [host copy];

//拿到状态
int aStateIndex = stateIndex;
__weak GCDAsyncSocket *weakSelf = self;

//全局Queue
dispatch_queue_t globalConcurrentQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
//异步执行
dispatch_async(globalConcurrentQueue, ^{ @autoreleasepool {
//忽视循环引用
#pragma clang diagnostic push
#pragma clang diagnostic warning "-Wimplicit-retain-self"

//查找错误
NSError *lookupErr = nil;
//server地址数组(包含IPV4 IPV6的地址 sockaddr_in6、sockaddr_in类型)
NSMutableArray *addresses = [[self class] lookupHost:hostCpy port:port error:&lookupErr];

//strongSelf
__strong GCDAsyncSocket *strongSelf = weakSelf;

//完整Block安全形态,在加个if
if (strongSelf == nil) return_from_block;

//如果有错
if (lookupErr)
{
//用cocketQueue
dispatch_async(strongSelf->socketQueue, ^{ @autoreleasepool {
//一些错误处理,清空一些数据等等
[strongSelf lookup:aStateIndex didFail:lookupErr];
}});
}
//正常
else
{

NSData *address4 = nil;
NSData *address6 = nil;
//遍历地址数组
for (NSData *address in addresses)
{
//判断address4为空,且address为IPV4
if (!address4 && [[self class] isIPv4Address:address])
{
address4 = address;
}
//判断address6为空,且address为IPV6
else if (!address6 && [[self class] isIPv6Address:address])
{
address6 = address;
}
}
//异步去发起连接
dispatch_async(strongSelf->socketQueue, ^{ @autoreleasepool {

[strongSelf lookup:aStateIndex didSucceedWithAddress4:address4 address6:address6];
}});
}

#pragma clang diagnostic pop
}});

//开启连接超时
[self startConnectTimeout:timeout];

result = YES;
}};
//在socketQueue中执行这个Block
if (dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey))
block();
//否则同步的调起这个queue去执行
else
dispatch_sync(socketQueue, block);

//如果有错误,赋值错误
if (errPtr) *errPtr = preConnectErr;
//把连接是否成功的result返回
return result;
}

这个方法非常长,它主要做了以下几件事:

  • 首先我们需要说一下的是,整个类大量的会出现LogTrace()类似这样的宏,我们点进去发现它的本质只是一个{},什么事都没做。

原来这些宏是为了追踪当前执行的流程用的,它被定义在一个大的#if #else中:

#ifndef GCDAsyncSocketLoggingEnabled
#define GCDAsyncSocketLoggingEnabled 0
#endif
#if GCDAsyncSocketLoggingEnabled
// Logging Enabled - See log level below
// Logging uses the CocoaLumberjack framework (which is also GCD based).
// https://github.com/robbiehanson/CocoaLumberjack
//
// It allows us to do a lot of logging without significantly slowing down the code.
#import "DDLog.h"
#define LogAsync YES
#define LogContext GCDAsyncSocketLoggingContext
#define LogObjc(flg, frmt, ...) LOG_OBJC_MAYBE(LogAsync, logLevel, flg, LogContext, frmt, ##__VA_ARGS__)
#define LogC(flg, frmt, ...) LOG_C_MAYBE(LogAsync, logLevel, flg, LogContext, frmt, ##__VA_ARGS__)
#define LogError(frmt, ...) LogObjc(LOG_FLAG_ERROR, (@"%@: " frmt), THIS_FILE, ##__VA_ARGS__)
#define LogWarn(frmt, ...) LogObjc(LOG_FLAG_WARN, (@"%@: " frmt), THIS_FILE, ##__VA_ARGS__)
#define LogInfo(frmt, ...) LogObjc(LOG_FLAG_INFO, (@"%@: " frmt), THIS_FILE, ##__VA_ARGS__)
#define LogVerbose(frmt, ...) LogObjc(LOG_FLAG_VERBOSE, (@"%@: " frmt), THIS_FILE, ##__VA_ARGS__)
#define LogCError(frmt, ...) LogC(LOG_FLAG_ERROR, (@"%@: " frmt), THIS_FILE, ##__VA_ARGS__)
#define LogCWarn(frmt, ...) LogC(LOG_FLAG_WARN, (@"%@: " frmt), THIS_FILE, ##__VA_ARGS__)
#define LogCInfo(frmt, ...) LogC(LOG_FLAG_INFO, (@"%@: " frmt), THIS_FILE, ##__VA_ARGS__)
#define LogCVerbose(frmt, ...) LogC(LOG_FLAG_VERBOSE, (@"%@: " frmt), THIS_FILE, ##__VA_ARGS__)
#define LogTrace() LogObjc(LOG_FLAG_VERBOSE, @"%@: %@", THIS_FILE, THIS_METHOD)
#define LogCTrace() LogC(LOG_FLAG_VERBOSE, @"%@: %s", THIS_FILE, __FUNCTION__)
#ifndef GCDAsyncSocketLogLevel
#define GCDAsyncSocketLogLevel LOG_LEVEL_VERBOSE
#endif
// Log levels : off, error, warn, info, verbose
static const int logLevel = GCDAsyncSocketLogLevel;
#else
// Logging Disabled
#define LogError(frmt, ...) {}
#define LogWarn(frmt, ...) {}
#define LogInfo(frmt, ...) {}
#define LogVerbose(frmt, ...) {}
#define LogCError(frmt, ...) {}
#define LogCWarn(frmt, ...) {}
#define LogCInfo(frmt, ...) {}
#define LogCVerbose(frmt, ...) {}
#define LogTrace() {}
#define LogCTrace(frmt, ...) {}
#endif


而此时因为GCDAsyncSocketLoggingEnabled默认为0,所以仅仅是一个{}。当标记为1时,这些宏就可以用来输出我们当前的业务流程,极大的方便了我们的调试过程。

  • 接着我们回到正题上,我们定义了一个Block,所有的连接操作都被包裹在这个Block中。我们做了如下判断:

    //在socketQueue中执行这个Block
if (dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey))
block();
//否则同步的调起这个queue去执行
else
dispatch_sync(socketQueue, block);

保证这个连接操作一定是在我们的socketQueue中,而且还是以串行同步的形式去执行,规避了线程安全的问题。

  • 接着把Block中连接过程产生的错误进行赋值,并且把连接的结果返回出去
//如果有错误,赋值错误
if (errPtr) *errPtr = preConnectErr;
//把连接是否成功的result返回
return result;

接着来看这个方法声明的Block内部,也就是进行连接的真正主题操作,这个连接过程将会调用许多函数,一环扣一环,我会尽可能用最清晰、详尽的语言来描述...

1.这个Block首先做了一些错误的判断,并调用了一些错误生成的方法。类似:

if ([host length] == 0)
{
NSString *msg = @"Invalid host parameter (nil or \"\"). Should be a domain name or IP address string.";
preConnectErr = [self badParamError:msg];

//其实就是return,大牛的代码真是充满逼格
return_from_block;
}
//用该字符串生成一个错误,错误的域名,错误的参数
- (NSError *)badParamError:(NSString *)errMsg
{
NSDictionary *userInfo = [NSDictionary dictionaryWithObject:errMsg forKey:NSLocalizedDescriptionKey];

return [NSError errorWithDomain:GCDAsyncSocketErrorDomain code:GCDAsyncSocketBadParamError userInfo:userInfo];
}

2.接着做了一个前置的错误检查:
if (![self preConnectWithInterface:interface error:&preConnectErr])
{
return_from_block;
}
这个检查方法,如果没通过返回NO。并且如果interface有值,则会将本机的IPV4 IPV6的 address设置上。即我们之前提到的这两个属性:
  //本机的IPV4地址
NSData * connectInterface4;
//本机的IPV6地址
NSData * connectInterface6;

我们来看看这个前置检查方法:

本文方法三--前置检查方法
//在连接之前的接口检查,一般我们传nil  interface本机的IP 端口等等
- (BOOL)preConnectWithInterface:(NSString *)interface error:(NSError **)errPtr
{
//先断言,如果当前的queue不是初始化quueue,直接报错
NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");

//无代理
if (delegate == nil) // Must have delegate set
{
if (errPtr)
{
NSString *msg = @"Attempting to connect without a delegate. Set a delegate first.";
*errPtr = [self badConfigError:msg];
}
return NO;
}
//没有代理queue
if (delegateQueue == NULL) // Must have delegate queue set
{
if (errPtr)
{
NSString *msg = @"Attempting to connect without a delegate queue. Set a delegate queue first.";
*errPtr = [self badConfigError:msg];
}
return NO;
}

//当前不是非连接状态
if (![self isDisconnected]) // Must be disconnected
{
if (errPtr)
{
NSString *msg = @"Attempting to connect while connected or accepting connections. Disconnect first.";
*errPtr = [self badConfigError:msg];
}
return NO;
}

//判断是否支持IPV4 IPV6 &位与运算,因为枚举是用 左位移<<运算定义的,所以可以用来判断 config包不包含某个枚举。因为一个值可能包含好几个枚举值,所以这时候不能用==来判断,只能用&来判断
BOOL isIPv4Disabled = (config & kIPv4Disabled) ? YES : NO;
BOOL isIPv6Disabled = (config & kIPv6Disabled) ? YES : NO;

//是否都不支持
if (isIPv4Disabled && isIPv6Disabled) // Must have IPv4 or IPv6 enabled
{
if (errPtr)
{
NSString *msg = @"Both IPv4 and IPv6 have been disabled. Must enable at least one protocol first.";
*errPtr = [self badConfigError:msg];
}
return NO;
}

//如果有interface,本机地址
if (interface)
{
NSMutableData *interface4 = nil;
NSMutableData *interface6 = nil;

//得到本机的IPV4 IPV6地址
[self getInterfaceAddress4:&interface4 address6:&interface6 fromDescription:interface port:0];

//如果两者都为nil
if ((interface4 == nil) && (interface6 == nil))
{
if (errPtr)
{
NSString *msg = @"Unknown interface. Specify valid interface by name (e.g. \"en1\") or IP address.";
*errPtr = [self badParamError:msg];
}
return NO;
}

if (isIPv4Disabled && (interface6 == nil))
{
if (errPtr)
{
NSString *msg = @"IPv4 has been disabled and specified interface doesn't support IPv6.";
*errPtr = [self badParamError:msg];
}
return NO;
}

if (isIPv6Disabled && (interface4 == nil))
{
if (errPtr)
{
NSString *msg = @"IPv6 has been disabled and specified interface doesn't support IPv4.";
*errPtr = [self badParamError:msg];
}
return NO;
}
//如果都没问题,则赋值
connectInterface4 = interface4;
connectInterface6 = interface6;
}

// Clear queues (spurious read/write requests post disconnect)
//清除queue(假的读写请求 ,提交断开连接)
//读写Queue清除
[readQueue removeAllObjects];
[writeQueue removeAllObjects];

return YES;
}

又是非常长的一个方法,但是这个方法还是非常好读的。

  • 主要是对连接前的一个属性参数的判断,如果不齐全的话,则填充错误指针,并且返回NO。

  • 在这里如果我们interface这个参数不为空话,我们会额外多执行一些操作。
    首先来讲讲这个参数是什么,简单来讲,这个就是我们设置的本机IP+端口号。照理来说我们是不需要去设置这个参数的,默认的为localhost(127.0.0.1)本机地址。而端口号会在本机中取一个空闲可用的端口。
    而我们一旦设置了这个参数,就会强制本地IP和端口为我们指定的。其实这样设置反而不好,其实大家也能想明白,这里端口号如果我们写死,万一被其他进程给占用了。那么肯定是无法连接成功的。
    所以就有了我们做IM的时候,一般是不会去指定客户端bind某一个端口。而是用系统自动去选择。

  • 我们最后清空了当前读写queue中,所有的任务。

至于有interface,我们所做的额外操作是什么呢,我们接下来看下一章






作者:Cooci
链接:https://www.jianshu.com/p/9968ff0280e5






收起阅读 »

CocoaAsyncSocket源码分析---Connect (一)

CocoaAsyncSocket是谷歌的开发者,基于BSD-Socket写的一个IM框架,它给Mac和iOS提供了易于使用的、强大的异步套接字库,向上封装出简单易用OC接口。省去了我们面向Socket以及数据流Stream等繁琐复杂的编程。本文为一个系列,旨在...
继续阅读 »
CocoaAsyncSocket是谷歌的开发者,基于BSD-Socket写的一个IM框架,它给Mac和iOS提供了易于使用的、强大的异步套接字库,向上封装出简单易用OC接口。省去了我们面向Socket以及数据流Stream等繁琐复杂的编程。
本文为一个系列,旨在让大家了解CocoaAsyncSocket是如何基于底层进行封装、工作的。

附上一张socket流程图



正文:
首先我们来看看框架:

整个库就这么两个类,一个基于TCP,一个基于UDP。其中基于TCP的GCDAsyncSocket,大概8000多行代码。而GCDAsyncUdpSocket稍微少一点,也有5000多行。
所以单纯从代码量上来看,这个库还是做了很多事的。

顺便提一下,之前这个框架还有一个runloop版的,不过因为功能重叠和其它种种原因,后续版本便废弃了,现在仅有GCD版本。

本系列我们将重点来讲GCDAsyncSocket这个类。

我们先来看看这个类的属性:

@implementation GCDAsyncSocket
{
//flags,当前正在做操作的标识符
uint32_t flags;
uint16_t config;

//代理
__weak id<GCDAsyncSocketDelegate> delegate;
//代理回调的queue
dispatch_queue_t delegateQueue;

//本地IPV4Socket
int socket4FD;
//本地IPV6Socket
int socket6FD;
//unix域的套接字
int socketUN;
//unix域 服务端 url
NSURL *socketUrl;
//状态Index
int stateIndex;

//本机的IPV4地址
NSData * connectInterface4;
//本机的IPV6地址
NSData * connectInterface6;
//本机unix域地址
NSData * connectInterfaceUN;

//这个类的对Socket的操作都在这个queue中,串行
dispatch_queue_t socketQueue;

dispatch_source_t accept4Source;
dispatch_source_t accept6Source;
dispatch_source_t acceptUNSource;

//连接timer,GCD定时器
dispatch_source_t connectTimer;
dispatch_source_t readSource;
dispatch_source_t writeSource;
dispatch_source_t readTimer;
dispatch_source_t writeTimer;

//读写数据包数组 类似queue,最大限制为5个包
NSMutableArray *readQueue;
NSMutableArray *writeQueue;

//当前正在读写数据包
GCDAsyncReadPacket *currentRead;
GCDAsyncWritePacket *currentWrite;
//当前socket未获取完的数据大小
unsigned long socketFDBytesAvailable;

//全局公用的提前缓冲区
GCDAsyncSocketPreBuffer *preBuffer;

#if TARGET_OS_IPHONE
CFStreamClientContext streamContext;
//读的数据流
CFReadStreamRef readStream;
//写的数据流
CFWriteStreamRef writeStream;
#endif
//SSL上下文,用来做SSL认证
SSLContextRef sslContext;

//全局公用的SSL的提前缓冲区
GCDAsyncSocketPreBuffer *sslPreBuffer;
size_t sslWriteCachedLength;

//记录SSL读取数据错误
OSStatus sslErrCode;
//记录SSL握手的错误
OSStatus lastSSLHandshakeError;

//socket队列的标识key
void *IsOnSocketQueueOrTargetQueueKey;

id userData;

//连接备选服务端地址的延时 (另一个IPV4或IPV6)
NSTimeInterval alternateAddressDelay;
}

这个里定义了一些属性,可以先简单看看注释,这里我们仅仅先暂时列出来,给大家混个眼熟。
在接下来的代码中,会大量穿插着这些属性的使用。所以大家不用觉得困惑,具体作用,我们后面会一一讲清楚的。

接着我们来看看本文方法一--初始化方法:
//层级调用
- (id)init
{
return [self initWithDelegate:nil delegateQueue:NULL socketQueue:NULL];
}

- (id)initWithSocketQueue:(dispatch_queue_t)sq
{
return [self initWithDelegate:nil delegateQueue:NULL socketQueue:sq];
}

- (id)initWithDelegate:(id)aDelegate delegateQueue:(dispatch_queue_t)dq
{
return [self initWithDelegate:aDelegate delegateQueue:dq socketQueue:NULL];
}

- (id)initWithDelegate:(id)aDelegate delegateQueue:(dispatch_queue_t)dq socketQueue:(dispatch_queue_t)sq
{
if((self = [super init]))
{
delegate = aDelegate;
delegateQueue = dq;

//这个宏是在sdk6.0之后才有的,如果是之前的,则OS_OBJECT_USE_OBJC为0,!0即执行if语句
//对6.0的适配,如果是6.0以下,则去retain release,6.0之后ARC也管理了GCD
#if !OS_OBJECT_USE_OBJC

if (dq) dispatch_retain(dq);
#endif

//创建socket,先都置为 -1
//本机的ipv4
socket4FD = SOCKET_NULL;
//ipv6
socket6FD = SOCKET_NULL;
//应该是UnixSocket
socketUN = SOCKET_NULL;
//url
socketUrl = nil;
//状态
stateIndex = 0;

if (sq)
{
//如果scoketQueue是global的,则报错。断言必须要一个非并行queue。
NSAssert(sq != dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0),
@"The given socketQueue parameter must not be a concurrent queue.");
NSAssert(sq != dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),
@"The given socketQueue parameter must not be a concurrent queue.");
NSAssert(sq != dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),
@"The given socketQueue parameter must not be a concurrent queue.");
//拿到scoketQueue
socketQueue = sq;
//iOS6之下retain
#if !OS_OBJECT_USE_OBJC
dispatch_retain(sq);
#endif
}
else
{
//没有的话创建一个, 名字为:GCDAsyncSocket,串行
socketQueue = dispatch_queue_create([GCDAsyncSocketQueueName UTF8String], NULL);
}

// The dispatch_queue_set_specific() and dispatch_get_specific() functions take a "void *key" parameter.
// From the documentation:
//
// > Keys are only compared as pointers and are never dereferenced.
// > Thus, you can use a pointer to a static variable for a specific subsystem or
// > any other value that allows you to identify the value uniquely.
//
// We're just going to use the memory address of an ivar.
// Specifically an ivar that is explicitly named for our purpose to make the code more readable.
//
// However, it feels tedious (and less readable) to include the "&" all the time:
// dispatch_get_specific(&IsOnSocketQueueOrTargetQueueKey)
//
// So we're going to make it so it doesn't matter if we use the '&' or not,
// by assigning the value of the ivar to the address of the ivar.
// Thus: IsOnSocketQueueOrTargetQueueKey == &IsOnSocketQueueOrTargetQueueKey;

//比如原来为 0X123 -> NULL 变成 0X222->0X123->NULL
//自己的指针等于自己原来的指针,成二级指针了 看了注释是为了以后省略&,让代码更可读?
IsOnSocketQueueOrTargetQueueKey = &IsOnSocketQueueOrTargetQueueKey;

void *nonNullUnusedPointer = (__bridge void *)self;

//dispatch_queue_set_specific给当前队里加一个标识 dispatch_get_specific当前线程取出这个标识,判断是不是在这个队列
//这个key的值其实就是一个一级指针的地址 ,第三个参数把自己传过去了,上下文对象?第4个参数,为销毁的时候用的,可以指定一个函数
dispatch_queue_set_specific(socketQueue, IsOnSocketQueueOrTargetQueueKey, nonNullUnusedPointer, NULL);
//读的数组 限制为5
readQueue = [[NSMutableArray alloc] initWithCapacity:5];
currentRead = nil;

//写的数组,限制5
writeQueue = [[NSMutableArray alloc] initWithCapacity:5];
currentWrite = nil;

//设置大小为 4kb
preBuffer = [[GCDAsyncSocketPreBuffer alloc] initWithCapacity:(1024 * 4)];

#pragma mark alternateAddressDelay??
//交替地址延时?? wtf
alternateAddressDelay = 0.3;
}
return self;
}


其中值得一提的是第三种:UnixSocket,这个是用于Unix Domin Socket通信用的。
那么什么是Unix Domain Socket呢?
原来它是在socket的框架上发展出一种IPC(进程间通信)机制,虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC 更有效率 :

  • 不需要经过网络协议栈
  • 不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。

基本上它是当今应用于IPC最主流的方式。至于它到底和普通的socket通信实现起来有什么区别,别着急,我们接着往下看。

3.生成了一个socketQueue,这个queue是串行的,接下来我们看代码就会知道它贯穿于这个类的所有地方。所有对socket以及一些内部数据的相关操作,都需要在这个串行queue中进行。这样使得整个类没有加一个锁,就保证了整个类的线程安全。

4.创建了两个读写队列(本质数组),接下来我们所有的读写任务,都会先追加在这个队列最后,然后每次取出队列中最前面的任务,进行处理。

5.创建了一个全局的数据缓冲区:preBuffer,我们所操作的数据,大部分都是要先存入这个preBuffer中,然后再从preBuffer取出进行处理的。

6.初始化了一个交替延时变量:alternateAddressDelay,这个变量先简单的理解下:就是进行另一个服务端地址请求的延时。后面我们一讲到,大家就明白了。

初始化方法就到此为止了。

接着我们有socket了,我们如果是客户端,就需要去connect服务器。

又或者我们是服务端的话,就需要去bind端口,并且accept,等待客户端的连接。(基本上也没有用iOS来做服务端的吧...)

connect总方法见下篇


全篇地址

CocoaAsyncSocket源码分析---Connect (一)

CocoaAsyncSocket源码分析---Connect (二)

CocoaAsyncSocket源码分析---Connect (三)

CocoaAsyncSocket源码分析---Connect (四)

CocoaAsyncSocket源码分析---Connect (五)

CocoaAsyncSocket源码分析---Connect (六)

CocoaAsyncSocket源码分析---Connect (七)


作者:Cooci

链接:https://www.jianshu.com/p/9968ff0280e5







收起阅读 »

汉字笔顺动画技术剖析

背景 汉字笔顺动画是常见的语文教育需求,我们导入网上开源的 Hanzi Writter 并部署编辑器,应用在大力智能作业灯上。在原版前端实现基础上我们扩展了 Android 和 iOS 端实现,提供更优化的笔顺动画性能。增强对笔顺绘制的控制能力,实现了指定笔...
继续阅读 »

背景


汉字笔顺动画是常见的语文教育需求,我们导入网上开源的 Hanzi Writter 并部署编辑器,应用在大力智能作业灯上。在原版前端实现基础上我们扩展了 Android 和 iOS 端实现,提供更优化的笔顺动画性能。增强对笔顺绘制的控制能力,实现了指定笔顺/笔段渲染,支持笔顺批改和描红能力。



关键技术


1. 字形数据提取


字形是单个字符的外观形状,而字体是具有相同外观样式和排版尺寸的字形集合。基于字形数据,我们可以实现每个文字的渲染和笔顺动画。那么该如何拿到字形数据呢?TTF就是不错的选择。TTF(TrueTypeFont)是一种曲线描边字体文件格式,由Apple公司和Microsoft公司共同推出,其文件扩展名为.ttf。它支持多种字体,如语文课本中常见的楷体。TTF文件由一系列表组成,其中glyf表就包括了字形数据,即字形的轮廓定义和调整指令。依据下述流程可以提取字形数据:



  1. 对不同字体的boundary进行适配,全部转换成1024*1024,并对上下左右进行微调。

  2. 提取所有字形glyph的轮廓点contours和path数据。



提取出字形数据后,使用SVG将对应文字绘制出来。



2. Stroke Extraction 笔画拆取


在第一节中实现了字形数据的提取,它包括字形的轮廓点contours及path(a)。但是仅依靠这些数据无法实现笔顺动画,因为它们只有轮廓信息,没有笔画信息,只要有交叉重叠,都会识别成同一个体(b)。因此,要实现笔顺动画,就必须从源数据中拆取出所有笔画的轮廓数据,即笔画拆取。



2.1 解决思路


由于笔画之间存在交叉重叠(c),若要实现笔画拆取,就需要将笔画交接处的凹点连通起来。这些处于交叉处特殊的拐角点称为corner,连通两个corner形成bridge,表明他们同属于一个笔画。


当顺时针跟踪端点:



  • 连通前:依据轮廓点顺序。


  • 连通后:会依据bridge直接联通对应corner,从而实现笔画的拆分。




因此,笔画拆取工作主要分为以下四步:



2.2 EndPoint Parsing Corner检测


在Hanzi Writter开源库中,笔画拆取算法主要围绕corner展开。那corner是什么呢?详细来说,corner是位于两个笔画轮廓交界的特殊端点,通常情况下,他会有另一个corner与之匹配,如C1和C2。C1和C2相邻且处于笔画A的轮廓同侧,连通C1和C2就可以将笔画A和笔画B拆分开来,得到笔画A自己的轮廓数据。这些corner具有显著的局部特征,它们是字形的凹点,经过该点处的path会沿着顺时针急剧弯曲。计算出所有端点的角度和距离,判断是否拥有该特征,就可以检测哪些端点为corner。



function Endpoint(paths, index) {
this.index = index;
const path = paths[index[0]];
const n = path.length;
this.indices = [[index[0], (index[1] + n - 1) % n], index];
this.segments = [path[(index[1] + n - 1) % n], path[index[1]]];
this.point = this.segments[0].end;
assert(Point.valid(this.point), this.apoint);
assert(Point.equal(this.point, this.segments[1].start), path);
this.tangents = [
Point.subtract(this.segments[0].end, this.segments[0].start),
Point.subtract(this.segments[1].end, this.segments[1].start),
];
const threshold = Math.pow(MIN_CORNER_TANGENT_DISTANCE, 2);
if (this.segments[0].control !== undefined &&
Point.distance2(this.point, this.segments[0].control) > threshold) {
this.tangents[0] = Point.subtract(this.point, this.segments[0].control);
}
if (this.segments[1].control !== undefined &&
Point.distance2(this.point, this.segments[1].control) > threshold) {
this.tangents[1] = Point.subtract(this.segments[1].control, this.point);
}
this.angles = this.tangents.map(Point.angle);
const diff = Angle.subtract(this.angles[1], this.angles[0]);
this.corner = diff < -MIN_CORNER_ANGLE;
return this;
}

2.3 Corner Match Scoring 通过NN评分Corner匹配度


检测出一组corner数据后,就要对这些corner进行一对一匹配,但在匹配前还需要评判哪些corner更有可能连接起来。开源库采用神经网络算法计算corner间的匹配度,它将成为匹配算法中的权重值,使匹配结果更贴近现实情况。


const scoreCorners = (ins, out, classifier) => {
return classifier(getFeatures(ins, out));
}


import {NEURAL_NET_TRAINED_FOR_STROKE_EXTRACTION} from '/lib/net';
import {stroke_extractor} from '/lib/stroke_extractor';

Meteor.startup(() => {
const input = new convnetjs.Vol(1, 1, 8 /* feature vector dimensions */);
const net = new convnetjs.Net();
net.fromJSON(NEURAL_NET_TRAINED_FOR_STROKE_EXTRACTION);
const weight = 0.8;

const trainedClassifier = (features) => {
input.w = features;
const softmax = net.forward(input).w;
return softmax[1] - softmax[0];
}

stroke_extractor.combinedClassifier = (features) => {
return stroke_extractor.handTunedClassifier(features) +
weight*trainedClassifier(features);
}
});

2.4 Corner Matching 通过匈牙利算法进行Corner匹配


在2.3小节中已经通过神经网络产生了权重,接下来就可以使用最简单的匈牙利算法,实现corner匹配。




const matchCorners = (corners, classifier) => {
const matrix = [];
for (let i = 0; i < corners.length; i++) {
matrix.push([]);
for (let j = 0; j < corners.length; j++) {
matrix[i].push(scoreCorners(corners[i], corners[j], classifier)); //corner之间相关性
}
}
for (let i = 0; i < corners.length; i++) {
for (let j = 0; j < corners.length; j++) {
const reversed_score = matrix[j][i] - REVERSAL_PENALTY;
if (reversed_score > matrix[i][j]) {
matrix[i][j] = reversed_score;
}
}
}
return (new Hungarian(matrix)).x_match;
}

2.5 Make Bridges 连通配对端点拆分笔画


依据2.4小节的匹配结果返回一组bridge,其中每个bridge包含两个corner。跟踪轮廓的同时连通corner,就可以提取出每个笔画的轮廓数据,实现笔画拆分。值得注意的是,当遇到多个bridge时,选择与当前path构成最大角度的bridge。



const getBridges = (endpoints, classifier) => {
const result = [];
const corners = endpoints.filter((x) => x.corner);
const matching = matchCorners(corners, classifier);
for (let i = 0; i < corners.length; i++) {
const j = matching[i];
if (j <= i && matching[j] === i) {
continue;
}
result.push([Point.clone(corners[i].point), Point.clone(corners[j].point)]);
}
result.map(checkBridge);
return result;
}

const stroke_paths = extractStrokes(paths, endpoints, bridges, log);
const strokes = stroke_paths.map((x) => svg.convertPathsToSVGPath([x]));

3. Medians 笔画中点生成


在第二节中实现了笔画的拆分,得到每个笔画的轮廓数据。依据轮廓数据可以进一步生成笔画的中点骨架。轮廓决定单个笔画的绘制范围,而中点则决定了绘制的顺序和方向。结合轮廓和中点数据,就可以实现单个笔画的绘制动画。接下来就让我们一起了解,如何通过轮廓数据生成中点。



3.1 Polygon Approximation 端点加密


首先,为了提高中点生成结果的可靠性,需要先采用矢量图形的多边形近似方法进行轮廓点加密。TrueType字体利用二次贝赛尔曲线和直线来描述字形的轮廓,因此加密公式如下:




svg.getPolygonApproximation = (path, approximation_error) => {
const result = [];
approximation_error = approximation_error || 64;
for (let x of path) {
const control = x.control || Point.midpoint(x.start, x.end);
const distance = Math.sqrt(Point.distance2(x.start, x.end));
const num_points = Math.floor(distance/approximation_error);
for (let i = 0; i < num_points; i++) {
const t = (i + 1)/(num_points + 1);
const s = 1 - t;
result.push([s*s*x.start[0] + 2*s*t*control[0] + t*t*x.end[0],
s*s*x.start[1] + 2*s*t*control[1] + t*t*x.end[1]]);
}
result.push(x.end);
}
return result;
}

3.2 Polygon Voronoi 通过冯洛诺伊图(泰森多边形)生成中点


得到加密的轮廓点数据后,就可以通过泰森多边形生成中点。那什么是泰森多边形呢?


首先对一组零散控制点做如下操作:



  1. 将三个相邻控制点连成一个三角形

  2. 对三角形的每条边做垂直平分线

  3. 这些垂直平分线会组成连续多边形


这些连续多边形就是泰森多边形,又叫冯洛诺伊图(Voronoi diagram),得名于Georgy Voronoi。在IS和地理分析中经常采用泰森多边形进行快速插值,分析地理实体的影响区域,是解决邻接度问题的又一常用工具。



通过原理可以了解到,泰森多边形每个顶点是对应三角形的外接圆圆心,因此它到这些控制点的距离相等。


  var sites = [{x:300,y:300}, {x:100,y:100}, {x:200,y:500}, {x:250,y:450}, {x:600,y:150}];
// xl, xr means x left, x right
// yt, yb means y top, y bottom
var bbox = {xl:0, xr:800, yt:0, yb:600};
var voronoi = new Voronoi();
// pass an object which exhibits xl, xr, yt, yb properties. The bounding
// box will be used to connect unbound edges, and to close open cells
result = voronoi.compute(sites, bbox);
// render, further analyze, etc.

按照这个思路,可以将笔画的轮廓点作为控制点,生成泰森多边形。提取泰森多边形的顶点作为笔画中心点。



const findStrokeMedian = (stroke) => {
...
for (let approximation of [16, 64]) {
polygon = svg.getPolygonApproximation(paths[0], approximation);
voronoi = voronoi || new Voronoi;
const sites = polygon.map((point) => ({x: point[0], y: point[1]}));
const bounding_box = {xl: -size, xr: size, yt: -size, yb: size};
try {
diagram = voronoi.compute(sites, bounding_box);
break;
} catch(error) {
console.error(`WARNING: Voronoi computation failed at ${approximation}.`);
}
}
...
}

4. 笔画顺序


第三节实现了单个笔画的绘制动画,但还是需要解决笔画之间的顺序问题。解决问题的关键,就是依据汉字结构,将目标汉字不断拆解成已知顺序的字。


在开源库中提供了汉字分解数据库,关键字段如下:



以【胡】为例,?表示胡为左右结构,左边为古,右边为月。



以【湖】为例,拆解过程如下:




拆解完笔顺后,需要将所有零散的中点数据,与当前所拥有的中点再做一遍匈牙利算法匹配,最终可得到一个相对正确的笔画顺序结果,生成json文件。下面为汉字“丁”的笔顺结果文件。


{"strokes": ["M 531 651 Q 736 675 868 663 Q 893 662 899 670 Q 906 683 894 696 Q 863 724 817 744 Q 801 750 775 740 Q 712 725 483 694 Q 185 660 168 657 Q 162 658 156 657 Q 141 657 141 645 Q 140 632 160 618 Q 178 605 211 594 Q 221 590 240 599 Q 348 629 470 644 L 531 651 Z", "M 435 100 Q 407 107 373 116 Q 360 120 361 112 Q 361 103 373 94 Q 445 39 491 -5 Q 503 -15 518 2 Q 560 60 553 141 Q 541 447 548 561 Q 548 579 550 596 Q 556 624 549 635 Q 545 642 531 651 C 509 671 457 671 470 644 Q 485 629 485 573 Q 488 443 488 148 Q 487 112 477 99 Q 464 92 435 100 Z"], "medians": [[[153, 645], [177, 634], [219, 628], [416, 663], [794, 706], [823, 702], [887, 679]], [[478, 644], [518, 610], [518, 101], [495, 55], [450, 68], [369, 110]]]}

5. 相关参考


本文重点剖析了开源库中笔顺动画的关键技术,相关参考资料如下:



  1. hanziwriter.org/

  2. www.skishore.me/makemeahanz…

  3. github.com/skishore/ma…

收起阅读 »

从一个10年程序员的角度告诉你:搞懂Java面向对象有多容易?

前言: 1)java 面向对象语言,面向过程围绕过程(解决问题步骤),面向对象围绕实体(名词,特性(属性),行为(动作、方法))。它们设计思想区别在于关心核心不同的。 主流都是面向对象的。 实际开发,先按面向对象思想进行设计,具体实现时面向过程(人...
继续阅读 »


前言:



  • 1)java
    面向对象语言,面向过程围绕过程(解决问题步骤),面向对象围绕实体(名词,特性(属性),行为(动作、方法))。它们设计思想区别在于关心核心不同的。


主流都是面向对象的。


实际开发,先按面向对象思想进行设计,具体实现时面向过程(人习惯)



  • 2)java 怎么支持面向对象呢?


a. 万物皆对象,所有的类都是 Object 子类


b. java 中支持单继承,多重继承,Tiger 是 Animal 子类,Animal 是 Object 的子类。满足单继承(每次都一个父类,超类)


c. 面向对象的 4 大特性:封装、继承、多态、抽象



  • 3)封装的优点


a. 隐藏细节,开发者关注内容就少,好写代码,


b. 安全,你不需要知道我内部实现细节,private 修饰后,外部不能访问。


c. 方便修改,私有,外部不能访问,修改不影响其他类(送耦合)



  • 4)继承


a. extends 继承


a.1 继承实现类 class


a.2 继承抽象类 abstract class (必须有抽象方法,子类去实现)


b. implements 实现


实现接口 interface (里面全是抽象方法,子类去实现)


面向对象



  • 1)面向过程(早期)、面向对象(主流)、面向服务(SOA、微服务)(主流,在面向对象基础上)


  • 2)面向过程和面向对象的区别?



编程思想,做一件同样事情,做的思路不同。


思路不同在哪里?


例子:把大象放到冰箱里。(本意:把公大象放到格力冰箱中)需求变更


面向过程:开发步骤(流水账)
a. 把冰箱门打开



  • b. 把什么放进去:大象


  • c. 把大象放入冰箱


  • d. 把冰箱门关上



找出主体:名词(冰箱、大象),围绕它做事


找出动作:动词(打开、放入、关上),强调过程


面向过程:找出把名词主体,和动作(动词)连接起来,最后怎么完成整个过程!


面向对象:



  • a. 找出主体(名词:冰箱、大象)


  • b. 创建模型:(额外考虑,感觉画蛇添足)



冰箱:容大、颜色、品牌、耗电、打开、关上


大象:产地、公母、皮、腿



  • c. 执行步骤


打开冰箱,把大象放入冰箱,关上门


它考虑额外事情,目前为止用户不关心。


在实际开发中,用户不遵守他的话,他说的话不算数。


实际开发中,无法完全(合同),无法严格按合同执行,开发者就必须适应用户的需求变更。


从这一实际角度出发,面向过程思考好还是面向对象思考好!


如果按照面向过程思考,它不能适应用户需求变更,要修改代码,加班加点完成。前面考虑不够完善。


如果按照面向对象思考,它提前考虑很多细节,超过用户要求,表面上多考虑了,但是当用户需求变化,刚好就在我们多考虑范畴中!代码不需改,改动量很少。按期完成,无需额外资金投入。


面向对象要考虑很多,考虑范围,不能太广,过度设计。设计都需要人力物力。


有一个平衡点,设计多大范围合适呢? 系统分析师(高薪!)8 年


到超市购买商品


第一次用户提出需求:要购买白酒和花生米


第二次用户提出需求:要购买白酒和花生米,买猪头肉(需求变更)


第三次用户提出需求:+ 凉菜


用户变化 n 次


面向过程:


1)到哪个超市


2)挑选商品(白酒、花生米)2 种,


3)购买,结束


每次需求都要改,有可能之前代码框架都无法适应新需求,翻天覆地重做。


面向对象:


1)都有哪些超市,都有哪些商品,对超市商品全部建模 2000 种商品


2)到某个超市,挑选商品(白酒、花生米、猪头肉、凉菜)


3)购买,结束


总的来说:面向对象优于面向过程设计,主流设计思想面向对象设计!


java 是怎么支持面向对象设计的?


java 面向对象,c 面向过程,c++面向对象,python 面向对象,javascript 面向对象,vue 框架面向对象


java 四大特性:围绕面向对象而言:封装、继承、多态、抽象


封装


面向过程过程中每个细节都需要开发者去了解,封装改变这样方式,它先进行建模,把名称创建对象,设置它的属性(代表这个事物的特点)和方法(表现一些动作)


把生活中的物品抽象成 java 中的对象


对象为了简单,有些内容对外暴露,有些内容隐藏。隐藏就体现了封装。


例子:手机


如果知道手机所有细节,我们现在都用不上手机。



  • 1)对外暴露:屏幕、键盘、话筒、耳机、充电器

  • 2)隐藏:怎么通讯,运行 app


对有些内容使用者是不关心它的内部实现,手机把这些内容进行封装,用户使用就简单了。


代码如何实现封装呢?


有些功能用户不能去访问,用户不需要知道内容封装。


它需要知道,我们会单独给它接口 api


手机:
1)创建一个类:Phone


2)类中封装,对外看不到,创建属性 call、keys,私有 private (体现了封装)


3)对外暴露:怎么开发 call,怎么开发 keys?公有 public


package cn.tedu.oop;
//模拟手机建模(建立模型)需求中的名词
public class Phone {
//成员变量,可以在多个方法中直接调用
private String call; //模拟打电话特性
private String keys; //模拟手机键盘特性
//如何对外暴露私有的属性,对其进行操作。
//使用getXxx(获取)和setXxx(设置)方法来操作私有属性
//这个业界的规范,开发工具都直接支持自动产生对应属性的get和set方法
//私有属性外部不能访问,但在类的内部的方法可以直接访问
public String getCall() {
return call;
}
//外部怎么去设置成员变量值呢?setCall方法的参数
public void setCall(String call) {
//前面成员变量,后面是参数,参数就保存了用户设置值,
//以后用户使用get方法就可以获取新的值
//参数名和成员变量的名称重复,怎么区分谁是谁呢?
//this.value就可以区分,this代表本类,对象,this.value代表成员变量,就不是参数名
this.call = call;
}
public String getKeys() {
return keys;
}//加入Java开发交流君样:756584822一起吹水聊天
public void setKeys(String keys) {
//警察,监听电话,
//用户只管调用setKeys方法,它并不知道这块代码
if( keys.equals("110") ) { //判断keys值是否为110
System.out.println("通知警察");
}
this.keys = keys;
}
}

在这里插入图片描述


封装的好处


1)把不让外界知道的信息就隐藏起来,外部无法操作。代码比较安全,外部无法操作。


2)代码只能内部处理。当代码修改,所有调用地方都要随之改变,这种结构紧耦合。如果代码只能内部修改,修改后,外部丝毫不影响,这种结构松耦合。程序能实现松耦合就松耦合。封装就实现松耦合结构。内部修改不影响其他代码。


3)封装后,里面程序实现细节,对应调用者来说不关心,只关心如何使用。把复杂问题变简单。


继承


什么叫继承?


java 中继承和生活继承是一样一样的。父类(父亲)和子类(自己)。父亲父亲,子类子类。


java 继承单继承,c 语言允许多继承(c 语言代码质量出错,很难找的其中一个原因)。在这里插入图片描述
结论:java 中单继承,但是可以多层


java 中如何实现继承关系?


实现继承关系提供 2 个方式:


1)extends 关键字(继承):语法 extends 后面跟类(class 实现类、abstract class 抽象类)


Tiger extends Animal


Eagle extends Animal


特点:Animal 是一个实现类,它具体实现 eat 方法


抽象类特点:它有部分实现(父类自己实现)和部分规定不实现的(子类去实现)


Tiger extends AbstractAnimal(抽象类中有抽象方法,抽象方法父类不实现,压到子类去实现)


2)implements 关键字(实现):语法 implements 后面跟接口(interface)


接口特点:所有方法都是抽象方法,一点活都不干,它指手画脚(它要规定子类实现方法)


为什么要使用继承?


我们可以从父类继承它的属性和方法,在子类中直接调用父类资源(属性和方法),方法和属性都是 public。在这里插入图片描述
在这里插入图片描述
通过对上面两个类的观察:


1)它们有共性,eat 方法一样


2)它们有个性,Tiger 类它有自己的 run()方法,Eagle 类它有自己的 fly()方法。


有共性有不同!在这里插入图片描述
缺点:


共性的方法,出现在多个类中,如果业务需要修改,要修改多处,工作量大,容易造成失误,这个类改,那个类


忘改,造成结果不一致!


继承


解决办法?就是继承!


1)要把共性方法抽取出来,放到一个单独类中 Animal


2)把共性方法就从当前类中删除


3)两个类连接起来,使用继承,Tiger extends Animal,可以在子类中直接访问父类方法 eat()
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


抽象类


在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


接口


在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


实现类和抽象类和接口区别


在这里插入图片描述


为什么需要抽象类?


学框架,框架中很多抽象类。例如:spring 框架


框架要做很多底层公用事情,让我们写代码利用框架,程序更加健壮,更加安全,


业务需求私有事情,还得我们去实现


公用框架实现,私有我们自己实现,我们自己写代码怎么和框架对接。框架进行规定!


规范私有类(抽象方法声明)


为什么需要接口?


提倡面向接口开发,你可以实现接口,别人可不可以接口。


java JDBC 数据库一套规范,java 自身规定接口,其他厂商去实现


mysql 数据库厂商,Mysql 的实现;oracle 数据库厂商,oracle 的实现。


小结


今日给大家分享的是面向对象,这对于很多小白来说很友好的,大家都能看得懂!
生命不止坚毅鱼奋斗,有梦想才是有意义的追求
给大家推荐一个免费的学习交流群:
最后,祝大家早日学有所成,拿到满意offer,快速升职加薪,走上人生巅峰。
Java开发交流君样:756584822

收起阅读 »

不懂泛型,怎么装逼,一文把泛型说的明明白白,安排!!!

目录 前言 1、泛型的概念 2、泛型的使用 3、泛型原理,泛型擦除 3.1 IDEA 查看字节码 3.2 泛型擦除原理 4、?和 T 的区别 5、super extends 6、注意点 1、静态方法无法访问类的泛型 2、创建之后无法修改类...
继续阅读 »

图片


目录


前言


1、泛型的概念


2、泛型的使用


3、泛型原理,泛型擦除


3.1 IDEA 查看字节码


3.2 泛型擦除原理


4、?和 T 的区别


5、super extends


6、注意点


1、静态方法无法访问类的泛型


2、创建之后无法修改类型


3、类型判断问题


4、创建类型实例


7、总结




前言


 


泛型是Java中的高级概念,也是构建框架必备技能,比如各种集合类都是泛型实现的,今天详细聊聊Java中的泛型概念,希望有所收获。记得点赞,关注,分享哦。


1、泛型的概念


泛型的作用就是把类型参数化,也就是我们常说的类型参数


平时我们接触的普通方法的参数,比如public void fun(String s);参数的类型是String,是固定的


现在泛型的作用就是再将String定义为可变的参数,即定义一个类型参数T,比如public static <T> void fun(T t);这时参数的类型就是T的类型,是不固定的


泛型常见的字母有以下:


? 表示不确定的类型
T (type) 表示具体的一个java类型
K V (key value) 分别代表java键值中的Key Value
E (element) 代表Element

这些字母随意使用,只是代表类型,也可以用单词。


2、泛型的使用


泛型有三种使用方式,分别为:泛型类、泛型接口、泛型方法。


类的使用地方是


方法的使用地方



  • Java泛型类


  • Java泛型方法


  • Java泛型接口



/**
* @author 香菜
*/
public class Player<T> {// 泛型类
  private T name;
  public T getName() {
      return name;
  }
  public void setName(T name) {
      this.name = name;
  }
}


 


public class Apple extends Fruit {
  public <T> void getInstance(T t){// 泛型方法
      System.out.println(t);
  }
}


public interface Generator<T> {
   
      public T next();
   
  }


3、泛型原理,泛型擦除


3.1 IDEA 查看字节码


1、创建Java文件,并编译,确认生成了class


图片


2、idea ->选中Java 文件 ->View


图片


3.2 泛型擦除原理


我们通过例子来看一下,先看一个非泛型的版本:


图片


从字节码可以看出,在取出对象的的时候我们做了强制类型转换。


下面我们给出一个泛型的版本,从字节码的角度来看看:


图片


在编译过程中,类型变量的信息是能拿到的。所以,set方法在编译器可以做类型检查,非法类型不能通过编译。但是对于get方法,由于擦除机制,运行时的实际引用类型为Object类型。为了“还原”返回结果的类型,编译器在get之后添加了类型转换。所以,在Player.class文件main方法主体第18行有一处类型转换的逻辑。它是编译器自动帮我们加进去的


所以在泛型类对象读取和写入的位置为我们做了处理,为代码添加约束。


泛型参数将会被擦除到它的第一个边界(边界可以有多个,重用 extends 关键字,通过它能给与参数类型添加一个边界)。编译器事实上会把类型参数替换为它的第一个边界的类型。如果没有指明边界,那么类型参数将被擦除到Object。


4、?和 T 的区别


?使用场景 和Object一样,和C++的Void 指针一样,基本上就是不确定类型,可以指向任何对象。一般用在引用。


T 是泛型的定义类型,在运行时是确定的类型。


5、super extends


通配符限定:


<? extends T>:子类型的通配符限定,以查询为主,比如消费者集合场景


<? super T>:超类型的通配符限定,以添加为主,比如生产者集合场景


super 下界通配符 ,向下兼容子类及其子孙类, T super Child 会被擦除为 Object


extends 上界通配符  ,向下兼容子类及其子孙类, T extends Parent 会被擦除为 Parent



class Fruit {}
class Apple extends Fruit {}
class FuShi extends Apple {}
class Orange extends Fruit {}

import java.util.ArrayList;
import java.util.List;

public class Aain {
 public static void main(String[] args) {
       //上界
       List<? extends Fruit> topList = new ArrayList<Apple>();
       topList.add(null);
       //add Fruit对象会报错
       //topList.add(new Fruit());
       Fruit fruit1 = topList.get(0);

       //下界
       List<? super Apple> downList = new ArrayList<>();
       downList.add(new Apple());
       downList.add(new FuShi());
       //get Apple对象会报错
       //Apple apple = downList.get(0);
}

上界 <? extend Fruit> ,表示所有继承Fruit的子类,但是具体是哪个子类,但是肯定是Fruit


下界 <? super Apple>,表示Apple的所有父类,包括Fruit,一直可以追溯到老祖宗Object 。


归根结底可以用一句话表示,那就是编译器可以支持向上转型,但不支持向下转型。具体来讲,我可以把Apple对象赋值给Fruit的引用,但是如果把Fruit对象赋值给Apple的引用就必须得用cast。


6、注意点


1、静态方法无法访问类的泛型


图片


可以看到Idea 提示无法引用静态上下文。


2、创建之后无法修改类型


List<Player> 无法插入其他的类型,已经确定类型的不可以修改类型


3、类型判断问题


问题:因为类型在编译完之后无法获取具体的类型,所以在运行时是无法判断类的类型。


我们可以通过下面的代码来解决泛型的类型信息由于擦除无法进行类型判断的问题:


/**
* 判断类型
* @author 香菜
* @param <T>
*/
public class GenClass<T> {
   Class<?> classType;
   public GenClass(Class<?> classType) {
       this.classType = classType;
  }
   public boolean isInstance(Object object){
       return classType.isInstance(object);
  }
}

解决方案:我们通过在创建对象的时候在构造函数中传入具体的class类型,然后通过这个Class对象进行类型判断。


4、创建类型实例


问题:泛型代码中不能new T()的原因有两个,一是因为擦除,不能确定类型;而是无法确定T是否包含无参构造函数。


在之前的文章中,有一个需求是根据不同的节点配置实例化创建具体的执行节点,即根据IfNodeCfg 创建具体的IfNode.


/**
* 创建实例
* @author 香菜
*/
public abstract class AbsNodeCfg<T> {
   public abstract T getInstance();
}

public class IfNodeCfg extends AbsNodeCfg<IfNode>{
   @Override
   public IfNode getInstance() {
       return new IfNode();
  }
}

 



/**
* 创建实例
* @author 香菜
*/
public class IfNode {
}

解决方案:通过上面的方式可以根据具体的类型,创建具体的实例,扩展的时候直接继承AbsNodeCfg,并且实现具体的节点就可以了。


7、总结


泛型相当于创建了一组的类,方法,虚拟机中没有泛型类型对象的概念,在它眼里所有对象都是普通对象


图片


有疑问的可以留言,我们一起讨论,没有问题的也可以留言,我们交个朋友


打字不容易,点赞,转发,关注三连,谢谢大家支持。

收起阅读 »

程序员五一被拉去相亲,结果彻底搞懂了HTTP常用状态码

我有一个朋友…… 叫小星,是个北漂程序员。 小星年纪不小了,还是个单身狗。家里很着急,小星也很着急。 可是,小星起身一看,眼前一闪闪闪闪闪闪闪闪闪…… ——全是秃头抠脚大汉…… 前一阵子好不容易来个实习生小姑娘,分给小星带,小星高兴坏了,结果姑娘...
继续阅读 »


我有一个朋友……


叫小星,是个北漂程序员。


小星年纪不小了,还是个单身狗。家里很着急,小星也很着急。


可是,小星起身一看,眼前一闪闪闪闪闪闪闪闪闪……


——全是秃头抠脚大汉……


img


前一阵子好不容易来个实习生小姑娘,分给小星带,小星高兴坏了,结果姑娘没呆三天,受不了公司的九九六跑了。


所以,部门彻底沦为了和尚部门,拔剑四顾心茫然,不见妹子只见男。



404(Not Found):服务器无法根据客户端的请求找到资源(网页)



茫然


老妈打电话又催了,小星说工作忙,没有女同事。


老妈问和你一起长大的小美还联系吗?


小美已经嫁做人妇,孩子都会叫爸爸了。



  • 301(Moved Permanently):永久移动。请求的资源已被永久的移动到新URI。


痛哭


那你大学时谈过的小静呢?


小静已经重新开始,找了新的男朋友。



302(Found):临时移动。与301类似。但资源只是临时被移动。



哭泣

那我帮你安排相亲好了。


什么……好吧。


五一回家,老妈给小星安排了相亲。小星蔫头巴脑地去见相亲的小姐姐了,没想到,小姐姐还挺白净,小星一下子就支棱起来了。


小星高兴地和妹子聊了起来。妹子说她最近去过西安旅游,平时会去游泳,喜欢吃一些小吃。


——这,九九六的工作节奏基本剥夺了小星的业余生活,小星冷汗连连,只能“嗯”、“啊”。


终于,妹子问了一句,你的工作怎么样?


小星的脸上一下子泛起了红光,眼神里带着一种神圣的色彩。


你知道能应对亿级流量的高并发架构如何搭建吗?你知道高并发下保证幂等性的几种方式吗?你知道保证Redis高可用的几种方法吗……啊吧啊吧



400(Bad Request):客户端请求的语法错误,服务器无法理解。



什么东西


妹子说,我吹了风,感觉头有点疼,今天就先到这吧。



500(Internal Server Error):服务器内部错误,无法完成请求。



尴尬又不失礼貌的微笑


小星垂头丧气地回家了,老妈一看小星这个样子,就知道什么情况。


— 没有关系,明天我给你安排了第二场,好好休息准备,明天好好表现。


小星夜里失眠了,辗转反侧。


明天的姑娘是什么样?和照片上差别大吗?应该很温柔吧?……


到了半夜,小星终于睡着了。


小星做了一个好梦,他和一个叫小萌的姑娘在一起了。过了一段时间,小星想?乛?乛?,小萌:? ?° ?? ?° ?,讨厌,,才确定关系多久。



403(Forbidden):服务器理解请求客户端的请求,但是拒绝执行此请求。



害羞


但是耐不住小星的软磨硬泡,还是……结果闹出人命了,但是孩子还不能生。


因为没有结婚证和准生证。



401(Unauthorized):请求需要有通过HTTP认证(BASIC认证,DIGEST认证)的认证信息。



突然领证


后来,小星和小萌结婚了,生了一个可爱的孩子,一家三口过上了幸福的生活。



200(OK):请求成功。



突然,一个容嬷嬷跳了出来。


臭小子,彩礼钱没给够,挨我一针吧!


小星一下子惊醒。


还好,只是一场梦。


天已经亮了,小星强打精神,去参加今天的相亲了。


至于结果,标题里已经说明了。


表情







End!

纯属瞎侃,请勿当真!——“任由你 自由的 耗在我苦中作乐”


参考:


【1】:浪漫故事:常见HTTP状态码的另类解析

收起阅读 »

Android开发基础之控件CheckBox

目录 一、基础属性 二、自定义样式 1、去掉CheckBox的勾选框 2、自定义背景颜色 3、自定义勾选框的背景图片 三、监听事件       &nb...
继续阅读 »





       


一、基础属性























































1、layout_width 宽度
2、layout_height 高度
3、id 设置组件id
4、text 设置显示的内容
5、textColor 设置字体颜色
6、textStyle 设置字体风格:normal(无效果)、bold(加粗)、italic(斜体)
7、textSize 字体大小,单位常用sp
8、background 控件背景颜色
9、checked 默认选中该选项
10、orientation 内部控件排列的方向,例如水平排列或垂直排列
11、paddingXXX 内边距,该控件内部控件间的距离
12、background 控件背景颜色或图片

       
1、layout_width
2、layout_height


        组件宽度和高度有4个可选值,如下图:
在这里插入图片描述


       
3、id


// activity_main.xml
android:id="@+id/cb1" // 给当前控件取个id叫cb1

// MainActivity.java
CheckBox cb1=findViewById(R.id.cb1); // 按id获取控件
cb1.setText("hh"); // 对这个控件设置显示内容

        如果在.java和.xml文件中对同一属性进行了不同设置,比如.java中设置控件内容hh,.xml中设置内容为aa,最后显示的是.java中的内容hh。


       
4、text
       可以直接在activity_main.xml中写android:text="嘻嘻",也可以在strings.xml中定义好字符串,再在activity_main.xml中使用这个字符串。


// strings.xml
<string name="str1">嘻嘻</string>

// activity_main.xml
android:text="@string/str1"

       
5、textColor
       与text类似,可以直接在activity_main.xml中写android:textColor="#FF0000FF",也可以在colors.xml中定义好颜色,再在activity_main.xml中使用这个颜色。


       
       


9、checked
       checked=“true”,默认这个RadioButton是选中的。该属性只有在RadioGroup中每个RadioButton都设置了id的条件下才有效。


       


       


10、orientation


内部控件的排列方式:



  • orientation=“vertical”,垂直排列

  • orientation=“horizontal”,水平排列


       


11、paddingXXX


内边距,该控件与内部的控件间的距离,常用的padding有以下几种:



  • padding,该控件与内部的控件间的距离

  • paddingTop,该控件与内部的控件间的上方的距离

  • paddingBottom,该控件与内部的控件间的下方的距离

  • paddingRight,该控件与内部的控件间的左侧的距离

  • paddingLeft,该控件与内部的控件间的右侧的距离


       


程序示例:


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:orientation="vertical">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/tv1"
android:text="你喜欢做什么?"
android:textSize="50sp"
android:textColor="@color/i_purple_700">

</TextView>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb1"
android:text="吃饭"
android:textSize="50sp"
android:checked="true">

</CheckBox>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb2"
android:text="睡觉"
android:textSize="50sp"
android:checked="true">

</CheckBox>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb3"
android:text="工作"
android:textSize="50sp"
android:checked="true">

</CheckBox>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb4"
android:text="玩耍"
android:textSize="50sp"
android:checked="true">

</CheckBox>
</LinearLayout>

效果:


       


       


       


二、自定义样式


       


1、去掉CheckBox的勾选框


       在CheckBox的属性里写上button="@null"
程序示例:


<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb1"
android:text="吃饭"
android:textSize="50sp"
android:button="@null">

</CheckBox>

效果:


“吃饭”前面的勾选框没有了,不过这样做,你最好设置选中时一个背景,未选中时一个背景,不然像图上那样根本看不出来选没选中“吃饭”。


       


2、自定义背景颜色


新建一个选择器selector
在这里插入图片描述
在这里插入图片描述


       


你取的名字.xml文件内编写代码:



  • item android:state_checked=“false” , 未选中这个CheckBox时的样式

  • item android:state_checked=“true” ,选中这个CheckBox时的样式

  • solid android:color="@color/yellow_100" ,设置实心的背景颜色

  • stroke android:width=“10dp” ,设置边框粗细
               android:color="@color/i_purple_700" ,设置边框颜色

  • corners android:radius=“50dp” ,设置边框圆角大小


程序示例:
blue_selector.xml


<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="false">
<shape android:shape="rectangle">
<solid android:color="@color/blue_100"></solid>
<stroke android:color="@color/blue_700" android:width="5dp"></stroke>
<corners android:radius="30dp"></corners>
</shape>
</item>
<item android:state_checked="true">
<shape android:shape="rectangle">
<solid android:color="@color/blue_500"></solid>
<corners android:radius="30dp"></corners>
</shape>
</item>
</selector>

activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:orientation="vertical">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/tv1"
android:text="你喜欢做什么?"
android:textSize="50sp"
android:textColor="@color/i_purple_700"
android:paddingBottom="20dp"
android:paddingTop="20dp">

</TextView>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb1"
android:text="吃饭"
android:textSize="50sp"
android:background="@drawable/blue_selector">

</CheckBox>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb2"
android:text="睡觉"
android:textSize="50sp"
android:background="@drawable/purple_selector">

</CheckBox>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb3"
android:text="工作"
android:textSize="50sp"
android:background="@drawable/blue_selector">

</CheckBox>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb4"
android:text="玩耍"
android:textSize="50sp"
android:background="@drawable/purple_selector">

</CheckBox>
</LinearLayout>

都未选:


都选中:


       


       


3、自定义勾选框的背景图片


导入背景图片:
在这里插入图片描述
在这里插入图片描述
       


然后新建一个选择器selector
在这里插入图片描述
在这里插入图片描述


       


你取的名字.xml文件内编写代码:



  • item android:state_checked=“false” , 未选中这个CheckBox时的样式

  • item android:state_checked=“true” ,选中这个CheckBox时的样式

  • drawable="@drawable/ic_pink_heart",设置背景图片


程序示例:
bg_checkbox.xml


<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_pink_heart" android:state_checked="true"></item>
<item android:drawable="@drawable/ic_heart_broken" android:state_checked="false"></item>
</selector>

activity_main.xml里写button="@drawable/bg_checkbox",使用写好的背景文件bg_checkbox.xml


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:orientation="vertical">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/tv1"
android:text="你喜欢做什么?"
android:textSize="50sp"
android:textColor="@color/i_purple_700"
android:paddingBottom="20dp"
android:paddingTop="20dp">

</TextView>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb1"
android:text="吃饭"
android:textSize="50sp"
android:button="@drawable/bg_checkbox">

</CheckBox>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb2"
android:text="睡觉"
android:textSize="50sp"
android:button="@drawable/bg_checkbox">

</CheckBox>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb3"
android:text="工作"
android:textSize="50sp"
android:button="@drawable/bg_checkbox">

</CheckBox>
<CheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/cb4"
android:text="玩耍"
android:textSize="50sp"
android:button="@drawable/bg_checkbox">

</CheckBox>
</LinearLayout>

都未选:


都选中:


当然这个图标换成√或者×都是可以的。


       


       


       


三、监听事件


        在MainActivity.java内添加监听,当CheckBox的选中状态发生变化时,就会执行写好的操作:


public class MainActivity extends AppCompatActivity {
private CheckBox cb1;
private CheckBox cb2;
private CheckBox cb3;
private CheckBox cb4;

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

// 获取控件id
cb1=findViewById(R.id.cb1);
cb2=findViewById(R.id.cb2);
cb3=findViewById(R.id.cb3);
cb4=findViewById(R.id.cb4);
// 监听选中状态
cb1.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
Toast.makeText(MainActivity.this,isChecked?"选中":"未选中",Toast.LENGTH_SHORT).show();
}
});
cb2.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
Toast.makeText(MainActivity.this,isChecked?"选中":"未选中",Toast.LENGTH_SHORT).show();
}
});
cb3.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
Toast.makeText(MainActivity.this,isChecked?"选中":"未选中",Toast.LENGTH_SHORT).show();
}
});
cb4.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
Toast.makeText(MainActivity.this,isChecked?"选中":"未选中",Toast.LENGTH_SHORT).show();
}
});
}
}

       


选中某个CheckBox时,弹出选中提示信息:


       

收起阅读 »

Android自定义弧型View

好久没动手玩View自定义,有点生疏了。效果如下 思路很简单,onDraw绘制弧线、绘制Text canvas.drawArc(oval, startAngle, sweepAngle, false, paint); canvas.drawText...
继续阅读 »


好久没动手玩View自定义,有点生疏了。效果如下


思路很简单,onDraw绘制弧线、绘制Text



canvas.drawArc(oval, startAngle, sweepAngle, false, paint);

canvas.drawText(integralValue, rectF.centerX(), baseline, textPaint);


draw 动画效果实现:invalidate


canvas.drawArc(oval, startAngle, i, false, paint);

if(i<sweepAngle){
i = i+10;//sweepAngle :240,所以这里+10没问题,具体细节具体修改
getHandler().postDelayed(new Runnable() {
@Override
public void run() {
invalidate();
}
},50);
}

绘制的效果差别有点大不理想,先给画笔添加抗锯齿和画笔圆角


textPaint.setAntiAlias(true);
textPaint.setTypeface(Typeface.DEFAULT_BOLD);

drawText让文字居中对齐,一定要算好边界和baseLine



RectF rectF = new RectF(borderWidth * 2 + arcMargin, borderWidth * 2 + arcMargin, getMeasuredWidth() - arcMargin - borderWidth * 2, getMeasuredHeight() - arcMargin - borderWidth * 2);

//计算baseline
Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
float distance = (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom;
float baseline = rectF.centerY() + distance;
canvas.drawText(integralValue, rectF.centerX(), baseline, textPaint);

积分商城文本背景可以用shape绘制,这里直接代码搞一个drawable


   /**
* 设置圆角背景
*
* @param
* @param radiiValue 设置图片四个角圆形半径
* @return
*/
@SuppressLint("WrongConstant")
public static GradientDrawable getBackgroundShapeDrawable(int color, float radiiValue) {
float[] radii = new float[] { radiiValue, radiiValue, radiiValue, radiiValue, radiiValue, radiiValue, radiiValue,radiiValue };
GradientDrawable drawable = new GradientDrawable();
drawable.setShape(GradientDrawable.RECTANGLE);
drawable.setGradientType(GradientDrawable.RECTANGLE);
drawable.setCornerRadii(radii);
drawable.setColor(color);
return drawable;
}

居中文字大小不能固定值,防止超出弧型,要跟据文字长度动态调整



textPaint.setTextSize(getIntegralTextSize(integralValue));

protected int getIntegralTextSize(String text) {
int flag = 80;
if (text.length() == 8) {
flag = 70;
} else if (text.length() == 9) {
flag = 60;
} else if (text.length() >= 10) {
flag = 50;
}

return flag;
}

源码就不上传了,实现起来还是不难用了十几分钟,等下班咯

收起阅读 »

音视频开发必备基础知识点整理

IM
日常工作中都会接触到音视频的开发,比如目前工作中都会涉及到 TSPlayer、IjkPlayer、MediaPlayer 提供播放能力,不管是什么 Player,其上层调用都是大同小异,但是具体实现以及能够支持的能力各不相同,要想继续深入就必须深入音视频的学习...
继续阅读 »


日常工作中都会接触到音视频的开发,比如目前工作中都会涉及到 TSPlayer、IjkPlayer、MediaPlayer 提供播放能力,不管是什么 Player,其上层调用都是大同小异,但是具体实现以及能够支持的能力各不相同,要想继续深入就必须深入音视频的学习,Android 开发的几个主要方向分别是应用、Framework、音视频、NDK等,如果继续在 Android 领域,这些坑还是是必须要填的,主要内容如下:



1.视频编码
2.音频编码
3.多媒体播放组件
4.帧率
5.分辨率
6.编码格式
7.封装格式
8.码率
9.颜色空间
10.采样率
11.量化精度
12.声道




视频编码


视频编码指的是通过特定的压缩技术,将某个视频文件格式转换为另一种视频格式文件的方式,视频传输中主要编解码标准如下:



  • 运动静止图像专家组的 M-JPEG



    • M-JPEG 是一种图像压缩编码标准,是 Motion-JPEG 的简称,JPEG 标准主要是用来处理静止图像,而 M-JPEG 把运动的视频序列作为连续的静止图像来处理,这种压缩方式单独完整地压缩每一帧,在编辑过程中可随机存储每一帧,可进行精确到帧的编辑,M-JPEG 只对帧内的空间冗余进行压缩,不对帧间的时间冗余进行压缩,故压缩效率不高。


  • 国际标准化组织(ISO)运动图像专家组的 MPEG 系列标准



    • MPEG 标准主要有五个:MPEG-1、MPEG-2、MPEG-4、MPEG-7 及 MPEG-21 等,MPEG 标准的视频压缩编码技术主要利用了具有运动补偿的帧间压缩编码技术以减小时间冗余度,利用 DCT 技术以减小图像的空间冗余度,利用熵编码则在信息表示方面减小了统计冗余度。这几种技术的综合运用,大大增强了压缩性能。


  • 国际电联(ITU-T)的 H.261、H.263、H.264等



    • H.261:第一个实用的数字视频解码标准,采用的压缩算法是运动补偿帧间预测与分块 DCT 相结合的混合编码,其运动补偿使用用全像素精度和环路滤波,支持 CIF 和 QCIF 两种分辨率。

    • H.263:H.263 与 H.261 编码算法一样,但是做了一点改善,使得 H.263 标准在低码率下能够提供比 H.261 更好的图像效果,其运动补偿使用半像素精度,支持 CIF、QCIF 、SQCIF、4CIF和16CIF 五种分辨率。

    • H.264:H.264则是由两个组织 ISO 和 ITU-T 联合组建的联合视频组(JVT)共同制定的新数字视频编码标准,所以它既是 ITU-T 的H.264,又是 ISO/IEC 的 MPEG-4 高级视频编码(Advanced Video Coding,AVC)的第 10 部分,因此,不论是MPEG-4 AVC、MPEG-4 Part 10,还是 ISO/IEC 14496-10,都是指 H.264,H.264 是基于传统框架的混合编码系统,做了局部优化,注重编码效率和可靠性。H.264 在具有高压缩比的同时还拥有高质量流畅的图像,经过 H.264 压缩的视频数据,在网络传输过程中所需要的带宽更少,是压缩率最高的视频压缩标准。



音频编码


常见的音频编解码标准如下:



  • ITU:G.711、G.729 等

  • MPEG:MP3、AAC 等

  • 3GPP:AMR、AMR-WB、AMR-WB+等

  • 还有企业制定的标准,如 Dolby AC-3、DTS 、WMA 等


常见的介绍如下:



  • MP3(MPEG-1 audio layer 3):一种音频压缩技术,它被设计用来大幅度地降低音频数据量,利用 MPEG Audio Layer 3 的技术,将音乐以 1:10 甚至 1:12 的压缩率,压缩成容量较小的文件,而对于大多数用户来说重放的音质与最初的不压缩音频相比没有明显的下降,它是利用人耳对高频声音信号不敏感的特性,将时域波形信号转换成频域信号,并划分成多个频段,对不同的频段使用不同的压缩率,对高频加大压缩比(甚至忽略信号),对低频信号使用小压缩比,保证信号不失真,这样就相当于抛弃人耳基本听不到的高频声音,只保留能听到的低频部分,从而对音频进行一定压缩,此外 MP3 属于有损压缩的文件格式。


  • AAC:Advanced Audio Coding 的缩写,最初是基于 MPEG-2 的音频编码技术,MPEG-4 出现后,AAC 重新集成了其特性,且加入了SBR 技术和 PS 技术,为了区别于传统的 MPEG-2 AAC 又称为MPEG-4 AAC,AAC 是一种专为声音数据设计的文件压缩格式,相较 MP3,AAC 格式的音质更佳,文件更小,但是 AAC 是一种有损压缩格式,随着大容量设备的出现,其优势将越来越小。


  • WMA:Windows Media Audio 的缩写,是微软公司开发的一系列音频编解码器,也指相应的数字音频编码格式,WMA 包括四种不同的编解码器:WMA,原始的WMA编解码器,作为 MP3 和 RealAudio 编解码器的竞争者;WMA Pro,支持更多声道和更高质量的音频[;WMA Lossless,无损编解码器;WMA Voice,用于储存语音,使用的是低码率压缩。一些使用 Windows Media Audio 编码格式编码其所有内容的纯音频 ASF 文件也使用 WMA 作为扩展名,其特点是支持加密,非法拷贝到本地是无法播放的,WMA 也属于有损压缩的文件格式。



更多音视频编解码标准可以参考:音频编解码标准


多媒体播放组件


Android 多媒体播放组件包含 MediaPlayer、MediaCodec、OMX 、StageFright、AudioTrack 等,具体如下:



  • MediaPlayer:为应用层提供的播放控制接口

  • MediaCodec:提供访问底层媒体编解码器的接口

  • OpenMAX :Open Media Acceleration,又缩写为 OMX,开放多媒体加速层,是一个多媒体应用程序标准,Android 主要的多媒体引擎StageFright 是透过 IBinder 使用 OpenMax,用于编解码处理。

  • StageFright:Android 2.2 开始引入用来替换预设的媒体播放引擎 OpenCORE,Stagefright 是位于 Native 层的媒体播放引擎,内置了基于软件的编解码器,且适用于热门媒体格式,其编解码功能是利用OpenMAX 框架,引入的是 OpenCORE 的 omx-component 部分,在 Android 中是以共享库的形式存在,对应 libstagefright.so。

  • AudioTrack:管理和播放单个音频资源,仅支持 PCM 流,如大多数的 WAV 格式的音频文件就是就是 PCM 流,这类音频文件支持 AudioTrack 直接进行播放。


常见的多媒体框架及解决方案


常见的多媒体框架及解决方案有 VLC 、 FFmpeg 、 GStream 等,具体如下:



  • VLC : 即 Video LAN Client,是一款自由、开源的跨平台多媒体播放器及框架 。

  • FFmpeg:多媒体解决方案,不是多媒体框架,广泛用于音视频开发中。

  • GStreamer : 一套构建流媒体应用的开源多媒体框架 。


帧率


帧率是用于测量显示帧数的量度。单位为「每秒显示帧数」(Frame per Second,FPS)或「赫兹,Hz」,表示每秒的帧数(FPS)或者说帧率表示图形处理器处理场时每秒钟能够更新的次数,高的帧率可以得到更流畅、更逼真的动画,一般来说 30fps 就是可以接受的,但是将性能提升至 60fps 则可以明显提升交互感和逼真感,但是一般来说超过 75fps 一般就不容易察觉到有明显的流畅度提升了,如果帧率超过屏幕刷新率只会浪费图形处理的能力,因为监视器不能以这么快的速度更新,这样超过刷新率的帧率就浪费掉了。


分辨率


视频分辨率是指视频成像产品所形成的图像大小或尺寸,常见的 1080P、4K 等有代表什么呢,P 本身的含义是逐行扫描,表示视频像素的总行数,1080P 表示总共有 1080 行的像素数,而 K 表示视频像素的总列数,4K 表示有 4000 列的像素数,通常来说,1080P 就是指 1080 x 1920 的分辨率,4 k 指 3840 x 2160 的分辨率。


刷新率


刷新率就是屏幕每秒画面被刷新的次数,刷新率分为垂直刷新率和水平刷新率,一般提到的刷新率通常指垂直刷新率,垂直刷新率表示屏幕的图象每秒钟重绘多少次,也就是每秒钟屏幕刷新的次数,以 Hz(赫兹)为单位,刷新率越高越好,图象就越稳定,图像显示就越自然清晰,对眼睛的影响也越小,刷新频率越低,图像闪烁和抖动的就越厉害,眼睛疲劳得就越快,一般来说,如能达到 80Hz 以上的刷新频率就可完全消除图像闪烁和抖动感,眼睛也不会太容易疲劳。


编码格式


针对音视频来说,编码格式对应的就是音频编码和视频编码,对照前面的音频编码标准和视频编码标准,每种编码标准都对应的编码算法,其目的是通过一定编码算法实现数据的压缩、减少数据的冗余。


封装格式


直接看下百度百科的关于封装格式的介绍,封装格式(也叫容器),就是将已经编码压缩好的视频轨和音频轨按照一定的格式放到一个文件中,也就是说仅仅是一个外壳,或者大家把它当成一个放视频轨和音频轨的文件夹也可以,说得通俗点,视频轨相当于饭,而音频轨相当于菜,封装格式就是一个碗,或者一个锅,用来盛放饭菜的容器。


码率


码率,也就是比特率(Bit rate),指单位时间内传输或处理的比特的数量,单位为 bps(bit per second)也可表示为 b/s,比特率越高,单位时间传送的数据量(位数)越大,多媒体行业在指音频或视频在单位时间内的数据传输率时通常使用码率,单位是 kbps,一般来说,如果是 1M 的宽带,在网上只能看码流不超过 125kbps 的视频,超过 125kbps 的视频只能等视频缓冲才能顺利观看。


码率一般分为固定码率和可变码率:



  • 固定码率会保证码流的码率恒定,但是会牺牲视频质量,比如为了保证码率恒定,某些图像丰富的内容就是失去某些图像细节而变得模糊。

  • 可变码率指的是输出码流的码率是可变的,因为视频信源本身的高峰信息量是变化的,从确保视频传输质量和充分利用信息的角度来说,可变码率视频编码才是最合理的。


码率的高低与视频质量和文件提交成正比,但当码率超过一定数值后,对视频质量没有影响。


颜色空间



  • YUV:一种颜色编码方法,一般使用在在影像处理组件中,YUV 在对照片或视频编码时,考虑到人类的感知能力,允许降低色度的带宽,其中 Y 表示明亮度、U 表示色度、V 表示浓度,Y′UV、YUV、YCbCr、YPbPr 所指涉的范围,常有混淆或重叠的情况。从历史的演变来说,其中 YUV 和 Y’UV 通常用来编码电视的模拟信号,而 YCbCr 则是用来描述数字的影像信号,适合视频与图片压缩以及传输,例如 MPEG、JPEG,现在 YUV 通常已经在电脑系统上广泛使用。

  • RGB:原色光模式,又称 RGB 颜色模型或红绿蓝颜色模型,是一种加色模型,将红(Red)、绿(Green)、[蓝(Blue)三原色的色光以不同的比例相加,以合成产生各种色彩光,目前的大多数显示器都采用 RGB 这种颜色标准。


YUV 主要用于优化彩色视频信号的传输,使其向后相容老式黑白电视,与 RGB 视频信号传输相比,它最大的优点在于只需占用极少的带宽。


采样率


采样率,表示每秒从连续信号中提取并组成离散信号的采样个数,用赫兹(Hz)来表示,采样率是指将模拟信号转换成数字信号时的采样频率,人耳能听到的声音一般在 20Hz~20KHz 之间,根据采样定理,采样频率大于信号中最高频率的 2 倍时,采样之后的数字信号便能完整的反应真实信号,常见的采样率如下:



  • 8000 Hz:电话所用采样率, 对于人的说话已经足够

  • 11025 Hz:AM调幅广播所用采样率

  • 22050 Hz 和 24,000 Hz:FM调频广播所用采样率

  • 44100Hz:音频CD,常用于 MPEG-1 音频(VCD,SVCD,MP3)所用采样率

  • 47,250 Hz:商用 PCM 录音机所用采样率

  • 48,000 Hz:miniDV、数字电视、DVD、DAT、电影和专业音频所用的数字声音所用采样率


CD 音乐的标准采样频率为 44.1KHz,这也是目前声卡与计算机作业间最常用的采样频率,目前比较盛行的蓝光的采样率就相当的高,达到了 192kHz。而目前的声卡,绝大多数都可以支持 44.1kHz、48kHz、96kHz,高端产品可支持 192kHz 甚至更高,总之,采样率越高,获得的声音文件质量越好,占用存储空间也就越大。


量化精度


声波在转换为数字信号的过程中不只有采样率影响原始声音的完整性,还有一个重要影响因素是量化精度,采样频率针对的是每秒钟所采样的数量,而量化精度则是对于声波的振幅进行切割,切割的数量是以最大振幅切成 2 的 n 次方计算,n 就是 bit 数,而 bit 数就是音频分辨率。


另外,bit 的数目还决定了声波振幅的范围(即动态范围,最大音量与最小音量的差距),如果这个位数越大,则能够表示的数值越大,描述波形更精确,每一个 Bit 的数据可以记录约等于 6dB 动态的信号,一般来说,16Bit 可以提供最大 96dB 的动态范围(加高频颤动后 只有 92dB),据此可以推断出 20Bit 可以达到 120dB 的动态范围,动态范围大了,会有什么好处呢?动态范围是指系统的输出噪音功率和最大不失真音量功率的比值,这个值越大,则系统可以承受很高的动态。


声道


声道指声音在录制或播放时在不同空间位置采集或回放的相互独立的音频信号,所以声道数也就是声音录制时的音源数量或回放时相应的扬声器数量,常见声道有单声道、立体声道、4 声道、5.1 声道、7.1 声道等 ,具体如下:



  • 单声道:设置一个扬声器。

  • 立体声道:把单声道一个扬声器扩展为左右对称的两个扬声器,声音在录制过程中被分配到两个独立的声道,从而达到了很好的声音定位效果,这种技术在音乐欣赏中显得尤为有用,昕众可以清晰地分辨出各种乐器来自何方,从而使音乐更富想象力,更加接近临场感受。立体声技术广泛应用于自 Sound Blaster Pro 以后的大量声卡,成为了 影响深远的音频标准。

  • 4 声道:4 声道环绕规定了 4 个发音点,分别是前左、前右、后左、后右,昕众则被包围在中间,同时还建议增加一个低音音箱,以加强对低频信号的回放处理,这也就是如今 4.1 声道音箱系统广泛流行的原因,就整体效果而言,4 声道系统可以为听众带来来自多个不 同方向的声音环绕,可以获得身 临各种不同环境的昕觉感受,给用户以全新的体验。

  • 5.1 声道:其实 5.1 声道系统来源于 4.1 声道系统,将环绕声道一分为二,分为左环绕和右环绕,中央位置增加重低音效果。

  • 7.1 声道:7.1 声道系统在 5.1 声道系统的基础上又增加了中左和中右两个发音点,简单来说就是在听者的周围建立起一套前后相对平衡的声场,增加了 后中声场声道。


最后



大家如果还想了解更多Android 开发、音视频开发相关的更多知识点,可以点进我的GitHub项目中:https://github.com/733gh/Android-T3自行查看,里面记录了许多的Android 知识点。最后还请大家点点赞支持下!!!




收起阅读 »

【JavaWeb】关于WebSocket的IM在线聊天技术

IM
最近在弄IM的在线聊天,发现layim又停摆了,所以下决心看看以前学的socket技术,这次的想法是不用swing,使用javaweb的jsp实现在线聊天。 我计划的大致实现步骤分这样几大步: 1、首先实现简单的demo。 2、然后结合线程,实现多客户端连接...
继续阅读 »

最近在弄IM的在线聊天,发现layim又停摆了,所以下决心看看以前学的socket技术,这次的想法是不用swing,使用javaweb的jsp实现在线聊天。


我计划的大致实现步骤分这样几大步:
1、首先实现简单的demo。
2、然后结合线程,实现多客户端连接服务端发送消息;
3、实现后台服务端转发客户端消息至所有客户端,同时在客户端显示;
4、使用前端jsp或者html展示的界面使用js技术发送信息并接收处理。


5、这是后话这期不弄,优化界面,使用html5。






也伴随着HTML5推出的WebSocket,真正实现了Web的实时通信,使B/S模式具备了C/S模式的实时通信能力。WebSocket的工作流程是这样的:浏览器通过JavaScript向服务端发出建立WebSocket连接的请求,在WebSocket连接建立成功后,客户端和服务端就可以通过TCP连接传输数据。因为WebSocket连接本质上是TCP连接,不需要每次传输都带上重复的头部数据,所以它的数据传输量比轮询和Comet技术小了很多。这里就不详细地介绍WebSocket规范,主要介绍下WebSocket在Java Web中的实现。


也就是我demo




新建一个dynamic web项目:


客户端(Web主页)代码:



<%@ page language="java" import="java.util.*" pageEncoding="UTF-8"%>
<%
String path = request.getContextPath();
String basePath = request.getScheme()+"://"+request.getServerName()+":"+request.getServerPort()+path+"/";
%>

<!DOCTYPE HTML>
<html>
<head>
<base href="<%=basePath%>">
<title>My WebSocket</title>
</head>

<body>
Welcome<br/>
<input id="text" type="text" /><button οnclick="send()">Send</button> <button οnclick="closeWebSocket()">Close</button>
<div id="message">
</div>
</body>

<script type="text/javascript">
var websocket = null;

//判断当前浏览器是否支持WebSocket
if('WebSocket' in window){
websocket = new WebSocket("ws://localhost:8080/MyWebSocket/websocket");
}
else{
alert('Not support websocket')
}

//连接发生错误的回调方法
websocket.onerror = function(){
setMessageInnerHTML("error");
};

//连接成功建立的回调方法
websocket.onopen = function(event){
setMessageInnerHTML("open");
}

//接收到消息的回调方法
websocket.onmessage = function(){
setMessageInnerHTML(event.data);
}

//连接关闭的回调方法
websocket.onclose = function(){
setMessageInnerHTML("close");
}

//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function(){
websocket.close();
}

//将消息显示在网页上
function setMessageInnerHTML(innerHTML){
document.getElementById('message').innerHTML += innerHTML + '<br/>';
}

//关闭连接
function closeWebSocket(){
websocket.close();
}

//发送消息
function send(){
var message = document.getElementById('text').value;
websocket.send(message);
}
</script>
</html>



Java Web后台代码


package com.jy.im.websocket;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArraySet;

import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

//该注解用来指定一个URI,客户端可以通过这个URI来连接到WebSocket。类似Servlet的注解mapping。无需在web.xml中配置。
@ServerEndpoint("/websocket")
public class MyWebSocket {
//静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
private static int onlineCount = 0;

//concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。若要实现服务端与单一客户端通信的话,可以使用Map来存放,其中Key可以为用户标识
private static CopyOnWriteArraySet<MyWebSocket> webSocketSet = new CopyOnWriteArraySet<MyWebSocket>();

//与某个客户端的连接会话,需要通过它来给客户端发送数据
private Session session;

/**
* 连接建立成功调用的方法
* @param session 可选的参数。session为与某个客户端的连接会话,需要通过它来给客户端发送数据
*/
@OnOpen
public void onOpen(Session session){
this.session = session;
webSocketSet.add(this); //加入set中
addOnlineCount(); //在线数加1
System.out.println("有新连接加入!当前在线人数为" + getOnlineCount());
}

/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose(){
webSocketSet.remove(this); //从set中删除
subOnlineCount(); //在线数减1
System.out.println("有一连接关闭!当前在线人数为" + getOnlineCount());
}

/**
* 收到客户端消息后调用的方法
* @param message 客户端发送过来的消息
* @param session 可选的参数
*/
@OnMessage
public void onMessage(String message, Session session) {
System.out.println("来自客户端的消息:" + message);

//群发消息
for(MyWebSocket item: webSocketSet){
try {
item.sendMessage(message);
} catch (IOException e) {
e.printStackTrace();
continue;
}
}
}

/**
* 发生错误时调用
* @param session
* @param error
*/
@OnError
public void onError(Session session, Throwable error){
System.out.println("发生错误");
error.printStackTrace();
}

/**
* 这个方法与上面几个方法不一样。没有用注解,是根据自己需要添加的方法。
* @param message
* @throws IOException
*/
public void sendMessage(String message) throws IOException{
this.session.getBasicRemote().sendText(message);
//this.session.getAsyncRemote().sendText(message);
}

public static synchronized int getOnlineCount() {
return onlineCount;
}

public static synchronized void addOnlineCount() {
MyWebSocket.onlineCount++;
}

public static synchronized void subOnlineCount() {
MyWebSocket.onlineCount--;
}
}







收起阅读 »

Android移动端IM开发之应用层实现TCP长连接多路复用

IM
这里只是提供一个长连接多路复用的实现思路 什么是长连接多路复用 从字面意思看就是一台设备只有一条长连接连向服务器,其他集成这个IM SDK的app都会共享这条长连接,TCP长连接的维护是比较耗资源的,而且也会增加耗电,所以实现长连接共享就表示在一定程度...
继续阅读 »


这里只是提供一个长连接多路复用的实现思路


什么是长连接多路复用



  1. 从字面意思看就是一台设备只有一条长连接连向服务器,其他集成这个IM SDK的app都会共享这条长连接,TCP长连接的维护是比较耗资源的,而且也会增加耗电,所以实现长连接共享就表示在一定程度上降低了耗电也节省了内存

  2. java里面还有一个IO多路复用,这个用在服务端的比较多,用来解决并发问题,服务器的IM框架netty也用到了IO多路复用,原先是一个客户端对应一个IO,服务器要为每个客户端单独开一个线程来处理客户端的消息请求,但是使用了IO多路复用后,可能多个客户端共享一条IO链路,节省了资源,不必为每个客户端来单独开一个线程处理,具体服务端怎么实现我也没去深究,想多了解就上网找找资料或者看下netty的源码实现


IM的长连接多路复用的实现


IM的长连接多路复用的实现我觉得分为两部分,一部分是服务端的实现,一部分是客户端的实现
1. 一般一台手机设备会对应一个唯一的设备id,这个设备id对于服务器来说是绝对唯一的,是标识设备唯一性的一个标志,所以IM的接入服务器可以根据设备id来检测并控制每台设备连到服务器的长连接数量,接入服务器可以让同一台设备如果有两条不同的tcp连接连进来,那么可以断开其中一条tcp而只保持唯一一条tcp连接,这是从服务端保证单台设备TCP连接的唯一性
2. android客户端会集成响应的IM SDK,一般tcp长连接由service来维护,至于进程保活其实也就是service保活,因为push进程一般是和app的进程分开的;那么要实现在android手机设备上tcp连接的唯一性其实也就是保证维护tcp连接的service的唯一性,在android sdk中是可以有办法来判断service是否在运行的:


public static boolean isServiceRunning(Context mContext, String clsName) {
boolean isRunning = false;
ActivityManager myAM = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
List<ActivityManager.RunningServiceInfo> myList = myAM.getRunningServices(Integer.MAX_VALUE);
if (myList == null || myList.size() <= 0) {
return false;
}
for (int i = 0; i < myList.size(); i++) {
String mName = myList.get(i).service.getClassName().toString();
if (mName.equals(clsName)) {
isRunning = true;
break;
}
}
return isRunning;
}

其实思路很简单,检测到这个service已经运行了,那么就不start service了,而只要bind service来获取binder对象就足够了,然后可以通过aidl来实现跨进程通信了,但是这里有两个概念,就是必须指定宿主app和寄生app,宿主app就是负责维护唯一的tcp长连接,寄生app的收发消息就通过宿主app来路由,这样就做到了tcp长连接共享,所以在检测到宿主app维护长连接的service没有启动的时候寄生app就可以start service并且bind service,但是注意了,这里不能通过简单的action来启动service,要指定正确的ComponentName,或者把宿主app的context传进来:


Intent serviceIntent = new Intent();
serviceIntent.setComponent(new ComponentName(componentPckName, ConnectionService.class.getName()));
serviceIntent.setPackage(context.getPackageName());
serviceIntent.setAction(ConnectionService.ACTION);

如上componentPckName必须是宿主app的package name或者宿主app的context,那么在寄生app里面启动service才能正确启动宿主app的service,这就相当于唤醒了宿主app了,如果寄生app只是简单的通过action来start service那么它将会为自己启动一个service,而不会去启动宿主app的service,到这里寄生app其实已经开始公用宿主app的service,我已经实现了,aidl调用正常,这样也省却了im连接成功后的初始化操作,只需要在长连接第一次连接成功的时候初始化以下,后面其他的app直接bind service获取binder来直接调用aidl接口就行了。这里有一个可以优化的地方,多进程之间的通信我采用的是aidl来实现,aidl代码冗余,而且传输数据比较慢,比什么慢?我觉得比tcp长连接慢,也就是说可以用本地的tcp长连接来实现进程间的通信,把宿主app当作一个服务器,寄生app相当于客户端,然后每个寄生app都建立一条和宿主app的长连接来实现数据传输,但是要使用长连接来传输数据就必须要制定数据编码格式,因为长连接的数据传输的流传输,有粘包问题,所以要对数据进行编码校验然后解码,而且要维护这条长连接也要有一些工作量,例如长连接断线重连等,所以有利有弊,目前我暂时选择的是aidl来实现进程之间的通信,有可能以后会改成tcp长连接的通信,tcp长连接可以实现宿主app和寄生app互相监听,让service的存活率更高,我同时也不得不承认,这很流氓。。。

收起阅读 »

Android中开发IM即时通讯功能的方案

IM
最近准备做IM功能,在技术预研当中,其实可行方案还是比较多的,就看怎么选了这里有必要普及一下xmpp是个什么东西?引用某娘的定义:      XMPP是一种基于标准通用标记语言的子集XML的协议,它继承了在XML环境中灵活的发展性...
继续阅读 »

最近准备做IM功能,在技术预研当中,其实可行方案还是比较多的,就看怎么选了


这里有必要普及一下xmpp是个什么东西?

引用某娘的定义:

      XMPP是一种基于标准通用标记语言的子集XML的协议,它继承了在XML环境中灵活的发展性。因此,基于XMPP的应用具有超强的可扩展性。经过扩展以后的XMPP可以通过发送扩展的信息来处理用户的需求,以及在XMPP的顶端建立如内容发布系统和基于地址的服务等应用程序。而且,XMPP包含了针对服务器端的软件协议,使之能与另一个进行通话,这使得开发者更容易建立客户应用程序或给一个配好系统添加功能。

网络结构:

      XMPP中定义了三个角色,客户端,服务器,网关。通信能够在这三者的任意两个之间双向发生。服务器同时承担了客户端信息记录,连接管理和信息的路由功能。网关承担着与异构即时通信系统的互联互通,异构系统可以包括SMS(短信),MSNICQ等。基本的网络形式是单客户端通过TCP/IP连接到单服务器,然后在之上传输XML。

功能:

      传输的是与即时通讯相关的指令。在以前这些命令要么用2进制的形式发送(比如QQ),要么用纯文本指令加空格加参数加换行符的方式发送(比如MSN)。而XMPP传输的即时通讯指令的逻辑与以往相仿,只是协议的形式变成了XML格式的纯文本。


一句话总结就是一个可以用于IM功能的协议,传输的是xml数据


移动端IM大概有以下这些方案,但不仅于这些

1.第三方平台

比如环信,融云,leancloud,容联云等等。直接使用sdk就可以实现了,最简单最直接,而且稳定性已经不错了,连UI界面都带有了,可以自行修改,缺点是要收费,比如环信日活跃用户30万以上开始收费,融云基础功能不收费,VIP级别的功能要收费。如果让我选应该会在环信和融云之间选,环信的话一个是市场占有率比较大,二个是技术客服基本都能找到在线的,融云客服的话需要像写邮件一样找技术解决问题,不是在线的交流,我觉得挺不好的,融云的demo倒是做得比环信好看,环信的看上去很粗糙的感觉。据使用过的朋友反馈融云sdk比较好接入,环信稍微麻烦点,使用的时候都会有一些坑的地方要处理。


2.spark+smack+openfire套餐

安卓使用asmack,测试使用spark,服务器使用openfire。asmack可以说是smack的Android平台的支持版提供xmpp协议的实现,就是一些个api,spark就是一个可以用来在pc相互同信的客户端,openfire部署也比较简单,next,next就差不多了。这套方案还算比较成熟的了,当然没有第三方的方便,工作量也增多了,这套也是基于xmpp协议同时也就有xmpp协议的一些缺点。


3.使用第三方推送的sdk

利用推送的及时性来做im也是可以的,推送也不收费吧。


4.Socket长链接

socket大家都懂了的,感觉做个小东西还可以,但是正真商用要做的处理还是挺多的,网络优化,稳定性等等。


5.基于xmpp自己做

xmpp协议本来就是可以坐im功能的,据了解环信就是自己基于xmpp来做的,50人上下的团队14年开始做的,现在两年左右已经融了两三轮资了。不过xmpp基于xml,冗长直接导致费流量费电,特别是移动端,可以考虑在传输的时候自己加一层二进制协议,如果环信真是xmpp来做应该也会在这方面做优化的。还有mqtt,sip等协议也是可以自己做。如果im不是核心功能不推荐自己做,费时费力。



收起阅读 »

IM开发干货分享:我是如何解决大量离线消息导致客户端卡顿的

IM
1、引言 好久没写技术文章了,今天这篇不是原理性文章,而是为大家分享一下由笔者主导开发实施的IM即时通讯聊天系统,针对大量离线消息(包括消息漫游)导致的用户体验问题的升级改造全过程。 文章中,我将从如下几个方面进行介绍: 1)这款IM产品的主要业务及...
继续阅读 »

1、引言


好久没写技术文章了,今天这篇不是原理性文章,而是为大家分享一下由笔者主导开发实施的IM即时通讯聊天系统,针对大量离线消息(包括消息漫游)导致的用户体验问题的升级改造全过程。


文章中,我将从如下几个方面进行介绍:



  • 1)这款IM产品的主要业务及特点;

  • 2)IM系统业务现状和痛点;

  • 3)升级改造之路;

  • 4)消息ACK逻辑的优化。


下述内容都是根据笔者开发IM的亲身经历总结下来的宝贵经验,干货满满,期待你的点赞。


学习交流:



- 即时通讯/推送技术开发交流5群:215477170 [推荐]


- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM





2、IM开发干货系列文章


本文是系列文章中的第25篇,总目录如下:



IM消息送达保证机制实现(一):保证在线实时消息的可靠投递


IM消息送达保证机制实现(二):保证离线消息的可靠投递


如何保证IM实时消息的“时序性”与“一致性”?


IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?


IM群聊消息如此复杂,如何保证不丢不重?


一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)


移动端IM登录时拉取数据如何作到省流量?


通俗易懂:基于集群的移动端IM接入层负载均衡方案分享


浅谈移动端IM的多点登陆和消息漫游原理


IM开发基础知识补课(一):正确理解前置HTTP SSO单点登陆接口的原理


IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?


IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议


IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token


IM群聊消息的已读回执功能该怎么实现?


IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)?


IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列


一个低成本确保IM消息时序的方法探讨


IM开发基础知识补课(六):数据库用NoSQL还是SQL?读这篇就够了!


IM里“附近的人”功能实现原理是什么?如何高效率地实现它?


IM开发基础知识补课(七):主流移动端账号登录方式的原理及设计思路


IM开发基础知识补课(八):史上最通俗,彻底搞懂字符乱码问题的本质


IM的扫码登功能如何实现?一文搞懂主流应用的扫码登陆技术原理


IM要做手机扫码登陆?先看看微信的扫码登录功能技术原理


IM开发基础知识补课(九):想开发IM集群?先搞懂什么是RPC!


IM开发实战干货:我是如何解决大量离线聊天消息导致客户端卡顿的》(本文)



另外,如果您是IM开发初学者,强烈建议首先阅读《新手入门一篇就够:从零开发移动端IM》。


3、此IM产品的主要业务及特点


和传统互联网行业有所不同,笔者所在的公司(名字就不透露了)是一家做娱乐社交app的公司,包括小游戏、聊天、朋友圈feed等。


大家应该都有体会:游戏业务在技术上和产品形态上与电商、旅游等行业有着本质上的区别。


大部分做后端开发的朋友,都在开发接口。客户端或浏览器h5通过HTTP请求到我们后端的Controller接口,后端查数据库等返回JSON给客户端。大家都知道,HTTP协议有短连接、无状态、三次握手四次挥手等特点。而像游戏、实时通信等业务反而很不适合用HTTP协议。


原因如下:



  • 1)HTTP达不到实时通信的效果,可以用客户端轮询但是太浪费资源;

  • 2)三次握手四次挥手有严重的性能问题;

  • 3)无状态。


比如说,两个用户通过App聊天,一方发出去的消息,对方要实时感知到消息的到来。两个人或多个人玩游戏,玩家要实时看到对方的状态,这些场景用HTTP根本不可能实现!因为HTTP只能pull(即“拉”),而聊天、游戏业务需要push(即“推”)。


4、IM系统业务现状和痛点


4.1 业务现状


笔者负责整个公司的实时聊天系统,类似与微信、QQ那样,有私聊、群聊、发消息、语音图片、红包等功能。


下面我详细介绍一下,整个聊天系统是如何运转的。


首先:为了达到实时通信的效果,我们基于Netty开发了一套长链接网关gateway(扩展阅读:《Netty干货分享:京东京麦的生产级TCP网关技术实践总结》),采用的协议是MQTT协议,客户端登录时App通过MQTT协议连接到gateway(NettyServer),然后通过MQTT协议把聊天消息push给NettyServer,NettyServer与NettyClient保持长链接,NettyClient用于处理业务逻辑(如敏感词拦截、数据校验等)处理,最后将消息push给NettyServer,再由NettyServer通过MQTT push给客户端。


其次:客户端与服务端想要正常通信,我们需要制定一套统一的协议。拿聊天举例,我们要和对方聊天,需要通过uid等信息定位到对方的Channel(Netty中的通道,相当于一条socket连接),才能将消息发送给正确的客户端,同时客户端必须通过协议中的数据(uid、groupId等),将消息显示在私聊或者群聊的会话中。


协议中主要字段如下(我们将数据编码成protobuf格式进行传输):



{


    "cmd":"chat",


    "time":1554964794220,


    "uid":"69212694",


    "clientInfo":{


        "deviceId":"b3b1519c-89ec",


        "deviceInfo":"MI 6X"


    },


    "body":{


        "v":1,


        "msgId":"5ab2fe83-59ec-44f0-8adc-abf26c1e1029",


        "chatType":1,


        "ackFlg":1,


        "from":"69212694",


        "to":"872472068",


        "time":1554964793813,


        "msg":{


            "message":"聊天消息"


        }


    }


}



补充说明:如果你不了Protobuf格式是什么,请详读《Protobuf通信协议详解:代码演示、详细原理介绍等》。


如上json,协议主要字段包括: 



如果客户端不在线,我们服务端需要把发送的消息存储在离线消息表中,等下次对方客户端上线,服务端NettyServer通过长链接把离线消息push给客户端。


4.2 业务痛点


随着业务蓬勃发展,用户的不断增多,用户创建的群、加入的群和好友不断增多和聊天活跃度的上升,某些用户不在线期间,产生大量的离线消息(尤其是针对群聊,离线消息特别多)。


等下次客户端上线时,服务端会给客户端强推全部的离线消息,导致客户端卡死在登录后的首页。并且产品提出的需求,要扩大群成员的人数(由之前的百人群扩展到千人群、万人群等)。


这样一来,某些客户端登录后必定会因为大量离线消息而卡死,用户体验极为不好。


和客户端的同事一起分析了一下原因:



  • 1)用户登录,服务端通过循环分批下发所有离线消息,数据量较大;

  • 2)客户端登录后进入首页,需要加载的数据不光有离线消息,还有其他初始化数据;

  • 3)不同价位的客户端处理数据能力有限,处理聊天消息时,需要把消息存储到本地数据库,并且刷新UI界面,回复给服务端ack消息,整个过程很耗性能。


(庆幸的是,在线消息目前没有性能问题)。


所以针对上述问题,结合产品对IM系统的远大规划,我们服务端决定优化离线消息(稍微吐槽一下,客户端处理能力不够,为什么要服务端做优化?服务端的性能远没达到瓶颈。。。)。


5、升级改造之路


值得庆幸的是,笔者100%参与这次系统优化的全部过程,包括技术选型、方案制定和最后的代码编写。在此期间,笔者思考出多种方案,然后和服务端、客户端同事一起讨论,最后定下来一套稳定的方案。


5.1 方案一(被pass掉的一个方案)


? 【问题症状】:


客户端登录卡顿的主要原因是,服务端会强推大量离线消息给客户端,客户端收到离线消息后会回复服务端ack,然后将消息存储到本地数据库、刷新UI等。客户端反馈,即使客户端采用异步方式也会有比较严重的性能问题。


? 【于是我想】:


为什么客户端收到消息后还没有将数据存储到数据库就回复给服务端ack?很有可能存储失败,这本身不合理,这是其一。其二,服务端强推导致客户端卡死,不关心客户端的处理能力,不合理。


? 【伪代码如下】:



int max = 100;


//从新库读


while(max > 0) {


    List<OfflineMsgInfo> offlineMsgListNew = shardChatOfflineMsgDao.getByToUid(uid, 20);


    if(CollectionUtils.isEmpty(offlineMsgListNew)) {


        break;


    }


    handleOfflineMsg(uid, offlineMsgListNew, checkOnlineWhenSendingOfflineMsg);


    max--;


}



? 【初步方案】:


既然强推不合理,我们可以换一种方式,根据客户端不同机型的处理能力的不同,服务端采用不同的速度下发。


我们可以把整个过程当成一种生产者消费者模型,服务端是消息生产者,客户端是消息消费者。客户端收到消息,将消息存储在本地数据库,刷新UI界面后,再向服务端发送ack消息,服务端收到客户端的ack消息后,再推送下一批消息。


这么一来,消息下发速度完全根据客户端的处理能力,分批下发。但这种方式仍然属于推方式。


? 【悲剧结果】:


然而,理想很丰满,现实却很骨感。


针对这个方案,客户端提出一些问题:



  • 1)虽然这种方案,客户端不会卡死,但是如果当前用户的离线消息特别多,那么收到所有离线消息的时间会非常长;

  • 2)客户端每次收到消息后会刷新界面,很有可能客户端会发生,界面上下乱跳的画面。


so,这个方案被否定了。。。


5.2 方案二


? 【我的思考】:


既然强推的数据量过大,我们是否可以做到,按需加载?客户端需要读取离线消息的时候服务端给客户端下发,不需要的时候,服务端就不下发。


? 【技术方案】:针对离线消息,我们做了如下方案的优化


1)我们增加了离线消息计数器的概念:保存了每个用户的每个会话,未读的消息的元数据(包括未读消息数,最近的一条未读消息、时间戳等数据),这个计数器用于客户端显示未读消息的的红色气泡。这个数据属于增量数据,只保留离线期间收到的消息元数据。


消息格式如下:



{


    "sessionId1":{


        "count":20,


        "lastMsg":[


            "最后N条消息"


        ],


        "timestamp":1234567890


    },


    "sessionId2":{


    }


}



 


 


2)客户端每次登录时,服务端不推送全量离线消息,只推送离线消息计数器(这部分数据存储在redis里,并且数据量很小),这个数量用户显示在客户端消息列表的未读消息小红点上。


3)客户端拿到这些离线消息计数器数据,遍历会话列表,依次将未读消息数量累加(注意:不是覆盖,服务端保存客户端离线后的增量数据),然后通知服务端清空离线消息计数器的增量数据。


4)当客户端进入某会话后,上拉加载时,通过消息的msgId等信息发送HTTP请求给服务端,服务端再去分页查询离线消息返回给客户端。


5)客户端收到消息并保存在本地数据库后,向服务端发送ack,然后服务端删除离线消息表的离线消息。


? 【预期结果】:


客户端、服务端的技术人员认可这个方案。我们通过推拉结合的方式,解决了客户端加载离线消息卡顿的问题。(改造前是强推,改造后采用推拉结合的方式)


流程图如下:



? 【新的问题】:


方案虽然通过了,但是引发了一个新问题:即客户端消息衔接问题。


问题描述如下:客户端登录后进入会话页面,因为客户端本身就保存着历史消息,那么客户端下拉加载新消息时,到底怎么判断要加载本地历史消息?还是要请求服务端加载离线消息呢?


经过一番思考,服务端和客户端最终达成了一致的方案:



  • 1)在未读消息计数器的小红点逻辑中,服务端会把每个会话的最近N条消息一起下发给客户端;

  • 2)客户端进入会话时,会根据未读消息计数器的最近N条消息展示首页数据;

  • 3)客户端每次下拉加载时,请求服务端,服务端按时间倒排离线消息返回当前会话最近一页离线消息,直到离线消息库中的数据全部返回给客户端;

  • 4)当离线消息库中没有离线消息后,返回给客户端一个标识,客户端根据这个标识,在会话页面下一次下拉加载时不请求服务端的离线消息,直接请求本地数据库。


6、消息ACK逻辑的优化


最后,我们也对消息ack的逻辑进行了优化。


优化前:服务端采用push模型给客户端推消息,不论是在线消息还是离线消息,ack的逻辑都一样,其中还用到了kafka、redis等中间件,流程很复杂(我在这里就不详细展开介绍ack的具体流程了,反正不合理)。


离线消息和在线消息不同的是,我们不存储在线消息,而离线消息会有一个单独的库存储。完全没必要用在线消息的ack逻辑去处理离线消息,反而很不合理,不仅流程上有问题,也浪费kafka、redis等中间件性能。


优化后:我们和客户端决定在每次下拉加载离线消息时,将收到的上一批离线消息的msgId或消息偏移量等信息发送给服务端,服务端直接根据msgId删除离线库中已经发送给客户端的离线消息,再返回给客户端下一批离线消息。


另外:我们还增加了消息漫游功能,用户切换手机登录后仍然可以查到历史消息,这部分内容我就不展开详细介绍给大家了。


7、设计优化方案时的文档截图(仅供参考)


下面是优化的方案文档截图,请大家参考。


 



 



 


 



 


 



 


附录:更多IM开发相关文章汇总



[1] IM开发热门文章:


新手入门一篇就够:从零开发移动端IM


移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”


移动端IM开发者必读(二):史上最全移动弱网络优化方法总结


从客户端的角度来谈谈移动端IM的消息可靠性和送达机制


现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障


腾讯技术分享:社交网络图片的带宽压缩技术演进之路


小白必读:闲话HTTP短连接中的Session和Token


IM开发基础知识补课:正确理解前置HTTP SSO单点登录接口的原理


移动端IM中大规模群消息的推送如何保证效率、实时性?


移动端IM开发需要面对的技术问题


开发IM是自己设计协议用字节流好还是字符流好?


请问有人知道语音留言聊天的主流实现方式吗?


IM消息送达保证机制实现(一):保证在线实时消息的可靠投递


IM消息送达保证机制实现(二):保证离线消息的可靠投递


如何保证IM实时消息的“时序性”与“一致性”?


一个低成本确保IM消息时序的方法探讨


IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?


IM群聊消息如此复杂,如何保证不丢不重?


谈谈移动端 IM 开发中登录请求的优化


移动端IM登录时拉取数据如何作到省流量?


浅谈移动端IM的多点登录和消息漫游原理


完全自已开发的IM该如何设计“失败重试”机制?


通俗易懂:基于集群的移动端IM接入层负载均衡方案分享


微信对网络影响的技术试验及分析(论文全文)


即时通讯系统的原理、技术和应用(技术论文)


开源IM工程“蘑菇街TeamTalk”的现状:一场有始无终的开源秀


QQ音乐团队分享:Android中的图片压缩技术详解(上篇)


QQ音乐团队分享:Android中的图片压缩技术详解(下篇)


腾讯原创分享(一):如何大幅提升移动网络下手机QQ的图片传输速度和成功率


腾讯原创分享(二):如何大幅压缩移动网络下APP的流量消耗(上篇)


腾讯原创分享(三):如何大幅压缩移动网络下APP的流量消耗(下篇)


如约而至:微信自用的移动端IM网络层跨平台组件库Mars已正式开源


基于社交网络的Yelp是如何实现海量用户图片的无损压缩的?


腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(图片压缩篇)


腾讯技术分享:腾讯是如何大幅降低带宽和网络流量的(音视频技术篇)


字符编码那点事:快速理解ASCII、Unicode、GBK和UTF-8


全面掌握移动端主流图片格式的特点、性能、调优等


子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践


IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列


微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)


自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)


融云技术分享:解密融云IM产品的聊天消息ID生成策略


IM开发基础知识补课(六):数据库用NoSQL还是SQL?读这篇就够了!


适合新手:从零开发一个IM服务端(基于Netty,有完整源码)


拿起键盘就是干:跟我一起徒手开发一套分布式IM系统


适合新手:手把手教你用Go快速搭建高性能、可扩展的IM系统(有源码)


IM里“附近的人”功能实现原理是什么?如何高效率地实现它?


IM开发基础知识补课(七):主流移动端账号登录方式的原理及设计思路


IM开发基础知识补课(八):史上最通俗,彻底搞懂字符乱码问题的本质


IM“扫一扫”功能很好做?看看微信“扫一扫识物”的完整技术实现


IM的扫码登录功能如何实现?一文搞懂主流应用的扫码登录技术原理


IM要做手机扫码登录?先看看微信的扫码登录功能技术原理


IM消息ID技术专题(一):微信的海量IM聊天消息序列号生成实践(算法原理篇)


IM消息ID技术专题(二):微信的海量IM聊天消息序列号生成实践(容灾方案篇)


IM消息ID技术专题(三):解密融云IM产品的聊天消息ID生成策略


IM消息ID技术专题(四):深度解密美团的分布式ID生成算法


IM消息ID技术专题(五):开源分布式ID生成器UidGenerator的技术实现


IM开发宝典:史上最全,微信各种功能参数和逻辑规则资料汇总


IM开发干货分享:我是如何解决大量离线聊天消息导致客户端卡顿的


>> 更多同类文章 …… 


[2] IM群聊相关的技术文章:


快速裂变:见证微信强大后台架构从0到1的演进历程(一)


如何保证IM实时消息的“时序性”与“一致性”?


IM单聊和群聊中的在线状态同步应该用“推”还是“拉”?


IM群聊消息如此复杂,如何保证不丢不重?


微信后台团队:微信后台异步消息队列的优化升级实践分享


移动端IM中大规模群消息的推送如何保证效率、实时性?


现代IM系统中聊天消息的同步和存储方案探讨


关于IM即时通讯群聊消息的乱序问题讨论


IM群聊消息的已读回执功能该怎么实现?


IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)?


一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践


[技术脑洞] 如果把14亿中国人拉到一个微信群里技术上能实现吗?


IM群聊机制,除了循环去发消息还有什么方式?如何优化?


网易云信技术分享:IM中的万人群聊技术方案实践总结


阿里钉钉技术分享:企业级IM王者——钉钉在后端架构上的过人之处


>> 更多同类文章 ……


[3] 有关IM架构设计的文章:


浅谈IM系统的架构设计


简述移动端IM开发的那些坑:架构设计、通信协议和客户端


一套海量在线用户的移动端IM架构设计实践分享(含详细图文)


一套原创分布式即时通讯(IM)系统理论架构方案


从零到卓越:京东客服即时通讯系统的技术架构演进历程


蘑菇街即时通讯/IM服务器开发之架构选择


腾讯QQ1.4亿在线用户的技术挑战和架构演进之路PPT


微信后台基于时间序的海量数据冷热分级架构设计实践


微信技术总监谈架构:微信之道——大道至简(演讲全文)


如何解读《微信技术总监谈架构:微信之道——大道至简》


快速裂变:见证微信强大后台架构从0到1的演进历程(一)


17年的实践:腾讯海量产品的技术方法论


移动端IM中大规模群消息的推送如何保证效率、实时性?


现代IM系统中聊天消息的同步和存储方案探讨


IM开发基础知识补课(二):如何设计大量图片文件的服务端存储架构?


IM开发基础知识补课(三):快速理解服务端数据库读写分离原理及实践建议


IM开发基础知识补课(四):正确理解HTTP短连接中的Cookie、Session和Token


WhatsApp技术实践分享:32人工程团队创造的技术神话


微信朋友圈千亿访问量背后的技术挑战和实践总结


王者荣耀2亿用户量的背后:产品定位、技术架构、网络方案等


IM系统的MQ消息中间件选型:Kafka还是RabbitMQ?


腾讯资深架构师干货总结:一文读懂大型分布式系统设计的方方面面


以微博类应用场景为例,总结海量社交系统的架构设计步骤


快速理解高性能HTTP服务端的负载均衡技术原理


子弹短信光鲜的背后:网易云信首席架构师分享亿级IM平台的技术实践


知乎技术分享:从单机到2000万QPS并发的Redis高性能缓存实践之路


IM开发基础知识补课(五):通俗易懂,正确理解并用好MQ消息队列


微信技术分享:微信的海量IM聊天消息序列号生成实践(算法原理篇)


微信技术分享:微信的海量IM聊天消息序列号生成实践(容灾方案篇)


新手入门:零基础理解大型分布式架构的演进历史、技术原理、最佳实践


一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践


阿里技术分享:深度揭秘阿里数据库技术方案的10年变迁史


阿里技术分享:阿里自研金融级数据库OceanBase的艰辛成长之路


社交软件红包技术解密(一):全面解密QQ红包技术方案——架构、技术实现等


社交软件红包技术解密(二):解密微信摇一摇红包从0到1的技术演进


社交软件红包技术解密(三):微信摇一摇红包雨背后的技术细节


社交软件红包技术解密(四):微信红包系统是如何应对高并发的


社交软件红包技术解密(五):微信红包系统是如何实现高可用性的


社交软件红包技术解密(六):微信红包系统的存储层架构演进实践


社交软件红包技术解密(七):支付宝红包的海量高并发技术实践


社交软件红包技术解密(八):全面解密微博红包技术方案


社交软件红包技术解密(九):谈谈手Q红包的功能逻辑、容灾、运维、架构等


社交软件红包技术解密(十):手Q客户端针对2020年春节红包的技术实践


即时通讯新手入门:一文读懂什么是Nginx?它能否实现IM的负载均衡?


即时通讯新手入门:快速理解RPC技术——基本概念、原理和用途


多维度对比5款主流分布式MQ消息队列,妈妈再也不担心我的技术选型了


从游击队到正规军(一):马蜂窝旅游网的IM系统架构演进之路


从游击队到正规军(二):马蜂窝旅游网的IM客户端架构演进和实践总结


IM开发基础知识补课(六):数据库用NoSQL还是SQL?读这篇就够了!


瓜子IM智能客服系统的数据架构设计(整理自现场演讲,有配套PPT)


阿里钉钉技术分享:企业级IM王者——钉钉在后端架构上的过人之处


从游击队到正规军(三):基于Go的马蜂窝旅游网分布式IM系统技术实践


微信后台基于时间序的新一代海量数据存储架构的设计实践


IM开发基础知识补课(九):想开发IM集群?先搞懂什么是RPC!


>> 更多同类文章 ……



(本文同步发布于:http://www.52im.net/thread-3036-1-1.html


收起阅读 »

自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM(有源码)

IM
本文由作者FreddyChen原创分享,为了更好的体现文章价值,引用时有少许改动,感谢原作者。 1、写在前面 一直想写一篇关于im即时通讯分享的文章,无奈工作太忙,很难抽出时间。今天终于从公司离职了,打算好好休息几天再重新找工作,趁时间空闲,决定静下心...
继续阅读 »

本文由作者FreddyChen原创分享,为了更好的体现文章价值,引用时有少许改动,感谢原作者。



1、写在前面


一直想写一篇关于im即时通讯分享的文章,无奈工作太忙,很难抽出时间。今天终于从公司离职了,打算好好休息几天再重新找工作,趁时间空闲,决定静下心来写一篇文章,毕竟从前辈那里学到了很多东西。


工作了五年半,这三四年来一直在做社交相关的项目,有直播、即时通讯、短视频分享、社区论坛等产品,深知即时通讯技术在一个项目中的重要性,本着开源分享的精神,也趁这机会总结一下,所以写下了这篇文章。


* 重要提示:本文不是一篇即时通讯理论文章,文章内容全部由实战代码组织而成,如果你对即时通讯(IM)技术理论了解的太少,建议先详细阅读:《新手入门一篇就够:从零开发移动端IM》。


本文实践内容将涉及以下即时通讯技术内容:



1)Protobuf序列化;


2)TCP拆包与粘包;


3)长连接握手认证;


4)心跳机制;


5)重连机制;


6)消息重发机制;


7)读写超时机制;


8)离线消息;


9)线程池。



不想看文章的同学,可以直接到Github下载本文源码:



1)原始地址:https://github.com/FreddyChen/NettyChat


2)备用地址:https://github.com/52im/NettyChat



接下来,让我们进入正题。


(本文同步发布于:http://www.52im.net/thread-2671-1-1.html


2、本文阅读对象


本文适合没有任何即时通讯(IM)开发经验的小白开发者阅读,文章将教你从零开始,围绕一个典型即时通讯(IM)系统的方方面面,手把手为你展示如何基于Netty+TCP+Protobuf来开发出这样的系统。非常适合从零入门的Android开发者。


本文不适合没有编程的准开发者阅读,因为即时通讯(IM)系统属于特定的业务领域,如果你连一般的逻辑代码都很难编写出来,不建议阅读本文。本文显然不是一个编程语言入门教程。


3、关于作者



本文原文内容由FreddyChen原创分享,作者现从事Android程序开发,他的技术博客地址:https://juejin.im/user/5bd7affbe51d4547f763fe72 


4、为什么使用TCP?


这里需要简单解释一下,TCP/UDP的区别,简单地总结一下。


优点:


1)TCP:优点体现在稳定、可靠上,在传输数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制,在数据传完之后,还会断开连接用来节约系统资源。


2)UDP:优点体现在快,比TCP稍安全,UDP没有TCP拥有的各种机制,是一个无状态的传输协议,所以传递数据非常快,没有TCP的这些机制,被攻击利用的机制就少一些,但是也无法避免被攻击。


缺点:


1)TCP:缺点就是慢,效率低,占用系统资源高,易被攻击,TCP在传递数据之前要先建立连接,这会消耗时间,而且在数据传递时,确认机制、重传机制、拥塞机制等都会消耗大量时间,而且要在每台设备上维护所有的传输连接。


2)UDP:缺点就是不可靠,不稳定,因为没有TCP的那些机制,UDP在传输数据时,如果网络质量不好,就会很容易丢包,造成数据的缺失。


适用场景:


1)TCP:当对网络通讯质量有要求时,比如HTTP、HTTPS、FTP等传输文件的协议, POP、SMTP等邮件传输的协议。


2)UDP:对网络通讯质量要求不高时,要求网络通讯速度要快的场景。


至于WebSocket,后续可能会专门写一篇文章来介绍。综上所述,决定采用TCP协议。


关于TCP和UDP的对比和选型的详细文章,请见:



简述传输层协议TCP和UDP的区别


为什么QQ用的是UDP协议而不是TCP协议?


移动端即时通讯协议选择:UDP还是TCP?


网络编程懒人入门(四):快速理解TCP和UDP的差异


网络编程懒人入门(五):快速理解为什么说UDP有时比TCP更有优势


Android程序员必知必会的网络通信传输层协议——UDP和TCP



或者,如果你对TCP、UDP协议了解的太少,可以阅读一下文章:



TCP/IP详解 - 第11章·UDP:用户数据报协议


TCP/IP详解 - 第17章·TCP:传输控制协议


TCP/IP详解 - 第18章·TCP连接的建立与终止


TCP/IP详解 - 第21章·TCP的超时与重传


脑残式网络编程入门(一):跟着动画来学TCP三次握手和四次挥手


技术往事:改变世界的TCP/IP协议(珍贵多图、手机慎点)


通俗易懂-深入理解TCP协议(上):理论基础


网络编程懒人入门(三):快速理解TCP协议一篇就够


迈向高阶:优秀Android程序员必知必会的网络基础



5、为什么使用Protobuf?


对于App网络传输协议,我们比较常见的、可选的,有三种,分别是json/xml/protobuf,老规矩,我们先分别来看看这三种格式的优缺点。


PS:如果你不了解protobuf是什么,建议详细阅读:《Protobuf通信协议详解:代码演示、详细原理介绍等》。


优点:


1)json:优点就是较XML格式更加小巧,传输效率较xml提高了很多,可读性还不错。


2)xml:优点就是可读性强,解析方便。


3)protobuf:优点就是传输效率快(据说在数据量大的时候,传输效率比xml和json快10-20倍),序列化后体积相比Json和XML很小,支持跨平台多语言,消息格式升级和兼容性还不错,序列化反序列化速度很快。


缺点:


1)json:缺点就是传输效率也不是特别高(比xml快,但比protobuf要慢很多)。


2)xml:缺点就是效率不高,资源消耗过大。


3)protobuf:缺点就是使用不太方便。


在一个需要大量的数据传输的场景中,如果数据量很大,那么选择protobuf可以明显的减少数据量,减少网络IO,从而减少网络传输所消耗的时间。考虑到作为一个主打社交的产品,消息数据量会非常大,同时为了节约流量,所以采用protobuf是一个不错的选择。


更多有关IM相关的协议格式选型方面的文章,可进一步阅读:



如何选择即时通讯应用的数据传输格式


强列建议将Protobuf作为你的即时通讯应用数据传输格式


全方位评测:Protobuf性能到底有没有比JSON快5倍?


移动端IM开发需要面对的技术问题(含通信协议选择)


简述移动端IM开发的那些坑:架构设计、通信协议和客户端


理论联系实际:一套典型的IM通信协议设计详解


58到家实时消息系统的协议设计等技术实践分享


详解如何在NodeJS中使用Google的Protobuf


技术扫盲:新一代基于UDP的低延时网络传输层协议——QUIC详解


金蝶随手记团队分享:还在用JSON? Protobuf让数据传输更省更快(原理篇)


金蝶随手记团队分享:还在用JSON? Protobuf让数据传输更省更快(实战篇)


>> 更多同类文章 ……



6、为什么使用Netty?


首先,我们来了解一下,Netty到底是个什么东西。网络上找到的介绍:Netty是由JBOSS提供的基于Java NIO的开源框架,Netty提供异步非阻塞、事件驱动、高性能、高可靠、高可定制性的网络应用程序和工具,可用于开发服务端和客户端。


PS:如果你对Java的经典IO、NIO或者Netty框架不了解,请阅读以下文章:



史上最强Java NIO入门:担心从入门到放弃的,请读这篇!


少啰嗦!一分钟带你读懂Java的NIO和经典IO的区别


写给初学者:Java高性能NIO框架Netty的学习方法和进阶策略


NIO框架详解:Netty的高性能之道



为什么不用Java BIO?


1)一连接一线程:由于线程数是有限的,所以这样非常消耗资源,最终也导致它不能承受高并发连接的需求。


2)性能低:因为频繁的进行上下文切换,导致CUP利用率低。


3)可靠性差:由于所有的IO操作都是同步的,即使是业务线程也如此,所以业务线程的IO操作也有可能被阻塞,这将导致系统过分依赖网络的实时情况和外部组件的处理能力,可靠性大大降低。


为什么不用Java NIO?


1)NIO的类库和API相当复杂,使用它来开发,需要非常熟练地掌握Selector、ByteBuffer、ServerSocketChannel、SocketChannel等。


2)需要很多额外的编程技能来辅助使用NIO,例如,因为NIO涉及了Reactor线程模型,所以必须必须对多线程和网络编程非常熟悉才能写出高质量的NIO程序。


3)想要有高可靠性,工作量和难度都非常的大,因为服务端需要面临客户端频繁的接入和断开、网络闪断、半包读写、失败缓存、网络阻塞的问题,这些将严重影响我们的可靠性,而使用原生NIO解决它们的难度相当大。


4)JDK NIO中著名的BUG--epoll空轮询,当select返回0时,会导致Selector空轮询而导致CUP100%,官方表示JDK1.6之后修复了这个问题,其实只是发生的概率降低了,没有根本上解决。


为什么用Netty?


1)API使用简单,更容易上手,开发门槛低;


2)功能强大,预置了多种编解码功能,支持多种主流协议;


3)定制能力高,可以通过ChannelHandler对通信框架进行灵活地拓展;


4)高性能,与目前多种NIO主流框架相比,Netty综合性能最高;


5)高稳定性,解决了JDK NIO的BUG;


6)经历了大规模的商业应用考验,质量和可靠性都有很好的验证。


为什么不用第三方SDK,如:融云、环信、腾讯TIM?


这个就见仁见智了,有的时候,是因为公司的技术选型问题,因为用第三方的SDK,意味着消息数据需要存储到第三方的服务器上,再者,可扩展性、灵活性肯定没有自己开发的要好,还有一个小问题,就是收费。比如,融云免费版只支持100个注册用户,超过100就要收费,群聊支持人数有限制等等...



▲ 以上截图内容来自某云IM官网


Mina其实跟Netty很像,大部分API都相同,因为是同一个作者开发的。但感觉Mina没有Netty成熟,在使用Netty的过程中,出了问题很轻易地可以找到解决方案,所以,Netty是一个不错的选择。


PS:有关MINA和Netty框架的关系和对比,详见以下文章:



有关“为何选择Netty”的11个疑问及解答


开源NIO框架八卦——到底是先有MINA还是先有Netty?


选Netty还是Mina:深入研究与对比(一)


选Netty还是Mina:深入研究与对比(二)



好了,废话不多说,直接开始吧。


7、准备工作


首先,我们新建一个Project,在Project里面再新建一个Android Library,Module名称暂且叫做im_lib,如图所示:



然后,分析一下我们的消息结构,每条消息应该会有一个消息唯一id,发送者id,接收者id,消息类型,发送时间等,经过分析,整理出一个通用的消息类型,如下:



msgId:消息id


fromId:发送者id


toId:接收者id


msgType:消息类型


msgContentType:消息内容类型


timestamp:消息时间戳


statusReport:状态报告


extend:扩展字段



根据上述所示,我整理了一个思维导图,方便大家参考:



这是基础部分,当然,大家也可以根据自己需要自定义比较适合自己的消息结构。


我们根据自定义的消息类型来编写proto文件:



syntax = "proto3";// 指定protobuf版本


option java_package = "com.freddy.im.protobuf";// 指定包名


option java_outer_classname = "MessageProtobuf";// 指定生成的类名


 


message Msg {


    Head head = 1;// 消息头


    string body = 2;// 消息体


}


 


message Head {


    string msgId = 1;// 消息id


    int32 msgType = 2;// 消息类型


    int32 msgContentType = 3;// 消息内容类型


    string fromId = 4;// 消息发送者id


    string toId = 5;// 消息接收者id


    int64 timestamp = 6;// 消息时间戳


    int32 statusReport = 7;// 状态报告


    string extend = 8;// 扩展字段,以key/value形式存放的json


}



然后执行命令(我用的mac,windows命令应该也差不多):



然后就会看到,在和proto文件同级目录下,会生成一个java类,这个就是我们需要用到的东东:



我们打开瞄一眼:



东西比较多,不用去管,这是google为我们生成的protobuf类,直接用就行,怎么用呢?


直接用这个类文件,拷到我们开始指定的项目包路径下就可以啦:



添加依赖后,可以看到,MessageProtobuf类文件已经没有报错了,顺便把netty的jar包也导进来一下,还有fastjson的:



建议用netty-all-x.x.xx.Final的jar包,后续熟悉了,可以用精简的jar包。


至此,准备工作已结束,下面,我们来编写java代码,实现即时通讯的功能。


8、代码封装


为什么需要封装呢?说白了,就是为了解耦,为了方便日后切换到不同框架实现,而无需到处修改调用的地方。


举个栗子,比如Android早期比较流行的图片加载框架是Universal ImageLoader,后期因为某些原因,原作者停止了维护该项目,目前比较流行的图片加载框架是Picasso或Glide,因为图片加载功能可能调用的地方非常多,如果不作一些封装,早期使用了Universal ImageLoader的话,现在需要切换到Glide,那改动量将非常非常大,而且还很有可能会有遗漏,风险度非常高。


那么,有什么解决方案呢?


很简单,我们可以用工厂设计模式进行一些封装,工厂模式有三种:简单工厂模式、抽象工厂模式、工厂方法模式。在这里,我采用工厂方法模式进行封装,具体区别,可以参见:《通俗讲讲我对简单工厂、工厂方法、抽象工厂三种设计模式的理解》。


我们分析一下,ims(IM Service,下文简称ims)应该是有初始化、建立连接、重连、关闭连接、释放资源、判断长连接是否关闭、发送消息等功能。


基于上述分析,我们可以进行一个接口抽象:




OnEventListener是与应用层交互的listener:



IMConnectStatusCallback是ims连接状态回调监听器:



然后写一个Netty tcp实现类:




接下来,写一个工厂方法:



封装部分到此结束,接下来,就是实现了。


9、初始化


我们先实现init(Vector serverUrlList, OnEventListener listener, IMSConnectStatusCallback callback)方法,初始化一些参数,以及进行第一次连接等:



其中,MsgDispatcher是消息转发器,负责将接收到的消息转发到应用层:



ExecutorServiceFactory是线程池工厂,负责调度重连及心跳线程:





10、连接及重连


resetConnect()方法作为连接的起点,首次连接以及重连逻辑,都是在resetConnect()方法进行逻辑处理。


我们来瞄一眼:



可以看到,非首次进行连接,也就是连接一个周期失败后,进行重连时,会先让线程休眠一段时间,因为这个时候也许网络状况不太好,接着,判断ims是否已关闭或者是否正在进行重连操作,由于重连操作是在子线程执行,为了避免重复重连,需要进行一些并发处理。


开始重连任务后,分四个步骤执行:



1)改变重连状态标识;


2)回调连接状态到应用层;


3)关闭之前打开的连接channel;


4)利用线程池执行一个新的重连任务。



ResetConnectRunnable是重连任务,核心的重连逻辑都放到这里执行:





toServer()是真正连接服务器的地方:



initBootstrap()是初始化Netty Bootstrap:



注:NioEventLoopGroup线程数设置为4,可以满足QPS是一百多万的情况了,至于应用如果需要承受上千万上亿流量的,需要另外调整线程数。(参考自:《netty实战之百万级流量NioEventLoopGroup线程数配置》)


接着,我们来看看TCPChannelInitializerHanlder



其中,ProtobufEncoderProtobufDecoder是添加对protobuf的支持,LoginAuthRespHandler是接收到服务端握手认证消息响应的处理handler,HeartbeatRespHandler是接收到服务端心跳消息响应的处理handler,TCPReadHandler是接收到服务端其它消息后的处理handler,先不去管,我们重点来分析下LengthFieldPrependerLengthFieldBasedFrameDecoder,这就需要引申到TCP的拆包与粘包啦。


11、TCP的拆包与粘包


什么是TCP拆包?为什么会出现TCP拆包?


简单地说,我们都知道TCP是以“流”的形式进行数据传输的,而且TCP为提高性能,发送端会将需要发送的数据刷入缓冲区,等待缓冲区满了之后,再将缓冲区中的数据发送给接收方,同理,接收方也会有缓冲区这样的机制,来接收数据。拆包就是在socket读取时,没有完整地读取一个数据包,只读取一部分。


什么是TCP粘包?为什么会出现TCP粘包?


同上。粘包就是在socket读取时,读到了实际意义上的两个或多个数据包的内容,同时将其作为一个数据包进行处理。


引用一张图片来解释一下在TCP出现拆包、粘包以及正常状态下的三种情况:



了解了TCP出现拆包/粘包的原因,那么,如何解决呢?


通常来说,有以下四种解决方式:



1)消息定长;


2)用回车换行符作为消息结束标志;


3)用特殊分隔符作为消息结束标志,如\t、\n等,回车换行符其实就是特殊分隔符的一种;


4)将消息分为消息头和消息体,在消息头中用字段标识消息总长度。



netty针对以上四种场景,给我们封装了以下四种对应的解码器:



1)FixedLengthFrameDecoder,定长消息解码器;


2)LineBasedFrameDecoder,回车换行符消息解码器;


3)DelimiterBasedFrameDecoder,特殊分隔符消息解码器;


4)LengthFieldBasedFrameDecoder,自定义长度消息解码器。



我们用到的就是LengthFieldBasedFrameDecoder自定义长度消息解码器,同时配合LengthFieldPrepender编码器使用,关于参数配置,建议参考《netty--最通用TCP黏包解决方案:LengthFieldBasedFrameDecoder和LengthFieldPrepender》这篇文章,讲解得比较细致。


我们配置的是消息头长度为2个字节,所以消息包的最大长度需要小于65536个字节,netty会把消息内容长度存放消息头的字段里,接收方可以根据消息头的字段拿到此条消息总长度,当然,netty提供的LengthFieldBasedFrameDecoder已经封装好了处理逻辑,我们只需要配置lengthFieldOffset、lengthFieldLength、lengthAdjustment、initialBytesToStrip即可,这样就可以解决TCP的拆包与粘包,这也就是netty相较于原生nio的便捷性,原生nio需要自己处理拆包/粘包等问题。


12、长连接握手认证


接着,我们来看看LoginAuthHandler和HeartbeatRespHandler。


LoginAuthRespHandler:是当客户端与服务端长连接建立成功后,客户端主动向服务端发送一条登录认证消息,带入与当前用户相关的参数,比如token,服务端收到此消息后,到数据库查询该用户信息,如果是合法有效的用户,则返回一条登录成功消息给该客户端,反之,返回一条登录失败消息给该客户端,这里,就是在接收到服务端返回的登录状态后的处理handler。


比如:



可以看到,当接收到服务端握手消息响应后,会从扩展字段取出status,如果status=1,则代表握手成功,这个时候就先主动向服务端发送一条心跳消息,然后利用Netty的IdleStateHandler读写超时机制,定期向服务端发送心跳消息,维持长连接,以及检测长连接是否还存在等。


HeartbeatRespHandler:是当客户端接收到服务端登录成功的消息后,主动向服务端发送一条心跳消息,心跳消息可以是一个空包,消息包体越小越好,服务端收到客户端的心跳包后,原样返回给客户端,这里,就是收到服务端返回的心跳消息响应的处理handler。


比如:



这个就比较简单,收到心跳消息响应,无需任务处理,直接打印一下方便我们分析即可。


13、心跳机制及读写超时机制


心跳包是定期发送,也可以自己定义一个周期,比如:《移动端IM实践:实现Android版微信的智能心跳机制》,为了简单,此处规定应用在前台时,8秒发送一个心跳包,切换到后台时,30秒发送一次,根据自己的实际情况修改一下即可。心跳包用于维持长连接以及检测长连接是否断开等。


PS:更多心跳保活方面的文章请见:



Android端消息推送总结:实现原理、心跳保活、遇到的问题等


为何基于TCP协议的移动端IM仍然需要心跳保活机制?


微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)


移动端IM实践:WhatsApp、Line、微信的心跳策略分析



接着,我们利用Netty的读写超时机制,来实现一个心跳消息管理handler:



可以看到,利用userEventTriggered()方法回调,通过IdleState类型,可以判断读超时/写超时/读写超时,这个在添加IdleStateHandler时可以配置,下面会贴上代码。


首先我们可以在READER_IDLE事件里,检测是否在规定时间内没有收到服务端心跳包响应,如果是,那就触发重连操作。在WRITER_IDEL事件可以检测客户端是否在规定时间内没有向服务端发送心跳包,如果是,那就主动发送一个心跳包。发送心跳包是在子线程中执行,我们可以利用之前写的work线程池进行线程管理。


addHeartbeatHandler()代码如下:



从图上可看到,在IdleStateHandler里,配置的读超时为心跳间隔时长的3倍,也就是3次心跳没有响应时,则认为长连接已断开,触发重连操作。写超时则为心跳间隔时长,意味着每隔heartbeatInterval会发送一个心跳包。读写超时没用到,所以配置为0。


onConnectStatusCallback(int connectStatus)为连接状态回调,以及一些公共逻辑处理:



连接成功后,立即发送一条握手消息,再次梳理一下整体流程:


1)客户端根据服务端返回的host及port,进行第一次连接;


2)连接成功后,客户端向服务端发送一条握手认证消息(1001);


3)服务端在收到客户端的握手认证消息后,从扩展字段里取出用户token,到本地数据库校验合法性;


4)校验完成后,服务端把校验结果通过1001消息返回给客户端,也就是握手消息响应;


5)客户端收到服务端的握手消息响应后,从扩展字段取出校验结果。若校验成功,客户端向服务端发送一条心跳消息(1002),然后进入心跳发送周期,定期间隔向服务端发送心跳消息,维持长连接以及实时检测链路可用性,若发现链路不可用,等待一段时间触发重连操作,重连成功后,重新开始握手/心跳的逻辑。


看看TCPReadHandler收到消息是怎么处理的:




可以看到,在channelInactive()exceptionCaught()方法都触发了重连,channelInactive()方法在当链路断开时会调用,exceptionCaught()方法在当出现异常时会触发,另外,还有诸如channelUnregistered()channelReadComplete()等方法可以重写,在这里就不贴了,相信聪明的你一眼就能看出方法的作用。


我们仔细看一下channelRead()方法的逻辑,在if判断里,先判断消息类型,如果是服务端返回的消息发送状态报告类型,则判断消息是否发送成功,如果发送成功,从超时管理器中移除,这个超时管理器是干嘛的呢?


下面讲到消息重发机制的时候会详细地讲。在else里,收到其他消息后,会立马给服务端返回一个消息接收状态报告,告诉服务端,这条消息我已经收到了,这个动作,对于后续需要做的离线消息会有作用。如果不需要支持离线消息功能,这一步可以省略。最后,调用消息转发器,把接收到的消息转发到应用层即可。


代码写了这么多,我们先来看看运行后的效果,先贴上缺失的消息发送代码及ims关闭代码以及一些默认配置项的代码。


发送消息:



关闭ims:



ims默认配置:



还有,应用层实现的ims client启动器:



由于代码有点多,不太方便全部贴上,如果有兴趣可以下载本文的完整demo进行体验。


额,对了,还有一个简易的服务端代码,如下:





14、运行调试


我们先来看看连接及重连部分(由于录制gif比较麻烦,体积较大,所以我先把重连间隔调小成3秒,方便看效果)。


启动服务端:



启动客户端:



可以看到,正常的情况下已经连接成功了,接下来,我们来试一下异常情况。


比如服务端没启动,看看客户端的重连情况:



这次我们先启动的是客户端,可以看到连接失败后一直在进行重连,由于录制gif比较麻烦,在第三次连接失败后,我启动了服务端,这个时候客户端就会重连成功。


然后,我们再来调试一下握手认证消息即心跳消息:



可以看到,长连接建立成功后,客户端会给服务端发送一条握手认证消息(1001),服务端收到握手认证消息会,给客户端返回了一条握手认证状态消息,客户端收到握手认证状态消息后,即启动心跳机制。gif不太好演示,下载demo就可以直观地看到。


接下来,在讲完消息重发机制及离线消息后,我会在应用层做一些简单的封装,以及在模拟器上运行,这样就可以很直观地看到运行效果。


15、消息重发机制


消息重发,顾名思义,即使对发送失败的消息进行重发。考虑到网络环境的不稳定性、多变性(比如从进入电梯、进入地铁、移动网络切换到wifi等),在消息发送的时候,发送失败的概率其实不小,这时消息重发机制就很有必要了。


有关即时通讯(IM)应用中的消息送达保证机制,可以详细阅读以下文章:



IM消息送达保证机制实现(一):保证在线实时消息的可靠投递


IM群聊消息如此复杂,如何保证不丢不重?


完全自已开发的IM该如何设计“失败重试”机制?



我们先来看看实现的代码逻辑。


MsgTimeoutTimer:




MsgTimeoutTimerManager:




然后,我们看看收消息的TCPReadHandler的改造:



最后,看看发送消息的改造:



说一下逻辑吧:发送消息时,除了心跳消息、握手消息、状态报告消息外,消息都加入消息发送超时管理器,立马开启一个定时器,比如每隔5秒执行一次,共执行3次,在这个周期内,如果消息没有发送成功,会进行3次重发,达到3次重发后如果还是没有发送成功,那就放弃重发,移除该消息,同时通过消息转发器通知应用层,由应用层决定是否再次重发。如果消息发送成功,服务端会返回一个消息发送状态报告,客户端收到该状态报告后,从消息发送超时管理器移除该消息,同时停止该消息对应的定时器即可。


另外,在用户握手认证成功时,应该检查消息发送超时管理器里是否有发送超时的消息,如果有,则全部重发:



16、离线消息


由于离线消息机制,需要服务端数据库及缓存上的配合,代码就不贴了,太多太多。


我简单说一下实现思路吧:客户端A发送消息到客户端B,消息会先到服务端,由服务端进行中转。


这个时候,客户端B存在两种情况:



1)长连接正常,就是客户端网络环境良好,手机有电,应用处在打开的情况;


2)废话,那肯定就是长连接不正常咯。这种情况有很多种原因,比如wifi不可用、用户进入了地铁或电梯等网络不好的场所、应用没打开或已退出登录等,总的来说,就是没有办法正常接收消息。



如果是长连接正常,那没什么可说的,服务端直接转发即可。


如果长连接不正常,需要这样处理:


服务端接收到客户端A发送给客户端B的消息后,先给客户端A回复一条状态报告,告诉客户端A,我已经收到消息,这个时候,客户端A就不用管了,消息只要到达服务端即可。然后,服务端先尝试把消息转发到客户端B,如果这个时候客户端B收到服务端转发过来的消息,需要立马给服务端回一条状态报告,告诉服务端,我已经收到消息,服务端在收到客户端B返回的消息接收状态报告后,即认为此消息已经正常发送,不需要再存库。


如果客户端B不在线,服务端在做转发的时候,并没有收到客户端B返回的消息接收状态报告,那么,这条消息就应该存到数据库,直到客户端B上线后,也就是长连接建立成功后,客户端B主动向服务端发送一条离线消息询问,服务端在收到离线消息询问后,到数据库或缓存去查客户端B的所有离线消息,并分批次返回,客户端B在收到服务端的离线消息返回后,取出消息id(若有多条就取id集合),通过离线消息应答把消息id返回到服务端,服务端收到后,根据消息id从数据库把对应的消息删除即可。


以上是单聊离线消息处理的情况,群聊有点不同,群聊的话,是需要服务端确认群组内所有用户都收到此消息后,才能从数据库删除消息,就说这么多,如果需要细节的话,可以私信我。


更多有关离线消息处理思路的文章,可以详细阅读:



IM消息送达保证机制实现(二):保证离线消息的可靠投递


IM群聊消息如此复杂,如何保证不丢不重?


浅谈移动端IM的多点登陆和消息漫游原理



不知不觉,NettyTcpClient中定义了很多变量,为了防止大家不明白变量的定义,还是贴上代码吧:



18、最终运行


运行一下,看看效果吧:



运行步骤是:



1)首先,启动服务端。


2)然后,修改客户端连接的ip地址为192.168.0.105(这是我本机的ip地址),端口号为8855,fromId,也就是userId,定义成100001,toId为100002,启动客户端A。


3)再然后,fromId,也就是userId,定义成100002,toId为100001,启动客户端B。


4)客户端A给客户端B发送消息,可以看到在客户端B的下面,已经接收到了消息。


5)用客户端B给客户端A发送消息,也可以看到在客户端A的下面,也已经接收到了消息。



至于,消息收发测试成功。至于群聊或重连等功能,就不一一演示了,还是那句话,下载demo体验一下吧:https://github.com/52im/NettyChat


由于gif录制体积较大,所以只能简单演示一下消息收发,具体下载demo体验吧。如果有需要应用层UI实现(就是聊天页及会话页的封装)的话,我再分享出来吧。


19、写在最后


终于写完了,这篇文章大概写了10天左右,有很大部分的原因是自己有拖延症,每次写完一小段,总静不下心来写下去,导致一直拖到现在,以后得改改。第一次写技术分享文章,有很多地方也许逻辑不太清晰,由于篇幅有限,也只是贴了部分代码,建议大家把源码下载下来看看。一直想写这篇文章,以前在网上也尝试过找过很多im方面的文章,都找不到一篇比较完善的,本文谈不上完善,但包含的模块很多,希望起到一个抛砖引玉的作用,也期待着大家跟我一起发现更多的问题并完善,最后,如果这篇文章对你有用,希望在github上给我一个star哈。。。


应大家要求,精简了netty-all-4.1.33.Final.jar包,原netty-all-4.1.33.Final.jar包大小为3.9M。


经测试发现目前im_lib库只需要用到以下jar包:



netty-buffer-4.1.33.Final.jar


netty-codec-4.1.33.Final.jar


netty-common-4.1.33.Final.jar


netty-handler-4.1.33.Final.jar


netty-resolver-4.1.33.Final.jar


netty-transport-4.1.33.Final.jar



所以,抽取以上jar包,重新打成了netty-tcp-4.1.33-1.0.jar(已经上传到github工程了),目前自测没有问题,如果发现bug,请告诉我,谢谢。


附上原jar及裁剪后jar包的大小对比:




代码已更新到Github:


https://github.com/52im/NettyChat


附录:更多网络编程/即时通讯/消息推送的实战入门文章



手把手教你用Netty实现网络通信程序的心跳机制、断线重连机制
NIO框架入门(一):服务端基于Netty4的UDP双向通信Demo演示
NIO框架入门(二):服务端基于MINA2的UDP双向通信Demo演示
NIO框架入门(三):iOS与MINA2、Netty4的跨平台UDP双向通信实战
NIO框架入门(四):Android与MINA2、Netty4的跨平台UDP双向通信实战
微信小程序中如何使用WebSocket实现长连接(含完整源码)
Web端即时通讯安全:跨站点WebSocket劫持漏洞详解(含示例代码)
解决MINA数据传输中TCP的粘包、缺包问题(有源码)
开源IM工程“蘑菇街TeamTalk”2015年5月前未删减版完整代码 [附件下载]
用于IM中图片压缩的Android工具类源码,效果可媲美微信 [附件下载]
高仿Android版手机QQ可拖拽未读数小气泡源码 [附件下载]
一个WebSocket实时聊天室Demo:基于node.js+socket.io [附件下载]
Android聊天界面源码:实现了聊天气泡、表情图标(可翻页) [附件下载]
高仿Android版手机QQ首页侧滑菜单源码 [附件下载]
开源libco库:单机千万连接、支撑微信8亿用户的后台框架基石 [源码下载]
分享java AMR音频文件合并源码,全网最全
微信团队原创Android资源混淆工具:AndResGuard [有源码]
一个基于MQTT通信协议的完整Android推送Demo [附件下载]
Android版高仿微信聊天界面源码 [附件下载]
高仿手机QQ的Android版锁屏聊天消息提醒功能 [附件下载]
高仿iOS版手机QQ录音及振幅动画完整实现 [源码下载]
Android端社交应用中的评论和回复功能实战分享[图文+源码]
Android端IM应用中的@人功能实现:仿微博、QQ、微信,零入侵、高可扩展[图文+源码]
仿微信的IM聊天时间显示格式(含iOS/Android/Web实现)[图文+源码]



(本文同步发布于:http://www.52im.net/thread-2671-1-1.html

收起阅读 »

从零开始开发IM(即时通讯)服务端

IM
精选:★→2020年最新的常问企业面试题大全以及答案 作者:yuanrw 原文地址 https://juejin.im/post/5d6b3949f265da03c34c13e5 好消息:IM1.0.0 版本已经上线啦,支持特性: ...
继续阅读 »

精选:★→2020年最新的常问企业面试题大全以及答案



作者:yuanrw


原文地址 https://juejin.im/post/5d6b3949f265da03c34c13e5



好消息:IM1.0.0 版本已经上线啦,支持特性



  • 私聊发送文本 / 文件


  • 已发送 / 已送达 / 已读回执


  • 支持使用 ldap 登录


  • 支持接入外部的登录认证系统


  • 提供客户端 jar 包,方便客户端开发
    github 链接: github.com/yuanrw/IM



前言


首先讲讲 IM(即时通讯)技术可以用来做什么:
聊天:qq、微信
直播:斗鱼直播、抖音
实时位置共享、游戏多人互动等等
可以说几乎所有高实时性的应用场景都需要用到 IM 技术。


本篇将带大家从零开始搭建一个轻量级的 IM 服务端,麻雀虽小,五脏俱全,我们搭建的 IM 服务端实现以下功能



  1. 一对一的文本消息、文件消息通信


  2. 每个消息有 “已发送”/“已送达”/“已读” 回执


  3. 存储离线消息


  4. 支持用户登录,好友关系等基本功能。


  5. 能够方便地水平扩展



通过这个项目能学到什么?


这个项目涵盖了很多后端必备知识



  • rpc 通信


  • 数据库


  • 缓存


  • 消息队列


  • 分布式、高并发的架构设计


  • docker 部署



消息通信


文本消息


我们先从最简单的特性开始实现:一个普通消息的发送
消息格式如下:


message ChatMsg{
id = 1;
//消息id
fromId = Alice
//发送者userId
destId = Bob
//接收者userId
msgBody = hello
//消息体
}
复制代码


如上图,我们现在有两个用户:Alice 和 Bob 连接到了服务器,当 Alice 发送消息


message(hello)


给 Bob,服务端接收到消息,根据消息的 destId 进行转发,转发给 Bob。


发送回执


那我们要怎么来实现回执的发送呢?
我们定义一种回执数据格式 ACK,MsgType 有三种,分别是 sent(已发送), delivered(已送达), read(已读):


message AckMsg {
id;
//消息id
fromId;
//发送者id
destId;
//接收者id
msgType;
//消息类型
ackMsgId;
//确认的消息id
}
enum MsgType {
DELIVERED;
READ;
}
复制代码

当服务端接受到 Alice 发来的消息时:



  1. 向 Alice 发送一个 sent(hello)表示消息已经被发送到服务器。



message AckMsg {
id = 2;
fromId = Alice;
destId = Bob;
msgType = SENT;
ackMsgId = 1;
}
复制代码



  1. 服务器把



hello


转发给 Bob 后,立刻向 Alice 发送


delivered(hello)


表示消息已经发送给 Bob。


message AckMsg {
id = 3;
fromId = Bob;
destId = Alice;
msgType = DELIVERED;
ackMsgId = 1;
}
复制代码



  1. Bob 阅读消息后,客户端向服务器发送



read(hello)


表示消息已读


message AckMsg {
id = 4;
fromId = Bob;
destId = Alice;
msgType = READ;
ackMsgId = 1;
}
复制代码

这个消息会像一个普通聊天消息一样被服务器处理,最终发送给 Alice。



在服务器这里不区分 ChatMsg和 AckMsg,处理过程都是一样的:解析消息的 destId并进行转发。


水平扩展


当用户量越来越大,必然需要增加服务器的数量,用户的连接被分散在不同的机器上。此时,就需要存储用户连接在哪台机器上。
我们引入一个新的模块来管理用户的连接信息。


管理用户状态



模块叫做 user status,共有三个接口:


public interface UserStatusService {
/**
* 用户上线,存储userId与机器id的关系
*
* @param userId
* @param connectorId
* @return 如果当前用户在线,则返回他连接的机器id,否则返回null
*/
String online(String userId, String connectorId);
/**
* 用户下线
*
* @param userId
*/
void offline(String userId);
/**
* 通过用户id查找他当前连接的机器id
*
* @param userId
* @return
*/
String getConnectorId(String userId);
}
复制代码

这样我们就能够对用户连接状态进行管理了,具体的实现应考虑服务的用户量、期望性能等进行实现。
此处我们使用 redis 来实现,将 userId 和 connectorId 的关系以 key-value 的形式存储。


消息转发


除此之外,还需要一个模块在不同的机器上转发消息,如下结构:



此时我们的服务被拆分成了 connector和 transfer两个模块, connector模块用于维持用户的长链接,而 transfer的作用是将消息在多个 connector之间转发。
现在 Alice 和 Bob 连接到了两台 connector 上,那么消息要如何传递呢?



  1. Alice 上线,连接到 机器[1]上时




  • 将 Alice 和它的连接存入内存中。


  • 调用 user status的 online方法记录 Alice 上线。




  • Alice 发送了一条消息给 Bob




    • 机器[1]收到消息后,解析 destId,在内存中查找是否有 Bob。


    • 如果没有,代表 Bob 未连接到这台机器,则转发给 transfer



  • transfer调用 user status的 getConnectorId(Bob)方法找到 Bob 所连接的 connector,返回 机器[2],则转发给 机器[2]


  • 流程图:

    总结:



    • 引入 user status模块管理用户连接, transfer模块在不同的机器之间转发,使服务可以水平扩展。


    • 为了满足实时转发, transfer需要和每台 connector机器都保持长链接。


    离线消息

    如果用户当前不在线,就必须把消息持久化下来,等待用户下次上线再推送,这里使用 mysql 存储离线消息。
    为了方便地水平扩展,我们使用消息队列进行解耦



    • transfer接收到消息后如果发现用户不在线,就发送给消息队列入库。


    • 用户登录时,服务器从库里拉取离线消息进行推送。


    用户登录、好友关系

    用户的注册登录、账户管理、好友关系链等功能更适合使用 http 协议,因此我们将这个模块做成一个 restful 服务,对外暴露 http 接口供客户端调用。

    至此服务端的基本架构就完成了:

    总结

    以上就是这篇博客的所有内容,本篇帮大家构建了 IM 服务端的架构,但还有很多细节需要我们去思考,例如:



    • 如何保证消息的顺序和唯一


    • 多个设备在线如何保证消息一致性


    • 如何处理消息发送失败


    • 消息的安全性


    • 如果要存储聊天记录要怎么做


    • 数据库分表分库


    • 服务高可用
      ……


    更多细节实现就留到下一篇啦~

    IM1.0.0 版本已上线,github 链接: github.com/yuanrw/IM
    觉得对你有帮助请点个 star 吧~!

    (完)

    推荐阅读

    Spring Boot整合JWT实现用户认证(附源码)

    IDEA新特性:提前知道代码怎么走

    还在担心写的一手烂SQL,送你4款工

    瞬间几千次的重复提交,我用 SpringBoot+Redis 扛住了

    爬了知乎“神回复”,笑得根本停不下来

    这 17 个 JVM 参数,高级 Java 必须掌握!


    好文!必须点赞


收起阅读 »

开源一个自用的Android IM库,基于Netty+TCP+Protobuf实现(4)

调试 我们先来看看连接及重连部分(由于录制gif比较麻烦,体积较大,所以我先把重连间隔调小成3秒,方便看效果)。 启动服务端: 启动客户端: 可以看到,正常的情况下已经连接成功了,接下来,我们来试一下异常情况,比如服务端没启动,看看客户端的重连情况: 这...
继续阅读 »

调试


我们先来看看连接及重连部分(由于录制gif比较麻烦,体积较大,所以我先把重连间隔调小成3秒,方便看效果)。



  • 启动服务端:启动服务端

  • 启动客户端:启动客户端


可以看到,正常的情况下已经连接成功了,接下来,我们来试一下异常情况,比如服务端没启动,看看客户端的重连情况:
调试重连
这次我们先启动的是客户端,可以看到连接失败后一直在进行重连,由于录制gif比较麻烦,在第三次连接失败后,我启动了服务端,这个时候客户端就会重连成功。


然后,我们再来调试一下握手认证消息即心跳消息:
握手消息及心跳消息测试
可以看到,长连接建立成功后,客户端会给服务端发送一条握手认证消息(1001),服务端收到握手认证消息会,给客户端返回了一条握手认证状态消息,客户端收到握手认证状态消息后,即启动心跳机制。gif不太好演示,下载demo就可以直观地看到。


接下来,在讲完消息重发机制及离线消息后,我会在应用层做一些简单的封装,以及在模拟器上运行,这样就可以很直观地看到运行效果。




消息重发机制


消息重发,顾名思义,即使对发送失败的消息进行重发。考虑到网络环境的不稳定性、多变性(比如从进入电梯、进入地铁、移动网络切换到wifi等),在消息发送的时候,发送失败的概率其实不小,这时消息重发机制就很有必要了。

我们先来看看实现的代码逻辑。
MsgTimeoutTimer:
MsgTimeoutTimer1
MsgTimeoutTimer2
MsgTimeoutTimerManager:
MsgTimeoutTimerManager1
MsgTimeoutTimerManager2

然后,我们看看收消息的TCPReadHandler的改造:
加入消息重发机制的TCPReadHandler
最后,看看发送消息的改造:
加入消息重发机制的发送消息


说一下逻辑吧:发送消息时,除了心跳消息、握手消息、状态报告消息外,消息都加入消息发送超时管理器,立马开启一个定时器,比如每隔5秒执行一次,共执行3次,在这个周期内,如果消息没有发送成功,会进行3次重发,达到3次重发后如果还是没有发送成功,那就放弃重发,移除该消息,同时通过消息转发器通知应用层,由应用层决定是否再次重发。如果消息发送成功,服务端会返回一个消息发送状态报告,客户端收到该状态报告后,从消息发送超时管理器移除该消息,同时停止该消息对应的定时器即可。

另外,在用户握手认证成功时,应该检查消息发送超时管理器里是否有发送超时的消息,如果有,则全部重发:
握手认证成功检查是否有发送超时的消息




离线消息


由于离线消息机制,需要服务端数据库及缓存上的配合,代码就不贴了,太多太多,我简单说一下实现思路吧:
客户端A发送消息到客户端B,消息会先到服务端,由服务端进行中转。这个时候,客户端B存在两种情况:



  • 1.长连接正常,就是客户端网络环境良好,手机有电,应用处在打开的情况。

  • 2.废话,那肯定就是长连接不正常咯。这种情况有很多种原因,比如wifi不可用、用户进入了地铁或电梯等网络不好的场所、应用没打开或已退出登录等,总的来说,就是没有办法正常接收消息。


如果是长连接正常,那没什么可说的,服务端直接转发即可。

如果长连接不正常,需要这样处理:服务端接收到客户端A发送给客户端B的消息后,先给客户端A回复一条状态报告,告诉客户端A,我已经收到消息,这个时候,客户端A就不用管了,消息只要到达服务端即可。然后,服务端先尝试把消息转发到客户端B,如果这个时候客户端B收到服务端转发过来的消息,需要立马给服务端回一条状态报告,告诉服务端,我已经收到消息,服务端在收到客户端B返回的消息接收状态报告后,即认为此消息已经正常发送,不需要再存库。如果客户端B不在线,服务端在做转发的时候,并没有收到客户端B返回的消息接收状态报告,那么,这条消息就应该存到数据库,直到客户端B上线后,也就是长连接建立成功后,客户端B主动向服务端发送一条离线消息询问,服务端在收到离线消息询问后,到数据库或缓存去查客户端B的所有离线消息,并分批次返回,客户端B在收到服务端的离线消息返回后,取出消息id(若有多条就取id集合),通过离线消息应答把消息id返回到服务端,服务端收到后,根据消息id从数据库把对应的消息删除即可。

以上是单聊离线消息处理的情况,群聊有点不同,群聊的话,是需要服务端确认群组内所有用户都收到此消息后,才能从数据库删除消息,就说这么多,如果需要细节的话,可以私信我。




不知不觉,NettyTcpClient中定义了很多变量,为了防止大家不明白变量的定义,还是贴上代码吧:
定义了很多变量的NettyTcpClient


应用层封装


这个就见仁见智啦,每个人代码风格不同,我把自己简单封装的代码贴上来吧:

MessageProcessor消息处理器:
MessageProcessor1
MessageProcessor2
IMSEventListener与ims交互的listener:
IMSEventListener1
IMSEventListener2
IMSEventListener3
MessageBuilder消息转换器:
MessageBuilder1
MessageBuilder2
MessageBuilder3
AbstractMessageHandler抽象的消息处理handler,每个消息类型对应不同的messageHandler:
AbstractMessageHandler
SingleChatMessageHandler单聊消息处理handler:
SingleChatMessageHandler
GroupChatMessageHandler群聊消息处理handler:
GroupChatMessageHandler
MessageHandlerFactory消息handler工厂:
MessageHandlerFactory
MessageType消息类型枚举:
MessageType
IMSConnectStatusListenerIMS连接状态监听器:
IMSConnectStatusListener
由于每个人代码风格不同,封装代码都有自己的思路,所以,在此就不过多讲解,只是把自己简单封装的代码全部贴上来,作一个参考即可。只需要知道,接收到消息时,会回调OnEventListener的dispatchMsg(MessageProtobuf.Msg msg)方法:
应用层接收ims消息入口
发送消息需要调用imsClient的sendMsg(MessageProtobuf.Msg msg)方法:
应用层调用ims发送消息入口
即可,至于怎样去封装得更好,大家自由发挥吧。




最后,为了测试消息收发是否正常,我们需要改动一下服务端:
改动后的服务端1
改动后的服务端2
改动后的服务端3
改动后的服务端4
改动后的服务端5
可以看到,当有用户握手成功后,会保存该用户对应的channel到容器里,给用户发送消息时,根据用户id从容器里取出对应的channel,利用该channel发送消息。当用户断开连接后,会把该用户对应的channel从容器里移除掉。


运行一下,看看效果吧:
最终运行效果



  • 首先,启动服务端。

  • 然后,修改客户端连接的ip地址为192.168.0.105(这是我本机的ip地址),端口号为8855,fromId,也就是userId,定义成100001,toId为100002,启动客户端A。

  • 再然后,fromId,也就是userId,定义成100002,toId为100001,启动客户端B。

  • 客户端A给客户端B发送消息,可以看到在客户端B的下面,已经接收到了消息。

  • 用客户端B给客户端A发送消息,也可以看到在客户端A的下面,也已经接收到了消息。


至于,消息收发测试成功。至于群聊或重连等功能,就不一一演示了,还是那句话,下载demo体验一下吧。。。


由于gif录制体积较大,所以只能简单演示一下消息收发,具体下载demo体验吧。。。


如果有需要应用层UI实现(就是聊天页及会话页的封装)的话,我再分享出来吧。



作者:FreddyChen
链接:https://juejin.cn/post/6844903815846559757
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

开源一个自用的Android IM库,基于Netty+TCP+Protobuf实现(3)

TCP的拆包与粘包 什么是TCP拆包?为什么会出现TCP拆包? 简单地说,我们都知道TCP是以“流”的形式进行数据传输的,而且TCP为提高性能,发送端会将需要发送的数据刷入缓冲区,等待缓冲区满了之后,再将缓冲区中的数据发送给接收方,同理,接收方也会有缓冲区...
继续阅读 »

TCP的拆包与粘包




  • 什么是TCP拆包?为什么会出现TCP拆包?


    简单地说,我们都知道TCP是以“流”的形式进行数据传输的,而且TCP为提高性能,发送端会将需要发送的数据刷入缓冲区,等待缓冲区满了之后,再将缓冲区中的数据发送给接收方,同理,接收方也会有缓冲区这样的机制,来接收数据。

    拆包就是在socket读取时,没有完整地读取一个数据包,只读取一部分。




  • 什么是TCP粘包?为什么会出现TCP粘包?


    同上。

    粘包就是在socket读取时,读到了实际意义上的两个或多个数据包的内容,同时将其作为一个数据包进行处理。




引用网上一张图片来解释一下在TCP出现拆包、粘包以及正常状态下的三种情况,如侵请联系我删除:
TCP拆包、粘包、正常状态
了解了TCP出现拆包/粘包的原因,那么,如何解决呢?通常来说,有以下四种解决方式:



  • 消息定长

  • 用回车换行符作为消息结束标志

  • 用特殊分隔符作为消息结束标志,如\t、\n等,回车换行符其实就是特殊分隔符的一种。

  • 将消息分为消息头和消息体,在消息头中用字段标识消息总长度。


netty针对以上四种场景,给我们封装了以下四种对应的解码器:



  • FixedLengthFrameDecoder,定长消息解码器

  • LineBasedFrameDecoder,回车换行符消息解码器

  • DelimiterBasedFrameDecoder,特殊分隔符消息解码器

  • LengthFieldBasedFrameDecoder,自定义长度消息解码器。


我们用到的就是LengthFieldBasedFrameDecoder自定义长度消息解码器,同时配合LengthFieldPrepender编码器使用,关于参数配置,建议参考netty--最通用TCP黏包解决方案:LengthFieldBasedFrameDecoder和LengthFieldPrepender这篇文章,讲解得比较细致。我们配置的是消息头长度为2个字节,所以消息包的最大长度需要小于65536个字节,netty会把消息内容长度存放消息头的字段里,接收方可以根据消息头的字段拿到此条消息总长度,当然,netty提供的LengthFieldBasedFrameDecoder已经封装好了处理逻辑,我们只需要配置lengthFieldOffset、lengthFieldLength、lengthAdjustment、initialBytesToStrip即可,这样就可以解决TCP的拆包与粘包,这也就是netty相较于原生nio的便捷性,原生nio需要自己处理拆包/粘包等问题。




长连接握手认证


接着,我们来看看LoginAuthHandlerHeartbeatRespHandler



  • LoginAuthRespHandler是当客户端与服务端长连接建立成功后,客户端主动向服务端发送一条登录认证消息,带入与当前用户相关的参数,比如token,服务端收到此消息后,到数据库查询该用户信息,如果是合法有效的用户,则返回一条登录成功消息给该客户端,反之,返回一条登录失败消息给该客户端,这里,就是在接收到服务端返回的登录状态后的处理handler,比如:LoginAuthRespHandler


可以看到,当接收到服务端握手消息响应后,会从扩展字段取出status,如果status=1,则代表握手成功,这个时候就先主动向服务端发送一条心跳消息,然后利用Netty的IdleStateHandler读写超时机制,定期向服务端发送心跳消息,维持长连接,以及检测长连接是否还存在等。



  • HeartbeatRespHandler是当客户端接收到服务端登录成功的消息后,主动向服务端发送一条心跳消息,心跳消息可以是一个空包,消息包体越小越好,服务端收到客户端的心跳包后,原样返回给客户端,这里,就是收到服务端返回的心跳消息响应的处理handler,比如:HeartbeatRespHandler


这个就比较简单,收到心跳消息响应,无需任务处理,直接打印一下方便我们分析即可。




心跳机制及读写超时机制


心跳包是定期发送,也可以自己定义一个周期,比如Android微信智能心跳方案,为了简单,此处规定应用在前台时,8秒发送一个心跳包,切换到后台时,30秒发送一次,根据自己的实际情况修改一下即可。心跳包用于维持长连接以及检测长连接是否断开等。


接着,我们利用Netty的读写超时机制,来实现一个心跳消息管理handler:
HeartbeatHandler
可以看到,利用userEventTriggered()方法回调,通过IdleState类型,可以判断读超时/写超时/读写超时,这个在添加IdleStateHandler时可以配置,下面会贴上代码。首先我们可以在READER_IDLE事件里,检测是否在规定时间内没有收到服务端心跳包响应,如果是,那就触发重连操作。在WRITER_IDEL事件可以检测客户端是否在规定时间内没有向服务端发送心跳包,如果是,那就主动发送一个心跳包。发送心跳包是在子线程中执行,我们可以利用之前写的work线程池进行线程管理。

addHeartbeatHandler()代码如下:
addHeartbeatHandler
从图上可看到,在IdleStateHandler里,配置的读超时为心跳间隔时长的3倍,也就是3次心跳没有响应时,则认为长连接已断开,触发重连操作。写超时则为心跳间隔时长,意味着每隔heartbeatInterval会发送一个心跳包。读写超时没用到,所以配置为0。


onConnectStatusCallback(int connectStatus)为连接状态回调,以及一些公共逻辑处理:
onConnectStatusCallback
连接成功后,立即发送一条握手消息,再次梳理一下整体流程:



  • 客户端根据服务端返回的host及port,进行第一次连接。

  • 连接成功后,客户端向服务端发送一条握手认证消息(1001)

  • 服务端在收到客户端的握手认证消息后,从扩展字段里取出用户token,到本地数据库校验合法性。

  • 校验完成后,服务端把校验结果通过1001消息返回给客户端,也就是握手消息响应。

  • 客户端收到服务端的握手消息响应后,从扩展字段取出校验结果。若校验成功,客户端向服务端发送一条心跳消息(1002),然后进入心跳发送周期,定期间隔向服务端发送心跳消息,维持长连接以及实时检测链路可用性,若发现链路不可用,等待一段时间触发重连操作,重连成功后,重新开始握手/心跳的逻辑。


看看TCPReadHandler收到消息是怎么处理的:
TCPReadHandler1
TCPReadHandler2
可以看到,在channelInactive()及exceptionCaught()方法都触发了重连,channelInactive()方法在当链路断开时会调用,exceptionCaught()方法在当出现异常时会触发,另外,还有诸如channelUnregistered()、channelReadComplete()等方法可以重写,在这里就不贴了,相信聪明的你一眼就能看出方法的作用。

我们仔细看一下channelRead()方法的逻辑,在if判断里,先判断消息类型,如果是服务端返回的消息发送状态报告类型,则判断消息是否发送成功,如果发送成功,从超时管理器中移除,这个超时管理器是干嘛的呢?下面讲到消息重发机制的时候会详细地讲。在else里,收到其他消息后,会立马给服务端返回一个消息接收状态报告,告诉服务端,这条消息我已经收到了,这个动作,对于后续需要做的离线消息会有作用。如果不需要支持离线消息功能,这一步可以省略。最后,调用消息转发器,把接收到的消息转发到应用层即可。


代码写了这么多,我们先来看看运行后的效果,先贴上缺失的消息发送代码及ims关闭代码以及一些默认配置项的代码。

发送消息:发送消息
关闭ims:关闭ims
ims默认配置:ims默认配置
还有,应用层实现的ims client启动器:
IMSClientBootstrap
由于代码有点多,不太方便全部贴上,如果有兴趣可以下载demo体验。
额,对了,还有一个简易的服务端代码,如下:
NettyServerDemo1
NettyServerDemo2
NettyServerDemo3





作者:FreddyChen
链接:https://juejin.cn/post/6844903815846559757
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

开源一个自用的Android IM库,基于Netty+TCP+Protobuf实现(2)

封装 为什么需要封装呢?说白了,就是为了解耦,为了方便日后切换到不同框架实现,而无需到处修改调用的地方。举个栗子,比如Android早期比较流行的图片加载框架是Universal ImageLoader,后期因为某些原因,原作者停止了维护该项目,目前比较流行的...
继续阅读 »

封装


为什么需要封装呢?说白了,就是为了解耦,为了方便日后切换到不同框架实现,而无需到处修改调用的地方。举个栗子,比如Android早期比较流行的图片加载框架是Universal ImageLoader,后期因为某些原因,原作者停止了维护该项目,目前比较流行的图片加载框架是Picasso或Glide,因为图片加载功能可能调用的地方非常多,如果不作一些封装,早期使用了Universal ImageLoader的话,现在需要切换到Glide,那改动量将非常非常大,而且还很有可能会有遗漏,风险度非常高。


那么,有什么解决方案呢?


很简单,我们可以用工厂设计模式进行一些封装,工厂模式有三种:简单工厂模式、抽象工厂模式、工厂方法模式。在这里,我采用工厂方法模式进行封装,具体区别,可以参见:通俗讲讲我对简单工厂、工厂方法、抽象工厂三种设计模式的理解


我们分析一下,ims(IM Service,下文简称ims)应该是有初始化建立连接重连关闭连接释放资源判断长连接是否关闭发送消息等功能,基于上述分析,我们可以进行一个接口抽象:
抽象的ims接口1
抽象的ims接口2
OnEventListener是与应用层交互的listener:
OnEventListener
IMConnectStatusCallback是ims连接状态回调监听器:
IMConnectStatusCallback


然后写一个Netty tcp实现类:
Netty tcp ims1
Netty tcp ims2


接下来,写一个工厂方法:
ims实例工厂方法


封装部分到此结束,接下来,就是实现了。




初始化


我们先实现init(Vector serverUrlList, OnEventListener listener, IMSConnectStatusCallback callback)方法,初始化一些参数,以及进行第一次连接等:
初始化参数


其中,MsgDispatcher是消息转发器,负责将接收到的消息转发到应用层:
MsgDispatcher


ExecutorServiceFactory是线程池工厂,负责调度重连及心跳线程:
ExecutorServiceFactory1
ExecutorServiceFactory2
ExecutorServiceFactory3




连接及重连


resetConnect()方法作为连接的起点,首次连接以及重连逻辑,都是在resetConnect()方法进行逻辑处理,我们来瞄一眼:
resetConnect
可以看到,非首次进行连接,也就是连接一个周期失败后,进行重连时,会先让线程休眠一段时间,因为这个时候也许网络状况不太好,接着,判断ims是否已关闭或者是否正在进行重连操作,由于重连操作是在子线程执行,为了避免重复重连,需要进行一些并发处理。开始重连任务后,分四个步骤执行:



  • 改变重连状态标识

  • 回调连接状态到应用层

  • 关闭之前打开的连接channel

  • 利用线程池执行一个新的重连任务


ResetConnectRunnable是重连任务,核心的重连逻辑都放到这里执行:
ResetConnectRunnable1
ResetConnectRunnable2
ResetConnectRunnable3


toServer()是真正连接服务器的地方:
toServer


initBootstrap()是初始化Netty Bootstrap:
initBootstrap
注:NioEventLoopGroup线程数设置为4,可以满足QPS是一百多万的情况了,至于应用如果需要承受上千万上亿流量的,需要另外调整线程数。参考自:netty实战之百万级流量NioEventLoopGroup线程数配置


接着,我们来看看TCPChannelInitializerHanlder
TCPChannelInitializerHandler
其中,ProtobufEncoderProtobufDecoder是添加对protobuf的支持,LoginAuthRespHandler是接收到服务端握手认证消息响应的处理handler,HeartbeatRespHandler是接收到服务端心跳消息响应的处理handler,TCPReadHandler是接收到服务端其它消息后的处理handler,先不去管,我们重点来分析下LengthFieldPrependerLengthFieldBasedFrameDecoder,这就需要引申到TCP的拆包与粘包啦。


作者:FreddyChen
链接:https://juejin.cn/post/6844903815846559757
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

开源一个自用的Android IM库,基于Netty+TCP+Protobuf实现(1)

写在前面 一直想写一篇关于im即时通讯分享的文章,无奈工作太忙,很难抽出时间。今天终于从公司离职了,打算好好休息几天再重新找工作,趁时间空闲,决定静下心来写一篇文章,毕竟从前辈那里学到了很多东西。工作了五年半,这三四年来一直在做社交相关的项目,有 直播、 即时...
继续阅读 »

写在前面


一直想写一篇关于im即时通讯分享的文章,无奈工作太忙,很难抽出时间。今天终于从公司离职了,打算好好休息几天再重新找工作,趁时间空闲,决定静下心来写一篇文章,毕竟从前辈那里学到了很多东西。工作了五年半,这三四年来一直在做社交相关的项目,有
直播
即时通讯
短视频分享
社区论坛
等产品,深知即时通讯技术在一个项目中的重要性,本着开源分享的精神,也趁这机会总结一下,所以写下这篇文章,文中有不对之处欢迎批评与指正。


本文将介绍:



  • Protobuf序列化

  • TCP拆包与粘包

  • 长连接握手认证

  • 心跳机制

  • 重连机制

  • 消息重发机制

  • 读写超时机制

  • 离线消息

  • 线程池

  • AIDL跨进程通信


本想花一部分时间介绍一下利用AIDL实现多进程通信,提升应用保活率,无奈这种方法在目前大部分Android新版本上已失效,而且也比较复杂,所以考虑再三,把AIDL这一部分去掉,需要了解的童鞋可以私信我。


先来看看效果:
最终运行效果


不想看文章的同学可以直接移步到Github fork源码:github地址


接下来,让我们进入正题。




为什么使用TCP?


这里需要简单解释一下,TCP/UDP/WebSocket的区别。
这里就很好地解释了TCP/UDP的优缺点和区别,以及适用场景,简单地总结一下:




  • 优点:



    • TCP的优点体现在稳定可靠上,在传输数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制,在数据传完之后,还会断开连接用来节约系统资源。

    • UDP的优点体现在比TCP稍安全,UDP没有TCP拥有的各种机制,是一个无状态的传输协议,所以传递数据非常快,没有TCP的这些机制,被攻击利用的机制就少一些,但是也无法避免被攻击。




  • 缺点:



    • TCP缺点就是效率低占用系统资源高易被攻击,TCP在传递数据之前要先建立连接,这会消耗时间,而且在数据传递时,确认机制、重传机制、拥塞机制等都会消耗大量时间,而且要在每台设备上维护所有的传输连接。

    • UDP缺点就是不可靠不稳定,因为没有TCP的那些机制,UDP在传输数据时,如果网络质量不好,就会很容易丢包,造成数据的缺失。




  • 适用场景:



    • TCP:当对网络通讯质量有要求时,比如HTTP、HTTPS、FTP等传输文件的协议, POP、SMTP等邮件传输的协议。

    • UDP:对网络通讯质量要求不高时,要求网络通讯速度要快的场景。




至于WebSocket,后续可能会专门写一篇文章来介绍。
综上所述,决定采用TCP协议。




为什么使用Protobuf?


对于App网络传输协议,我们比较常见的、可选的,有三种,分别是json/xml/protobuf,老规矩,我们先分别来看看这三种格式的优缺点:




  • 优点:



    • json优点就是较XML格式更加小巧,传输效率较xml提高了很多,可读性还不错。

    • xml优点就是可读性强,解析方便。

    • protobuf优点就是传输效率快(据说在数据量大的时候,传输效率比xml和json快10-20倍),序列化后体积相比Json和XML很小,支持跨平台多语言,消息格式升级和兼容性还不错,序列化反序列化速度很快。




  • 缺点:



    • json缺点就是传输效率也不是特别高(比xml快,但比protobuf要慢很多)。

    • xml缺点就是效率不高,资源消耗过大。

    • protobuf缺点就是使用不太方便。




在一个需要大量的数据传输的场景中,如果数据量很大,那么选择protobuf可以明显的减少数据量,减少网络IO,从而减少网络传输所消耗的时间。考虑到作为一个主打社交的产品,消息数据量会非常大,同时为了节约流量,所以采用protobuf是一个不错的选择。




为什么使用Netty?


首先,我们来了解一下,Netty到底是个什么东西。网络上找到的介绍:Netty是由JBOSS提供的基于Java NIO的开源框架,Netty提供异步非阻塞、事件驱动、高性能、高可靠、高可定制性的网络应用程序和工具,可用于开发服务端和客户端。




  • 为什么不用Java BIO?



    • 一连接一线程,由于线程数是有限的,所以这样非常消耗资源,最终也导致它不能承受高并发连接的需求。

    • 性能低,因为频繁的进行上下文切换,导致CUP利用率低。

    • 可靠性差,由于所有的IO操作都是同步的,即使是业务线程也如此,所以业务线程的IO操作也有可能被阻塞,这将导致系统过分依赖网络的实时情况和外部组件的处理能力,可靠性大大降低。




  • 为什么不用Java NIO?



    • NIO的类库和API相当复杂,使用它来开发,需要非常熟练地掌握Selector、ByteBuffer、ServerSocketChannel、SocketChannel等。

    • 需要很多额外的编程技能来辅助使用NIO,例如,因为NIO涉及了Reactor线程模型,所以必须必须对多线程和网络编程非常熟悉才能写出高质量的NIO程序。

    • 想要有高可靠性,工作量和难度都非常的大,因为服务端需要面临客户端频繁的接入和断开、网络闪断、半包读写、失败缓存、网络阻塞的问题,这些将严重影响我们的可靠性,而使用原生NIO解决它们的难度相当大。

    • JDK NIO中著名的BUG--epoll空轮询,当select返回0时,会导致Selector空轮询而导致CUP100%,官方表示JDK1.6之后修复了这个问题,其实只是发生的概率降低了,没有根本上解决。




  • 为什么用Netty?



    • API使用简单,更容易上手,开发门槛低

    • 功能强大,预置了多种编解码功能,支持多种主流协议

    • 定制能力高,可以通过ChannelHandler对通信框架进行灵活地拓展

    • 高性能,与目前多种NIO主流框架相比,Netty综合性能最高

    • 高稳定性,解决了JDK NIO的BUG

    • 经历了大规模的商业应用考验,质量和可靠性都有很好的验证。




以上摘自:为什么要用Netty开发



  • 为什么不用第三方SDK,如:融云、环信、腾讯TIM?


这个就见仁见智了,有的时候,是因为公司的技术选型问题,因为用第三方的SDK,意味着消息数据需要存储到第三方的服务器上,再者,可扩展性、灵活性肯定没有自己开发的要好,还有一个小问题,就是收费。比如,融云免费版只支持100个注册用户,超过100就要收费,群聊支持人数有限制等等...
融云收费


Mina其实跟Netty很像,大部分API都相同,因为是同一个作者开发的。但感觉Mina没有Netty成熟,在使用Netty的过程中,出了问题很轻易地可以找到解决方案,所以,Netty是一个不错的选择。


好了,废话不多说,直接开始吧。




准备工作



  • 首先,我们新建一个Project,在Project里面再新建一个Android Library,Module名称暂且叫做im_lib,如图所示:


新建项目




  • 然后,分析一下我们的消息结构,每条消息应该会有一个消息唯一id,发送者id,接收者id,消息类型,发送时间等,经过分析,整理出一个通用的消息类型,如下:



    • msgId 消息id

    • fromId 发送者id

    • toId 接收者id

    • msgType 消息类型

    • msgContentType 消息内容类型

    • timestamp 消息时间戳

    • statusReport 状态报告

    • extend 扩展字段


    根据上述所示,我整理了一个思维导图,方便大家参考:
    消息结构

    这是基础部分,当然,大家也可以根据自己需要自定义比较适合自己的消息结构。


    我们根据自定义的消息类型来编写proto文件。
    编写proto文件
    然后执行命令(我用的mac,windows命令应该也差不多):
    执行protoc命令
    然后就会看到,在和proto文件同级目录下,会生成一个java类,这个就是我们需要用到的东东:
    生成的protobuf java类文件
    我们打开瞄一眼:
    打开的protobuf java类文件
    东西比较多,不用去管,这是google为我们生成的protobuf类,直接用就行,怎么用呢?直接用这个类文件,拷到我们开始指定的项目包路径下就可以啦:
    导入protobuf java类文件到项目中
    添加依赖后,可以看到,MessageProtobuf类文件已经没有报错了,顺便把netty的jar包也导进来一下,还有fastjson的:
    导入protobuf以及netty的依赖
    建议用netty-all-x.x.xx.Final的jar包,后续熟悉了,可以用精简的jar包。


    至此,准备工作已结束,下面,我们来编写java代码,实现即时通讯的功能。


作者:FreddyChen
链接:https://juejin.cn/post/6844903815846559757
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

一个小型合作的流水线——Android Handler

当我们遇到多线程的问题,考虑到线程间消息传递的时候,首先想到的肯定是 Handler。虽然写这篇文章的初衷并不是想探究 Handler 的机制,但我们还是先从这个被说烂了的话题开始。 Handler 的工作原理 首先,在了解 Handler 之前,我们需要了解...
继续阅读 »

当我们遇到多线程的问题,考虑到线程间消息传递的时候,首先想到的肯定是 Handler。虽然写这篇文章的初衷并不是想探究 Handler 的机制,但我们还是先从这个被说烂了的话题开始。


Handler 的工作原理


首先,在了解 Handler 之前,我们需要了解有四个关键的类是组成 Handler 的基础。它们分别是



  • Handler 负责协调安排未来某个时间点的消息或可运行状态,以及对不同线程的运行机制进行合理的排队

  • Looper 主要作用如其名,一个循环的机制,为线程运行消息循环分发

  • MessageQueue 一个链式队列数据结构,将消息实体串联成链

  • Message 消息实体,存储我们需要传递的消息的内容和信息等


Looper 和 MessageQueue——Handler 的流水线


ActivityThread 类中,作为入口方法的 main() 方法中,通过调用 Looperloop() 方法,启动 Looper 的循环机制(这里我们注意到,在方法的最后,抛出了一个主线程循环意外退出的异常,说明 Android 的主流程都是通过 Handler 来驱动的)。


/**
* ActivityThread
*/
public static void main(String[] args) {

// ...
Looper.prepareMainLooper();
// ...
Looper.loop();

throw new RuntimeException("Main thread loop unexpectedly exited");
}
复制代码

进入 loop() 方法,这里我们可以看到一个死循环,传说中的死循环这么快就跟我们见面了吗?其实不然,我们平时面试时更关注的死循环并不是这个,或者说它只是其中的一部分。废话先不说,这段代码精简后的大致作用可以归纳为:从 MessageQueue 的对象队列里取出一个未处理的消息,即 Message 实例,然后获取 Message 对象的 target 属性,它是一个 Handler 对象,然后通过 dispatchMessage() 方法来将消息进行分发。


/**
* Looper
*/
public static void loop() {
final MessageQueue queue = me.mQueue;

// Make sure the identity of this thread is that of the local process,
// and keep track of what that identity token actually is.
// 我最开始读到这段源码的时候,很困惑这个方法为什么调用了两遍,后来经过思索想明白了原因,这里稍作记录。
// 这个方法调用的是 native 的代码,源码如下:
// int64_t IPCThreadState::clearCallingIdentity()
// {
// int64_t token = ((int64_t)mCallingUid<<32) | mCallingPid;
// clearCaller();
// return token;
// }
// void IPCThreadState::clearCaller()
// {
// mCallingPid = getpid(); //当前进程pid赋值给mCallingPid
// mCallingUid = getuid(); //当前进程uid赋值给mCallingUid
// }
// 具体作用可以网上自行搜索,这个方法的作用,简而言之,就是将(可能是)其他进程的 pid 和 uid 清除,更换为自己的,
// 而 token 是用来存储原来进程的 pid 和 uid 的64位整型,所以第一遍调用时返回的是之前进程的 pid 和 uid 信息,
// 再次调用时,返回的才是当前进程的,而被我精简掉的源码里需要通过这个 token 来判断进程是否切换过,所以这个方法在这里会调用两遍
Binder.clearCallingIdentity();
final long ident = Binder.clearCallingIdentity();

for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}
try {
msg.target.dispatchMessage(msg);

} catch (Exception exception) {
throw exception;
}

msg.recycleUnchecked();
}
}
复制代码

因为 dispatchMessage() 方法比较简单,所以我们先越过过程看结果,看看这个方法的实现。


/**
* Handler
*/
public void dispatchMessage(@NonNull Message msg) {
if (msg.callback != null) {
handleCallback(msg);
} else {
if (mCallback != null) {
if (mCallback.handleMessage(msg)) {
return;
}
}
handleMessage(msg);
}
}
复制代码

这里就直接调用了 Handler 对象的 handleMessage() 方法,并传递 Message 的实例,所以我们在使用 Handler 时在这个方法中就可以接收到我们需要的消息实体(callback 默认不实现,实现后变更为调用相应的方法)。


好,结果我们已经知道了,那现在我们回过头来,研究一下上面 Looper 类的 loop() 方法中调用的 queue.next() 方法是如何拿到消息实体的(后面的注释已经提醒我们这个方法可能会阻塞)。


/**
* MessageQueue
*/
Message next() {
// Return here if the message loop has already quit and been disposed.
// This can happen if the application tries to restart a looper after quit
// which is not supported.
final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1; // -1 only during first iteration
// 这个变量作为 nativePollOnce 方法的参数表示休眠的时间
// 当值为 -1 时,表示无限休眠,直到有线程唤醒
// 当值为 0 时,表示立即唤醒
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}

// 根据 nextPollTimeoutMillis 变量的值进行休眠
nativePollOnce(ptr, nextPollTimeoutMillis);

synchronized (this) {
// Try to retrieve the next message. Return if found.
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
// 如果 Message 的 target 为 null,则说明它是 Looper synchronization barrier 的临界点
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;and the message is the earliest asynchronous message in the queue
} while (msg != null && !msg.isAsynchronous());
}
// 经过上面的循环后,到达这里的 Message 要么是 null,要么是 isAsynchronous() 方法返回 true
if (msg != null) {
if (now < msg.when) {
// Next message is not ready. Set a timeout to wake up when it is ready.
// 消息的发送时间未到,此时的 nextPollTimeoutMillis 为距离 msg 的发送时间的时间间隔,
// 那 nativePollOnce() 方法休眠相应的时间后,msg 即到了它该发送的时间
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// No more messages.
// 没有更多的消息,此时 nextPollTimeoutMillis 赋值为 -1,
// 那 nativePollOnce() 方法将导致线程永久休眠,直到有其他线程将其唤醒
nextPollTimeoutMillis = -1;
}

// Process the quit message now that all pending messages have been handled.
if (mQuitting) {
dispose();
return null;
}
}
}
}
复制代码

next() 方法看起来很长,但是它的主要工作只有一件事,就是找到符合要求的 Message 实例并返回。但是这个方法又特别重要,有一个常问的重要的面试考点。我们上面已经提到了,Looperloop() 方法中有一个死循环,作用是源源不断地从 MessageQueue 中「打捞」 Message 实体,而「打捞」的动作正是通过 next() 方法完成的。在 next() 方法中,也有一个死循环,完成上面的「打捞」工作。具体的细节我在代码中作了部分注释,可以帮助理解。其中提到了一个概念——「Looper synchronization barrier」,关于它的介绍我们放在下面的内容里。


好了,介绍完了 Handler 机制中的死循环,它是死循环双重嵌套的形式,那么面试问题来了:请问 Handler 机制中的死循环是如何做到不阻塞主线程的呢?网上搜索到的答案通常是死循环也未必会阻塞主线程,只要不在 onCreate()onStart() 等生命周期中阻塞就不会导致界面的卡死,其次在 MessageQueue 中没有 Message 实体时,线程会进入到一个休眠的状态,在有新消息来临时线程才会被唤醒,balabala小魔仙……我们看到 next() 方法的死循环的一开始有一句代码 nativePollOnce(),它是一个 native 的方法,通过执行 Linux 中的 epoll 机制来是线程休眠和运行,它和 nativeWake() 方法配对使用,在类文件的开头均有声明。所以每次在执行完一遍 next() 方法后,都会根据 nextPollTimeoutMillis 变量的值来决定休眠的时间。如果没有可被「打捞」的消息,那么线程将被永久休眠,等待被唤醒。那么在哪里唤醒的呢,我们暂时不管,在这里先记住线程休眠,主线程被阻塞,等待一个白马王子将其唤醒。至于白马王子何时到来,我们静待。


Handler——消息操作台


现在,我们再从消息发送的源头追溯——通过 Handler 的一系列 sendMessage() 方法,将消息发送出去。


我们以 sendEmptyMessage() 方法为例,经过一系列的调用后,最终会执行 enqueueMessage() 方法,该方法又会调用 MessageQueueenqueueMessage() 方法,该方法代码如下:


/**
* MessageQueue
*/
boolean enqueueMessage(Message msg, long when) {

synchronized (this) {

msg.markInUse();
msg.when = when;
Message p = mMessages;
boolean needWake;
if (p == null || when == 0 || when < p.when) {
// New head, wake up the event queue if blocked.
msg.next = p;
mMessages = msg;
needWake = mBlocked;
} else {
// Inserted within the middle of the queue. Usually we don't have to wake
// up the event queue unless there is a barrier at the head of the queue
// and the message is the earliest asynchronous message in the queue.
needWake = mBlocked && p.target == null && msg.isAsynchronous();
Message prev;
for (;;) {
prev = p;
p = p.next;
if (p == null || when < p.when) {
break;
}
if (needWake && p.isAsynchronous()) {
needWake = false;
}
}
msg.next = p; // invariant: p == prev.next
prev.next = msg;
}

// We can assume mPtr != 0 because mQuitting is false.
if (needWake) {
nativeWake(mPtr);
}
}
return true;
}
复制代码

好耶,「白马王子」来了!看到了吧,nativeWake() 方法显真身了,当有新的消息压入队列,消息需要被处理,此时就需要唤醒睡眠的线程。但是「白马王子」


的到来是需要条件的,即 needWake,那到底是怎样的条件呢?想想无非是判断当前的线程是否处于可能阻塞的状态,我们来看看。


在第一个条件 p == null || when == 0 || when < p.when 下,相比于罗列所有的满足条件的情况,更简单的方法是判断我们前面的线程被阻塞的情况是不是在这里被判定为 needWake, 因为在等待新的消息,所以 mMessage 值为 null,此时的 needWake = mBlocked,而 mBlocked 的线程被阻塞的情况下值是为 true 的,所以这里会被判定为需要被唤醒。而在 else 分支中,其实条件为p != null && when != 0 && when >= p.when,这说明消息队列中的消息并没有被取完,而是正在一个循环中,通常情况下是不需要再唤醒它,除非像注释中所说的 there is a barrier at the head of the queue and the message is the earliest asynchronous message in the queue


到这里,Handler 的大概工作流程就可以串联起来了——循环队列相当于物流,消息相当于商品,物流无时无刻在运转,当你需要新的商品时,商品被商家发送至物流,然后分发到目标客户即你的手中。


Looper synchronization barrier


在看源码的时候,不止一次会接触到这个概念,而且在上面我们也已经率先使用了这个概念,那么这个概念到底是个什么?搞清楚这个问题,我们需要从它的特征入手,在 MessageQueuenext() 方法中,我们说如果 mMessages.target == null,那么它就是一个 barrier 的临界点,我们通过查找 mMessage 的写引用,最终定位到 MessageQueue#postSyncBarrier() 这个方法。我这里摘录它的注释,相信大家对这个概念就会有一个清晰的认识。



Posts a synchronization barrier to the Looper's message queue.


Message processing occurs as usual until the message queue encounters the synchronization barrier that has been posted. When the barrier is encountered, later synchronous messages in the queue are stalled (prevented from being executed) until the barrier is released by calling {@link #removeSyncBarrier} and specifying the token that identifies the synchronization barrier.


This method is used to immediately postpone execution of all subsequently posted synchronous messages until a condition is met that releases the barrier. Asynchronous messages (see {@link Message#isAsynchronous} are exempt from the barrier and continue to be processed as usual.


This call must be always matched by a call to {@link #removeSyncBarrier} with the same token to ensure that the message queue resumes normal operation. Otherwise the application will probably hang!



在了解这个概念之前还需要知道一个属性的存在,那就是 Message#isAsynchronous()


好了,总结一下就是 Looper synchronization barrierMessageQueue 中那些 target == nullMessage,它们不需要被发送,只作为一种队列状态的判断标识。当 Message.isAsynchronous() == true 时,遇到 Looper synchronization barrier 时,Looper 会被阻塞,直到 removeSyncBarrier() 方法(和 postSyncBarrier() 方法成对使用)移除这个标识。但是如果 Message.isAsynchronous() == false 时,则不会被 barrier 阻断,具体使用场景见上方注释。


太多的代码和解说赶不上一张图片更能让人形成概念,那我从网上找了一张图片稍作加工,希望可以比较形象地说明 Handler 机制中各个类之间的分工。



映射关系


为了话题的自然过渡,这里我们思考一个问题,一个线程可以有多个 Looper 吗?一个 Looper 可以对应多个 MessageQueue 吗?从源码中看,一个线程是无法创建多个 Looper 和多个 MessageQueue 的,那么多个 LooperMessageQueue 会导致什么问题呢?最主要的就是我们上面说的消息同步性的问题了,多个消息队列和循环体如何保证消息的次序限制以及同步分发就是一个很复杂的问题。那么系统又是如何保证每个线程的 Looper 的唯一性的呢?那就是使用 ThreadLocal 了。


ThreadLocal


由于本篇内容旨在讨论 Handler 的相关机制,所以对于 ThreadLocal 的机制不做过多讨论。


Looper#prepare() 方法在 Looper 使用前必须调用,在这个方法里可以看到 ThreadLocal 的应用。


/**
* Looper
*/
static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();

private static void prepare(boolean quitAllowed) {
if (sThreadLocal.get() != null) {
throw new RuntimeException("Only one Looper may be created per thread");
}
sThreadLocal.set(new Looper(quitAllowed));
}
复制代码

sThreadLocal 对象是一个全局的静态对象,通过使用 sThreadLocal#set() 方法来存储 Looper 的实例,而 ThreadLocal 把真正的对象存储交给了它的静态内部类 ThreadLocalMap,这是一个自定义的 hash map,具体内部实现请自行阅读源码。


/**
* ThreadLocal
*/

public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
复制代码

可以看到,ThreadLocalMap 又和 Thread 绑定,每个 Thread 对应一个唯一的 ThreadLocalMap 实例, ThreadLocalMapkey 的类型是 ThreadLocal,而在 Looper 中的 sThreadLocal 作为静态对象,进程内唯一,通过这样的关系,可以唯一对应到 TreadLocalMap 中的某个元素,实现读取。


碎碎念


前面两个月经历找工作和工作后的一堆琐事,导致很久没有更新。这篇也是匆忙赶工,逻辑上和图文代码编排上都有一些问题,还请多多包涵。之前做的是 Flutter 的工作,现在又回到了 Android,Flutter 的内容也会继续带着更新,后面我会尽量保持正常的更新频率,但是水平确实有限,最后还是请大家雅正和包涵。


作者:Sandfone
链接:https://juejin.cn/post/6926698513082351623
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Jetpack Compose 初体验(二)

Jetpack Compose 初体验(一)二、主题 基本布局已经差不多啦,那么我们再来搞一些共性的东西,就像我们黄种人都有一样的肤色——散在土地里的黄,有种顽强,非常东方…… 以前的 View 系统其实也有关于 theme 的定义,那些被定义的 style,...
继续阅读 »

Jetpack Compose 初体验(一)

二、主题


基本布局已经差不多啦,那么我们再来搞一些共性的东西,就像我们黄种人都有一样的肤色——散在土地里的黄,有种顽强,非常东方……


以前的 View 系统其实也有关于 theme 的定义,那些被定义的 style,在官方定义的一系列 theme 的基础上加以扩展,形成我们 app 的主题。


Compose 框架提供了 Material Design 的实现,Material Design Theme 自然也被应用到 Compose 中,Material Design Theme 包括了对颜色、文本样式和形状等属性的定义,咱们自定义这些属性后,包括 button、cards、switches 等控件都会相应的改变它们的默认样式。


1.颜色


颜色在前端开发中真的是无处不在了,Color 可以帮助我们快速地构建颜色模型。


你可以泡着吃:


val red = Color(0xffff0000)
复制代码

可以扭着吃:


val blue = Color(red = 0f, green = 0f, blue = 1f)
复制代码

欸,你还可以干吃:


val black = Color.Black
复制代码

只要你喜欢,你甚至可以空翻360度加转体一周半的时候吃:


// 我不会空翻,也不会转体,期待你的表现,加油!
复制代码

Compose 提供了 Colors数来创建成套的浅色或深色:


val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)

private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200,
onPrimary = Color.Green
)

private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant Customize= Purple700,
secondary = Teal200,
onPrimary = Color.Green

/* Other default colors to override
background = Color.White,
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.Black,
onBackground = Color.Black,
onSurface = Color.Black,
*/
)
复制代码

然后,就可以传递给 MaterialTheme 使用喽:


@Composable
fun TestComposeTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}

MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}
复制代码

怎么样,还自动适配深色模式。


而且,我们也可以随时随地获取到主题色:


Text(
text = "Hello theming",
color = MaterialTheme.colors.primary
)
复制代码

表面颜色和内容颜色又是另一个概念了,许多组件都接受一对颜色和「内容颜色」:


Surface(
color: Color = MaterialTheme.colors.surface,
contentColor: Color = contentColorFor(color),


TopAppBar(
backgroundColor: Color = MaterialTheme.colors.primarySurface,
contentColor: Color = contentColorFor(backgroundColor),

复制代码

这样一来,您不仅可以设置可组合项的颜色,而且还能为包含在可组合项中的内容提供默认颜色。默认情况下,许多可组合项都使用这种内容颜色。例如,Text 的颜色基于其父项的内容颜色,而 Icon :「俺也一样」,它可以使用该颜色来设置其色调。


contentColorFor() 方法可以为任何主题颜色检索适当的“on”颜色。例如,如果您设置 primary 背景,就会将 onPrimary 设置内容颜色。如果您设置非主题背景颜色,还应指定合理的内容颜色。使用 LocalContentColor 可检索与当前背景形成对比的当前内容颜色。


我们以上面自定义的 Theme 来试验,使用它作为我们的主题:


@Preview
@Composable
fun TestColor() {
TestComposeTheme {
Button(onClick = {}) {
Text(
"hello world"
)
}
}
}Customize
复制代码

效果:


image-20210429183615573.png


2.字体排版


字体排版主要通过 TypographyTextStyle 类来完成。Typography 构造函数可以提供每种样式的默认值,因此您可以省略不希望自定义的任何样式:


val Rubik = FontFamily(
Font(R.font.rubik_regular),
Font(R.font.rubik_medium, FontWeight.W500),
Font(R.font.rubik_bold, FontWeight.Bold)
)

val MyTypography = Typography(
h1 = TextStyle(
fontFamily = Rubik,
fontWeight = FontWeight.W300,
fontSize = 96.sp
),
body1 = TextStyle(
fontFamily = Rubik,
fontWeight = FontWeight.W600,
fontSize = 16.sp
)
/*...*/
)
MaterialTheme(typography = MyTypography, /*...*/)
复制代码

如果您希望自始至终使用同一字体,请指定 defaultFontFamily 参数,并省略所有 TextStyle 元素的 fontFamily


val typography = Typography(defaultFontFamily = Rubik)
MaterialTheme(typography = typography, /*...*/)
复制代码

使用时,可以从主题检索 TextStyle,如以下示例所示:


Text(
text = "Subtitle2 styled",
style = MaterialTheme.typography.subtitle2
)
复制代码

3.形状


Compose 中可以轻松地定义各种形状,比如圆角或者操场跑道形状,在传统 View 系统中实现都比较麻烦。


我们现在修改一下上面的 Button 的形状来看看效果:


val Shapes = Shapes(
small = CutCornerShape(
topStart = 16.dp,
topEnd = 0.dp,
bottomStart = 16.dp,
bottomEnd = 0.dp
),
medium = RoundedCornerShape(percent = 50),
large = RoundedCornerShape(0.dp)
)
复制代码

image-20210429192726472.png


这里有一点需要注意的是,默认情况下,许多组件使用这些形状。例如,Button、TextField 和 FloatingActionButton 默认为 small,AlertDialog 默认为 medium,而 ModalDrawerLayout 默认为 large。如需查看完整的对应关系,请参阅形状方案参考文档。


三、列表


列表也是个常见的家伙,Android View 系统中早期的 ListView 和后来的 RecyclerView, Flutter 里的 ListView 等。


一个列表就是许多个元素排排站,整齐笔直。那一个纵向(或横向)的布局中动态地添加进许多的元素不就好了。


@Composable
fun MessageList(messages: List) {
Column {
messages.forEach { message ->
MessageRow(message)
}
}
}
复制代码

来,你猜,RecyclerView 是不是这么写的。这里有个最大的问题,假如你是个交际花,好友从这里排到法国,列表多到滑一晚上滑不到头,那么一次加载是不是要耗费巨大的资源,搞不好卡死了王思聪联系不上你那就太不给面了,不好。


RecyclerView 最大的一个优点是它可以懒加载列表项,一次只加载一个屏幕的条目(四舍五入就是我对)。Compose 中可没有 RecyclerView,但是同样有针对这一问题优化的组件,LazyColumnLazyRow 是垂直和水平方向的懒加载列表控件。我们先来看一下效果:


@Preview
@Composable
fun TestList() {
LazyColumn(modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Add a single item
item {
Text(text = "First item")
}

// Add 5 items
items(10000) { index ->
Text(text = "Item: $index")
}

// Add another single item
item {
Text(text = "Last item")
}
}
}
复制代码

这里加载了一万个元素的列表,看看这丝滑的效果吧(建议就着德芙食用)。


lazy_column.gif


我们还可以像上面一样,通过 contentPadding 设置内容边距,verticalArrangement 则是可以设置 item 间间距,以及均匀地排列元素以充满父空间。


比较遗憾地是 LazyColumnLazyRow 暂时无法设置例如添加元素时地动画,期待后续的加入吧。


LazyColumn 可以轻松地实现粘性标题,只需使用 stickyHeader() 函数即可:


// TODO: This ideally would be done in the ViewModel
val grouped = contacts.groupBy { it.firstName[0] }

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(grouped: Map>) {
LazyColumn {
grouped.forEach { (initial, contactsForInitial) ->
stickyHeader {
CharacterHeader(initial)
}

items(contactsForInitial) { contact ->
ContactListItem(contact)
}
}
}
}
复制代码

上面的代码演示了如何通过 Map 数据结构实现粘性标题的数据展示。


既然有列表,那么肯定会有宫格列表,LazyVerticalGrid 则能够帮助我们实现需求。更多用法查看相关 API(没错,我就是 LazyBoy,但我的尊严决定了我不会滚)。


在实际项目开发中,我们经常会遇到将数据分页展示的情况,以减少数据请求压力。借助 Paging 3.0 库 可以来进行分页,Paging 库是 Jetpack 中重要的一项新特性,可帮助您一次加载和显示多个小的数据块。按需载入部分数据会减少网络带宽和系统资源的使用量。


如需显示分页内容列表,可以使用 collectAsLazyPagingItems() 扩展函数,然后将返回的 LazyPagingItems 传入 LazyColumn 中的 items()。与视图中的 Paging 支持类似,您可以通过检查 item 是否为 null,在加载数据时显示占位符:


import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.items

@Composable
fun MessageList(pager: Pager) {
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

LazyColumn {
items(lazyPagingItems) { message ->
if (message != null) {
MessageRow(message)
} else {
MessagePlaceholder()
}
}
}
}

作者:Sandfone
链接:https://juejin.cn/post/6958979036181692424
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Jetpack Compose 初体验(一)

你是否受够了 Android 中 UI 编写的体验——在 xml 文件中编写复杂的层级结构和繁多的属性,动态化的视图逻辑又被分裂到 Activity 中?哦,这该死的友好度和割裂感! 这两年,Flutter 大行其道,不论是网上的讨论度还是实际的落地项目,风头...
继续阅读 »

你是否受够了 Android 中 UI 编写的体验——在 xml 文件中编写复杂的层级结构和繁多的属性,动态化的视图逻辑又被分裂到 Activity 中?哦,这该死的友好度和割裂感!


这两年,Flutter 大行其道,不论是网上的讨论度还是实际的落地项目,风头一时无两。所以从这个角度来说,作为 UI 框架的 Flutter,无疑是成功的。本着借鉴的思想(或许吧,谁知道呢),Android 在 Jetpack 项目中新增了一套全新的视图开发套件——Compose。它有着和 Flutter 一样好看的(姑且这么认为吧)外表,但究竟只是一个好看的花瓶还是才貌双全,这得我们自己去寻找答案。


Compose 当前还处于测试版本,想要使用它,我们需要首先下载 Android studio 的 canary 版本以提供支持。你可以在这里下载或者在你现有的 Android studio 中打开 File -> Settings -> Appearance & Behavior -> System Settings -> Updates 菜单,然后切换到 canary 渠道再点击 Check Now 按钮即可更新到最新的 canary 版本。


一、布局


1.可组合函数


使用 Compose 创建一个界面是简单的,只需通过 @Composable 注解定义一个可组合函数,在函数中返回界面元素组件即可。


@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
复制代码

该函数可以接收参数。函数内可以是一个组件,也可以是多个组件的组合。


通过 setContent 方法块可以设置页面内容,类似于之前的 setContentView() 方法。


setContent {
Text(text = "Hello Compose!")
}
复制代码

相比于之前的界面书写过程,Compose 更「神奇」的一个体现在于它可以直接在 Android studio 中预览我们编写的界面和组件,而无需让程序运行在设备中。


我们只需要在可组合函数的基础上再新增一个 @Preview 注解,但是需要注意的是,预览函数不接受参数,所以比较好的做法是在可组合函数的基础上编写其对应的预览函数。


@Preview
@Composable
fun DefaultPreview() {
Greeting("Android")
}
复制代码

预览函数对你的应用在设备上的最终呈现不会产生影响,Android studio 提供了一个预览窗口可以实时看到预览函数所呈现的效果。


image-20210412225335487.png


2.布局


我们编写的应用界面几乎任何时候都不会是简简单单的单一的控件,而是一定数量的独立控件在空间上的一种组合。


首先,我们就盲猜,如果我想竖直方向排列三个文字组件,肯定不是像下面这样随便组合三个 Text 控件。它怎么可能那么聪明,能知道你是想横着排还是竖着排,想并排排还是旋转开。怎么可能有人比苏菲更懂你!


@Composable
fun VerticalText() {
Text("Hello World!")
Text("Hello Again World!")
Text("How old are you, World!")
}
复制代码

image-20210413003422129.png


那,就组合喽。


@Composable
fun VerticalText() {
Column {
Text("Hello World!")
Text("Hello Again World!")
Text("How old are you, World!")
}
}
复制代码

给三个 Text 约定个竖框框,它们就能乖乖地排起队。


image-20210413005809783.png


这里,悄摸摸地说一句,这要是没有偷瞄 Flutter 的考卷 向优秀的思想借鉴,我把三个 Text 布局在我脑门上!


当然,只有这么生硬的排列可不行,我们还需要加点属性,使得整个布局更和谐点——例如,加点边距。


我们希望给 Column 加一个内边距,那么我们就应该给 Column 添加一个属性。Modifier 类用来给组件添加装饰或者行为,如背景、边距、点击事件等。


@Preview(showBackground = true)
@Composable
fun VerticalText() {
Column(
modifier = Modifier.padding(16.dp)
) {
Text("Hello World!")
Text("Hello Again World!")
Text("How old are you, World!")
}
}
复制代码

image-20210413210728702.png


现在,为了让界面看起来不那么单调,我们给这个界面加上下面这一张图片。


![](Compose 初体验.assets/hello_world_new_black.png)


将这张图片拷贝到 drawable 资源文件夹下面,然后通过下面的方式引用。


@Preview(showBackground = true)
@Composable
fun VerticalText() {
Column(
modifier = Modifier.padding(16.dp)
) {
Image(
painter = painterResource(id = R.drawable.hello_world_new_black),
contentDescription = null
)
Text("Hello World!")
Text("Hello Again World!")
Text("How old are you, World!")
}
}
复制代码

Image 的其中一个构造函数支持以下参数,其中 painter 参数和 contentDescription 参数没有默认值,为必传参数。


image-20210420220142848.png


这样,图片就被构造出来啦,看一下效果:


image-20210420220432840.png


那怎么该对图片进行一些约束呢?作为一个头图,我不希望它这么哗众取宠,做图片要低调一点。


在上面,我们认识了 Modifier,那就寻求它的帮助,让我们的图片小一些吧。


Image(
painter = painterResource(id = R.drawable.hello_world_new_black),
contentDescription = null,
modifier = Modifier
.width(126.dp)
.height(62.dp),
contentScale = ContentScale.Inside
)
复制代码

借助 Modifier 将图片的高度和宽度分别进行限定。然后通过 contentScale 参数对图片的缩放方式进行约束。ContentScale.Inside 保持图片比例不变的情况下尽可能地充满父控件的体积。


把上面的 Image 放入 preview 方法,看一下效果:


image-20210420221651702.png


现在头图就被我们拿捏得死死的,但是它还不是很好看,没脖子,加个脖子。


@Preview(showBackground = true)
@Composable
fun VerticalText() {
Column(
modifier = Modifier.padding(16.dp)
) {
Image(
painter = painterResource(id = R.drawable.hello_world_new_black),
contentDescription = null,
modifier = Modifier
.width(126.dp)
.height(62.dp),
contentScale = ContentScale.Inside
)

Spacer(modifier = Modifier.height(16.dp))

Text("Hello World!")
Text("Hello Again World!")
Text("How old are you, World!")
}
}
复制代码

image-20210420222219906.png


这样是不是好看多了,嗯,是的。


3.Material Design


谷歌霸霸的产品当然是支持 Material Design 的,那咱就看看。


做头图不要锋芒毕露,做图处事要圆滑一点。给头图加个圆角是个不错的想法。


在 Android 传统的 UI 编写中,圆角图片一直没有很简单的解决方案,需要通过诸如自定义 ImageView 的方式来实现。但是,朋友们,当你使用 Compose 框架的时候,只需要一行代码就可以圆角图片的显示!家祭无忘告乃翁。


@Preview(showBackground = true)
@Composable
fun VerticalText() {
Column(
modifier = Modifier.padding(16.dp)
) {
Image(
painter = painterResource(id = R.drawable.hello_world_new_black),
contentDescription = null,
modifier = Modifier
.width(126.dp)
.height(62.dp)
.clip(shape = RoundedCornerShape(4.dp)),
contentScale = ContentScale.Inside
)

Spacer(modifier = Modifier.height(16.dp))

Text("Hello World!")
Text("Hello Again World!")
Text("How old are you, World!")
}
}
复制代码

这里还是通过 Modifier 来实现需求,怎么样,现在的头图是不是圆滑可爱了很多。


image-20210420223334597.png


头图这么求上进,文字也不能落后,一篇好的文章要主次分明,错落有致。


声明 Typography 对象,然后给 Text 添加 style 属性,来控制文字的样式。


@Preview(showBackground = true)
@Composable
fun VerticalText() {
val typography = MaterialTheme.typography
Column(
modifier = Modifier.padding(16.dp)
) {
Image(
painter = painterResource(id = R.drawable.hello_world_new_black),
contentDescription = null,
modifier = Modifier
.width(126.dp)
.height(62.dp)
.clip(shape = RoundedCornerShape(4.dp)),
contentScale = ContentScale.Inside
)

Spacer(modifier = Modifier.height(16.dp))

Text("Hello World!", style = typography.h3)
Text("Hello Again World!", style = typography.body1)
Text("How old are you, World!", style = typography.body2)
}
}
复制代码

Typography 提供如下预设属性,囊括标题、子标题、段落体、按钮等。


image-20210420225524291.png


最终效果如下:


image-20210420225406851.png


怎么样,是不是主次开始变得分明了?结构变得清晰了?情节展开得顺滑了?故事开始自然了?……


当然,其他的诸如最大行数、字体、对齐方式等都可以被配置。


作者:Sandfone
链接:https://juejin.cn/post/6958979036181692424
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

HashMap原理底层剖析

注意以下文章可能有描述和理解上的错误,如果出现错误请到评论区指出,我会第一时间修改问题。也希望文章能解决你的疑惑。 HashMap结构图 HashMap底层数据结构:Entry数组+链表+红黑树(JDK1.8版本) Entry+链表(JDK1.7版本)...
继续阅读 »



注意以下文章可能有描述和理解上的错误,如果出现错误请到评论区指出,我会第一时间修改问题。也希望文章能解决你的疑惑。



HashMap结构图


HashMap底层数据结构:Entry数组+链表+红黑树(JDK1.8版本) Entry+链表(JDK1.7版本)
在这里插入图片描述




代码分析


常见的参数及意义


	//默认的Hash表的长度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//Hash表的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当链表的长度为8的时候转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
//桶中元素个数小于6的时候红黑树转换为链表
static final int UNTREEIFY_THRESHOLD = 6;
//只有当数组的长度大于等于64并且链表个数大于8才会转换为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
//Hash表
transient Node<K,V>[] table;
//遍历的时候使用返回一个K-V集合
transient Set<Map.Entry<K,V>> entrySet;
//表中K-V的个数
transient int size;
//对集合的修改次数,主要是后面出现的集合校验
transient int modCount;
//阈值当size大于threshold时就会进行resize
int threshold;
//加载因子
final float loadFactor;

源码解释


构造方法


//传入初始化容量,和指定的加载因子
public HashMap(int initialCapacity, float loadFactor) {
//参数校验
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果传入的值大于最大容量,就将最大的值赋给他
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//参数校验
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//返回的是2的整数次幂
this.threshold = tableSizeFor(initialCapacity);
}

//指定HashMap的容量
public HashMap(int initialCapacity) {
//调用如上的双参构造函数
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

//无参构造函数
public HashMap() {
//初始化加载因子为默认的加载因子
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

//构造一个映射关系与指定 Map 相同的新 HashMap。
public HashMap(Map<? extends K, ? extends V> m) {
//初始化加载因子为默认的加载因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
//构造的过程函数
putMapEntries(m, false);
}

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
//获取m集合中元素个数
int s = m.size();
//如果m集合元素个数是0个那么下面这些操作也就没有必要了
if (s > 0) {
if (table == null) { //表示的拷贝构造函数调用putMapEntries函数,或者是构造了HashMap但是还没有存放元素
//计算的值存在小数所以+1.0F向上取整
float ft = ((float)s / loadFactor) + 1.0F;
//将ft强制转换为整形
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
//如果计算出来的值大于当前HashMap的阈值更新新的阈值为2次方
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)//如果Map集合元素大于当前集合HashMap的阈值则进行扩容
resize();
//将Map集合中元素存放到当前集合中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}

size函数


 //返回key-val的数量
public int size() {
return size;
}

isEmpty函数


   //当前的集合是否为null
public boolean isEmpty() {
return size == 0;
}

get具体过程函数


//根据key获取对应的val
public V get(Object key) {
Node<K,V> e;
//通过hash值,key找到目标节点再返回对应的val
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

//获取key对应的节点
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果集合为空和对应的下标数组中的值为空直接返回null
//first = tab[(n - 1) & hash]数组的长度是2n次方减1后对应位全部变为1,这样为与操作永远都会在数组下标范围内不会越界
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // 如果第一个节点hash与对应hash相等,并且key也相等则返回当前节点
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//第一个节点的下一个节点不为null
if ((e = first.next) != null) {
//判断节点是否为树形
if (first instanceof TreeNode)
//在树形结构中查找节点并返回
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {//通过do...while结构遍历找对应key的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//找到节点并返回
return e;
} while ((e = e.next) != null);
}
}
//未找到对应的节点
return null;
}

containsKey函数


	//查看是否包含指定key    
public boolean containsKey(Object key) {
//通过getNode返回是否为null判断是否存在key
return getNode(hash(key), key) != null;
}

put函数


在此之前先看一下put的过程
在这里插入图片描述


//调用putVal向当前集合中存放元素并返回对应的val
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

//存放对应的key-val
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果当前集合为null则将集合扩容并且将新的存放结构赋值给tab
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//找到key存放的链表,如果为空直接将当前节点存放链表在第一个位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else { //当前为链表不为null
Node<K,V> e; K k;
//表示当前链表第一个位置key已经存在,将当前节点赋值给e
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//查看当前的节点是否属于树形结构如果是则在TreeNode中查找并将赋值给e
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
//找到当前存放位置节点的最后一个节点的next并将当前要插入的节点插入
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // 链表的长度为8的时候转化为红黑树减一是因为元素从0开始
treeifyBin(tab, hash);
//跳出死循环
break;
}
//表示的是当前链表已经存在当前要插入的key,HashMap不存在重复的key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//将节点后移
p = e;
}
}
if (e != null) { // 当前节点不为null将e.val存放在oldValue
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)//不管oldValue是否为null都会发生value赋值给e.value
//当出现重复的key之后上面会将节点保存给e并未修改新的val值,在此更新
e.value = value;
//将结点向后调整到最后面
afterNodeAccess(e);
//如果为null返回null,不为null返回对应的val
return oldValue;
}
}
//++modCount对其集合操作的次数+1
++modCount;
if (++size > threshold)//如果在放入元素以后大于阈值则进行2倍扩容
resize();
afterNodeInsertion(evict);
return null;
}


resize函数


 //将集合扩容
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//旧表的容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//之前的阈值
int oldThr = threshold;
int newCap, newThr = 0;
//这里也可以说集合不为空
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {//如果集合现在数组的长度大于等于最大容量
threshold = Integer.MAX_VALUE;//将整型最大的值赋值给threshold
return oldTab;
}
//当前集合数组长度扩大二倍赋值给newCap小于MAXIMUM_CAPACITY
//并且集合的容量大于等于默认容量将当前阈值扩大二倍赋值给新的阈值
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//若没有经历过初始化,通过构造函数指定了initialCapcity,将当前容量设置为大于它最小的2的n次方
else if (oldThr > 0)
newCap = oldThr;
else { // 初始的时候长度和阈值都使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//重新计算threshold
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新当前集合阈值
threshold = newThr;
//从这里开始便是将oldTab数据重新hash放入扩容后的newTab
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将table指向的oldTab指向newTab
table = newTab;
if (oldTab != null) {
//遍历哈希表
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//当前链表是否为null、并且将就链表赋值给e
if ((e = oldTab[j]) != null) {
oldTab[j] = null;//将原来位置的链表置为null方便垃圾回收
if (e.next == null)//链表的长度为1直接将链表中的一个节点重新hash存放到相应的位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) //表示节点类型为树形结构
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { //链表是非树形结构,并且节点数量是大于1
//将链表拆分为两个子链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do { //通过do...while遍历链表
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null) //设置头节点
loHead = e;
else //设置尾结点
loTail.next = e;
loTail = e;//将尾结点变为最后一个节点
}
else {
if (hiTail == null)//同上都是设置头节点下面也一样是设置尾结点
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {//在新表的j位置存放链表
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {//在新表的j+oldCap位置存放链表
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

remove函数


 	// 移除指向key返回对应的val          
public V remove(Object key) {
Node<K,V> e;
//返回如果为空返回null否则返回e.val
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//常规的判断表不为null,key有对应的存储位置
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//表示的是key存储在当前链表的第一个位置
node = p;
else if ((e = p.next) != null) {//表示的是链表的长度大于1
if (p instanceof TreeNode)//判断是否是树的实列
//返回对应key在红黑树存储的位置
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {//当前结构为链表
do {//遍历链表
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {//找到对应的节点保存并跳出循环
node = e;
break;
}
//将节点后移
p = e;
} while ((e = e.next) != null);
}
}
//表示要删除的key存在并且找到
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)//如果是树形在树型结构中移除当前节点
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)//表示的链表中的第一个节点
tab[index] = node.next;
else
p.next = node.next;//移除节点
++modCount;//操作+1
--size;//长度-1
afterNodeRemoval(node);
//返回节点
return node;
}
}
return null;
}

clear函数


//清除集合中的所有key-value      
public void clear() {
Node<K,V>[] tab;
//集合操作+1
modCount++;
if ((tab = table) != null && size > 0) {//表不为null才进行遍历
size = 0;
for (int i = 0; i < tab.length; ++i)//遍历集合所有元素都置为null,方便垃圾回收
tab[i] = null;
}
}

containsValue函数


 	//查看集合是否包含指定value
public boolean containsValue(Object value) {
Node<K,V>[] tab; V v;
if ((tab = table) != null && size > 0) {//表不为null
for (int i = 0; i < tab.length; ++i) {//遍历数组
for (Node<K,V> e = tab[i]; e != null; e = e.next) {//遍历链表
if ((v = e.value) == value ||
(value != null && value.equals(v)))
//存在指定的value直接返回true
return true;
}
}
}
//集合中不存在指定value返回false
return false;
}

keySet函数


	//返回key的所有集合set
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new KeySet();
keySet = ks;
}
return ks;
}

values函数


	//返回所有的value集合    
public Collection<V> values() {
Collection<V> vs = values;
if (vs == null) {
vs = new Values();
values = vs;
}
return vs;
}

entrySet函数


   // 返回所有的key-value集合
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

面试常见的问题



  1. 为什么HashMap默认的长度为2的整数次幂?



    就是因为获取索引h&(length-1)可以保证散列的均匀,避免不必要的hash冲突。



  2. 为什么加载因子是0.75?大了会怎么样?小了会怎么样?



    首先加载因子是表示hash表的填满程度,当为0.75的时候是在对提高空间利用率和减少查询成本的折中,当大于0.75的时候填满的元素越多,空间利用率越高,但是冲突的概率变大;当小于0.75的时候填满的元素越少,空间利用率越低,但是冲突的概率变小。



  3. 什么是哈希冲突?如何解决?



    哈希冲突是指hash出来的地址被其他元素所占用;
    解决的方法
    1.链地址法
    解决的思路就是当出现冲突的时候将冲突的元素加入当前的链表之中
    在这里插入图片描述
    2.开放地址法
    开放地址法也称之为再散列。
    思路:如果映射的地址被占用了,在哈希函数值的基础上加上指定数值,这样就可以把冲突的地址给错开,然后重新开辟新的地址用来存储。根据增量值的不同,分为线性探测再散列和二次探测再散列
    在这里插入图片描述
    3.再哈希法
    这种方法就是构造多个不同的哈希函数,当哈希地址Hi=RH1(Key)发生冲突时,再计算Hi=RH2(Key)…直到哈希不冲突,这样的方法增加了计算的时间。
    4.建立公共溢区
    就是哈希表分成了两个表:一个是基础表,另外一个则是溢出表,凡是与基础表发生冲突的数据都会被添加到溢出表。



  4. 什么是扰动函数?怎么设计的?为什么这个设计?



    扰动函数是hash函数拿到k的hashcode值,这个值是一个32位的int,让高16位与低16位进行异或。
    理论上来说字符串的hashCode是一个int类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个hashCode的取值范围是[-2147483648, 2147483647],有将近40亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。
    混合原始哈希码的高位和低位,以此来加大低位的随机性,这样设计在一定的程度上减少了hash碰撞,优化了散列的效果 。



  5. JDK1.8在对HashMap较1.7有什么优化?



    1.首先是最重要的就是底层的数据结构,1.7的时候底层数据结构是数组+链表;而在1.8的时候变成了数组+链表+红黑树
    2.在哈希上1.7扰动四次,1.8做了一次扰动,可以提高效率
    3.1.7在进行resize扩容的时候是重新哈希,1.8的时候采用的是索引位置不变或者就是就哈希表的容量+当前索引。
    4.1.7采用插入方式是头插法,1.8采用的是尾插法。



  6. 为什么1.8扩容不用重新哈希?
    在这里插入图片描述


  7. HashMap线程安全吗?为什么不安全?怎么解决不安全?



    首先HashMap是线程不安全的。JDK1.7的时候采用头插法,多线程同时插入的时候,A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环。JDK1.8采用尾插法,会造成两种情况两个线程同时插入只有一个成功插入,还有就是可能会造成两次resize(++size > threshold) 。解决的方案:一、使用HashTable效率比较差。二、使用ConcurrentHashMap比较常用的。三、使用Collections.synchronizedMap() 以上三种线程安全。



  8. HashMap内部节点是有序的吗?



    不是有序的。有序的Map集合有LinkedHashMap、TreeMap



  9. HashMap一般采用什么作为key?



    HashMap一般采用String、Integer 等类作为key、因为这些类底层已经重写了hashcode、equals方法,用的是final修饰类在多线程情况下相对安全。



  10. 为什么重写equals还要重写hashcode?



    比如HashMap中不允许存在相同的key,当重写了equals方法没有重写hashcode方法,当两个对象中的值相同,但是他们hashcode不同会造成比如
    class newInstance1 = new class(1);
    class newInstabce2 = new class(2);
    以上的比较对象的时候hashcode不同,equal方法比较返回false;但是重写Hashcode后,可以达到返回true。





如果看完觉得得到帮助就留下你的一键三连,谢谢 注意如果有错误的地方评论区提出来我立即更正谢谢大佬的指正。


收起阅读 »