注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

一个模仿即刻App用户头像拖动效果的工具类

SnakeViewMakerSnakeViewMaker 是一个模仿即刻App里用户头像拖动效果的工具类。调用方法:1.创建 SnakeViewMaker; SnakeViewMaker snakeViewMaker = new SnakeViewMak...
继续阅读 »

SnakeViewMaker

SnakeViewMaker 是一个模仿即刻App里用户头像拖动效果的工具类。

image

调用方法:

1.创建 SnakeViewMaker;

    SnakeViewMaker snakeViewMaker = new SnakeViewMaker(MainActivity.this);

2.绑定

    snakeViewMaker
.addTargetView(imageView) // 绑定目标View
.attachToRootLayout((ViewGroup) findViewById(R.id.root)); // 绑定Activity/Fragment的根布局

3.其他相关API

    snakeViewMaker.detachSnake();                // 解除绑定
snakeViewMaker.updateSnakeImage(); // 当目标View的视图发生变化时,调用此方法用以更新Snake视图状态
snakeViewMaker.interceptTouchEvent(true); // Snake拖动过程中是否需要屏蔽其他onTouch事件,默认屏蔽
snakeViewMaker.setVisibility(View.VISIBLE); // 控制可见性
snakeViewMaker.setClickable(true); // 控制可点击
snakeViewMaker.setEnabled(true); // 控制可触摸

注意事项

1.目前不支持LinearLayout根布局

2.加载本地图片可直接调用。网络图片需要在图片加载完成后才能调用,不然可能出现绑定不成功的情况

例如,用glide加载网络图片时,调用时机如下:

    snakeViewMaker = new SnakeViewMaker(MainActivity.this);
Glide.with(this).load(url).asBitmap()
.into(new BitmapImageViewTarget(imageView) {
@Override
protected void setResource(Bitmap resource) {
super.setResource(resource);
snakeViewMaker.addTargetView(imageView)
.attachToRootLayout((ViewGroup) findViewById(R.id.root));
}
});


代码下载:SnakeViewMaker-master.zip

收起阅读 »

Android修炼系列(十二),自定义一个超顺滑的回弹RecyclerView

前面写了一个嵌套滑动框架和分析了ViewDragHelper的事件分发,本节主要自定义一个带有回弹效果的RecyclerView,看看事件和动画的配合,这在各大App中都比较常见了,效果如下: 实现 这是定义的回弹类:OverScrollRecycl...
继续阅读 »

前面写了一个嵌套滑动框架和分析了ViewDragHelper的事件分发,本节主要自定义一个带有回弹效果的RecyclerView,看看事件和动画的配合,这在各大App中都比较常见了,效果如下:





实现


这是定义的回弹类:OverScrollRecyclerView,其是RecyclerView的子类,并实现了OnTouchListener方法:


public class OverScrollRecyclerView extends RecyclerView implements View.OnTouchListener {

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

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

public OverScrollRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initParams();
}
}
复制代码

随后会定义一些必要的属性,其中DEFAULT_TOUCH_DRAG_MOVE_RATIO表示滑动的像素数与实际view偏移量的比例,减速系数和时间也都是根据实际效果不断调整的。


```java
public class OverScrollRecyclerView extends RecyclerView implements View.OnTouchListener {

// 下拉与上拉,move px / view Translation
private static final float DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD = 2f;
private static final float DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK = 1f;
// 默认减速系数
private static final float DEFAULT_DECELERATE_FACTOR = -2f;
// 最大反弹时间
private static final int MAX_BOUNCE_BACK_DURATION_MS = 800;
private static final int MIN_BOUNCE_BACK_DURATION_MS = 200;

// 初始状态,滑动状态,回弹状态
private IDecoratorState mCurrentState;
private IdleState mIdleState;
private OverScrollingState mOverScrollingState;
private BounceBackState mBounceBackState;

private final OverScrollStartAttributes mStartAttr = new OverScrollStartAttributes();
private float mVelocity;
private final RecyclerView mRecyclerView = this;
...
public OverScrollRecyclerView(Context context, @Nullable AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initParams();
}
...
}

复制代码

这是我们的状态接口IDecoratorState,其提供了3个方法,IdleState、OverScrollingState、BounceBackState都是它的具体实现类,符合状态模式的思想:


    protected interface IDecoratorState {
// 处理move事件
boolean handleMoveTouchEvent(MotionEvent event);
// 处理up事件
boolean handleUpTouchEvent(MotionEvent event);
// 事件结束后的动画处理
void handleTransitionAnim(IDecoratorState fromState);
}
复制代码

初始化我们定义的变量,没有什么特殊的操作,只是一些各自属性的赋值,具体见下文:


    private void initParams() {
mBounceBackState = new BounceBackState();
mOverScrollingState = new OverScrollingState();
mCurrentState = mIdleState = new IdleState();
attach();
}
复制代码

这是我们的attach,添加触摸监听,并去掉滚动到边缘的光晕效果:


    @SuppressLint("ClickableViewAccessibility")
public void attach() {
mRecyclerView.setOnTouchListener(this);
mRecyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER);
}
复制代码

核心代码就是事件的监听了,需要我们处理onTouch事件,当手指按下滑动时,此时mCurrentState还处于初始状态,其会执行相应的handleMoveTouchEvent方法:


    @Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
return mCurrentState.handleMoveTouchEvent(event);
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
return mCurrentState.handleUpOrCancelTouchEvent(event);
}
return false;
}
复制代码

这是初始状态IdleState处理move的逻辑,主要做些校验工作,如果移动不满足要求,就将事件透出去,具体见下:


    @Override
public boolean handleMoveTouchEvent(MotionEvent event) {
// 是否符合move要求,不符合不拦截事件
if (!initMotionAttributes(mRecyclerView, mMoveAttr, event)) {
return false;
}
// 在RecyclerView顶部但不能下拉 或 在RecyclerView底部但不能上拉
if (!((isInAbsoluteStart(mRecyclerView) && mMoveAttr.mDir) ||
(isInAbsoluteEnd(mRecyclerView) && !mMoveAttr.mDir))) {
return false;
}
// 保存当前Motion信息
mStartAttr.mPointerId = event.getPointerId(0);
mStartAttr.mAbsOffset = mMoveAttr.mAbsOffset;
mStartAttr.mDir = mMoveAttr.mDir;
// 初始状态->滑动状态
issueStateTransition(mOverScrollingState);
return mOverScrollingState.handleMoveTouchEvent(event);
}
复制代码

这是initMotionAttributes方法,会计算Y方向偏移量,如果满足要求,则为MotionAttributes赋值:


    private boolean initMotionAttributes(View view, MotionAttributes attributes, MotionEvent event) {
if (event.getHistorySize() == 0) {
return false;
}
// 像素偏移量
final float dy = event.getY(0) - event.getHistoricalY(0, 0);
final float dx = event.getX(0) - event.getHistoricalX(0, 0);
if (Math.abs(dy) < Math.abs(dx)) {
return false;
}
attributes.mAbsOffset = view.getTranslationY();
attributes.mDeltaOffset = dy;
attributes.mDir = attributes.mDeltaOffset > 0;
return true;
}
复制代码

这里的isInAbsoluteStart方法用来判断,当前RecyclerView是否不能向下滑动,另一个isInAbsoluteEnd是否不能向上滑动,代码就不展示了:


    private boolean isInAbsoluteStart(View view) {
return !view.canScrollVertically(-1);
}
复制代码

当move事件通过初始状态的校验,则改变状态为滑动态OverScrollingState,正式处理滑动逻辑,其方法见下:


    @Override
public boolean handleMoveTouchEvent(MotionEvent event) {
final OverScrollStartAttributes startAttr = mStartAttr;
// 不是一个触摸点事件,则直接切到回弹状态
if (startAttr.mPointerId != event.getPointerId(0)) {
issueStateTransition(mBounceBackState);
return true;
}

final View view = mRecyclerView;

// 是否符合move要求
if (!initMotionAttributes(view, mMoveAttr, event)) {
return true;
}

// mDeltaOffset: 实际要移动的像素,可以为下拉和上拉设置不同移动比
float deltaOffset = mMoveAttr.mDeltaOffset / (mMoveAttr.mDir == startAttr.mDir
? mTouchDragRatioFwd : mTouchDragRatioBck);
// 计算偏移
float newOffset = mMoveAttr.mAbsOffset + deltaOffset;

// 上拉下拉状态与滑动方向不符,则回到初始状态,并将视图归位
if ((startAttr.mDir && !mMoveAttr.mDir && (newOffset <= startAttr.mAbsOffset)) ||
(!startAttr.mDir && mMoveAttr.mDir && (newOffset >= startAttr.mAbsOffset))) {
translateViewAndEvent(view, startAttr.mAbsOffset, event);
issueStateTransition(mIdleState);
return true;
}

// 不让父类截获move事件
if (view.getParent() != null) {
view.getParent().requestDisallowInterceptTouchEvent(true);
}

// 计算速度
long dt = event.getEventTime() - event.getHistoricalEventTime(0);
if (dt > 0) {
mVelocity = deltaOffset / dt;
}

// 改变控件位置
translateView(view, newOffset);
return true;
}
复制代码

这是translateView方法,改变view相对父布局的偏移量:


    private void translateView(View view, float offset) {
view.setTranslationY(offset);
}
复制代码

当滑动事件结束,手指抬起时,会将状态由滑动状态切换为回弹状态:


    @Override
public boolean handleUpTouchEvent(MotionEvent event) {
// 事件up切换状态,有滑动态-回弹态
issueStateTransition(mBounceBackState);
return false;
}
复制代码

上文提到的issueStateTransition方法,只是说切换了状态,但实际上它还会执行handleTransitionAnim的操作,只不过初始状态和滑动状态此接口都是空实现,只有回弹状态才会去处理动画效果罢了:


    protected void issueStateTransition(IDecoratorState state) {
IDecoratorState oldState = mCurrentState;
mCurrentState = state;
// 处理回弹动画效果
mCurrentState.handleTransitionAnim(oldState);
}
复制代码

这是我们处理动画效果的方法,核心方法createAnimator具体看下,之后添加了动画监听,并开启动画:


    @Override
public void handleTransitionAnim(IDecoratorState fromState) {
Animator bounceBackAnim = createAnimator();
bounceBackAnim.addListener(this);
bounceBackAnim.start();
}
复制代码

这是动画创建的核心类,使用了属性动画,先由当前速度mVelocity->0,随后回弹slowdownEndOffset->mStartAttr.mAbsOffset,具体代码见下:


    private Animator createAnimator() {
initAnimationAttributes(view, mAnimAttributes);

// 速度为0了或手势记录的状态与mDir不符合,直接回弹
if (mVelocity == 0f || (mVelocity < 0 && mStartAttr.mDir) || (mVelocity > 0 && !mStartAttr.mDir)) {
return createBounceBackAnimator(mAnimAttributes.mAbsOffset);
}

// 速度减到0,即到达最大距离时,需要的动画事件
float slowdownDuration = (0 - mVelocity) / mDecelerateFactor;
slowdownDuration = (slowdownDuration < 0 ? 0 : slowdownDuration);

// 速度减到0,动画的距离,dx = (Vt^2 - Vo^2) / 2a
float slowdownDistance = -mVelocity * mVelocity / mDoubleDecelerateFactor;
float slowdownEndOffset = mAnimAttributes.mAbsOffset + slowdownDistance;

// 开始动画,减速->回弹
ObjectAnimator slowdownAnim = createSlowdownAnimator(view, (int) slowdownDuration, slowdownEndOffset);
ObjectAnimator bounceBackAnim = createBounceBackAnimator(slowdownEndOffset);
AnimatorSet wholeAnim = new AnimatorSet();
wholeAnim.playSequentially(slowdownAnim, bounceBackAnim);
return wholeAnim;
}
复制代码

这是具体的减速动画方法,设置时间和差值器,就不细说了,不是本文的重点,直接见代码吧:


    private ObjectAnimator createSlowdownAnimator(View view, int slowdownDuration, float slowdownEndOffset) {
ObjectAnimator slowdownAnim = ObjectAnimator.ofFloat(view, mAnimAttributes.mProperty, slowdownEndOffset);
slowdownAnim.setDuration(slowdownDuration);
slowdownAnim.setInterpolator(mBounceBackInterpolator);
slowdownAnim.addUpdateListener(this);
return slowdownAnim;
}
复制代码

同样这是回弹动画的方法,设置时间和差值器,添加监听等,代码见下:


    private ObjectAnimator createBounceBackAnimator(float startOffset) {
float bounceBackDuration = (Math.abs(startOffset) / mAnimAttributes.mMaxOffset) * MAX_BOUNCE_BACK_DURATION_MS;
ObjectAnimator bounceBackAnim = ObjectAnimator.ofFloat(view, mAnimAttributes.mProperty, mStartAttr.mAbsOffset);
bounceBackAnim.setDuration(Math.max((int) bounceBackDuration, MIN_BOUNCE_BACK_DURATION_MS));
bounceBackAnim.setInterpolator(mBounceBackInterpolator);
bounceBackAnim.addUpdateListener(this);
return bounceBackAnim;
}
复制代码

当动画结束的时候,会将状态由回弹模式切换为初始状态,代码见下:


    @Override
public void onAnimationEnd(Animator animation) {
// 动画结束改变状态
issueStateTransition(mIdleState);
}
复制代码

好了,到这里核心逻辑就结束啦,应该不难理解吧。如果讲的不好,博客的栗子我都上传到了gitHub上,感兴趣的可以直接下载看下。



本文到这里,关于回弹效果的实现就结束了。如果本文对你有用,来点个赞吧,大家的肯定也是阿呆i坚持写作的动力。


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

Android修炼系列(十一),强大的可拖拽工具类ViewDragHelper

demo实现效果图见下,可自由拖拽的view,还在自己造轮子吗?使用系统androidx包(原v4)下的ViewDragHelper 几行代码即可搞定.. 实现 ViewDragHelper是用于编写自定义ViewGroup的工具类。它提供了许多有用...
继续阅读 »

demo实现效果图见下,可自由拖拽的view,还在自己造轮子吗?使用系统androidx包(原v4)下的ViewDragHelper 几行代码即可搞定..





实现


ViewDragHelper是用于编写自定义ViewGroup的工具类。它提供了许多有用的操作和状态跟踪,以允许用户在其父级ViewGroup中拖动和重新放置视图,具体可见 官网API。好,那我们就开始自定义一个简单的ViewGroup,并创建ViewDragHelper,代码见下:


public class DragViewGroup extends RelativeLayout {

ViewDragHelper mDragHelper;

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

public DragViewGroup(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public DragViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mDragHelper = ViewDragHelper.create(this, 1.0f, new ViewDragCallback());
}
...
}
复制代码

其中ViewDragCallback是我自己创建的内部类,继承自ViewDragHelper.Callback实现类。


private static class ViewDragCallback extends ViewDragHelper.Callback {
@Override
public boolean tryCaptureView(@NonNull View child, int pointerId) {
// 决定child是否可以被拖拽,具体见下文源码分析
return true;
}

@Override
public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
// 可决定child横向的偏移计算,见下文
return left;
}

@Override
public int clampViewPositionVertical(@NonNull View child, int top, int dy) {
// 可决定child竖向的偏移计算,见下文
return top;
}
}
复制代码

重写DragViewGroup的方法onInterceptHoverEvent和onTouchEvent方法:


public class DragViewGroup extends RelativeLayout {
...
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
return mDragHelper.shouldInterceptTouchEvent(event);
}

@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
return true;
}
...
}
复制代码

这是我们的layout文件,其中DragViewGroup是我们上面定义的ViewGroup,TextView就是待拖拽的child view。


<com.blog.a.drag.DragViewGroup
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<TextView
android:layout_width="70dp"
android:layout_height="70dp"
android:text="可拖拽"
android:gravity="center"
android:textColor="#fff"
android:background="#6495ED"
/>
</com.blog.a.drag.DragViewGroup>
复制代码

是不是非常省事,博客的栗子我都上传到了gitHub上,感兴趣的可以下载看下。


源码


本篇文章主要分析下,当触摸事件开始到结束,processTouchEvent的处理过程:


    public boolean onTouchEvent(MotionEvent event) {
mDragHelper.processTouchEvent(event);
return true;
}
复制代码

MotionEvent.ACTION_DOWN


当手指刚接触屏幕时,会触发ACTION_DOWN 事件,通过MotionEvent我们能获取到点击事件发生的 x, y 坐标,注意这里的getX/getY的坐标是相对于当前view而言的。Pointer是触摸点的概念,一个MotionEvent可能会包含多个Pointer触摸点的信息,而每个Pointer触摸点都会有一个自己的id和index。具体往下看。


    case MotionEvent.ACTION_DOWN: {
final float x = ev.getX();
final float y = ev.getY();
final int pointerId = ev.getPointerId(0);
final View toCapture = findTopChildUnder((int) x, (int) y);

saveInitialMotion(x, y, pointerId);

tryCaptureViewForDrag(toCapture, pointerId);
// mTrackingEdges默认是0,可通过ViewDragHelper#setEdgeTrackingEnabled(int)
// 来设置,用来控制触碰边缘回调onEdgeTouched
final int edgesTouched = mInitialEdgesTouched[pointerId];
if ((edgesTouched & mTrackingEdges) != 0) {
mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
}
break;
}
复制代码

这里的findTopChildUnder方法是用来获取当前x, y坐标点所在的view,默认是最上层的,当然我们也可以通过callback#getOrderedChildIndex(int) 接口来自定义view遍历顺序,代码见下:


    public View findTopChildUnder(int x, int y) {
final int childCount = mParentView.getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
final View child = mParentView.getChildAt(mCallback.getOrderedChildIndex(i));
if (x >= child.getLeft() && x < child.getRight()
&& y >= child.getTop() && y < child.getBottom()) {
return child;
}
}
return null;
}
复制代码

这里的saveInitialMotion方法是用来保存当前触摸位置信息,其中getEdgesTouched方法用来判断x, y是否位于此viewGroup边缘之外,并返回保存相应result结果。todo:下篇准备写一下关于位运算符的文章,很有意思。


    private void saveInitialMotion(float x, float y, int pointerId) {
ensureMotionHistorySizeForId(pointerId);
mInitialMotionX[pointerId] = mLastMotionX[pointerId] = x;
mInitialMotionY[pointerId] = mLastMotionY[pointerId] = y;
mInitialEdgesTouched[pointerId] = getEdgesTouched((int) x, (int) y);
mPointersDown |= 1 << pointerId;
}
复制代码

其中tryCaptureViewForDrag方法内,mCapturedView是当前触摸的视图view,如果相同则直接返回,否则会进行mCallback#tryCaptureView(View, int)判断,这个是不是很眼熟,我们可以重写这个回调来控制toCapture这个view能否被捕获,即能否被拖拽操作。


    boolean tryCaptureViewForDrag(View toCapture, int pointerId) {
if (toCapture == mCapturedView && mActivePointerId == pointerId) {
// Already done!
return true;
}
if (toCapture != null && mCallback.tryCaptureView(toCapture, pointerId)) {
mActivePointerId = pointerId;
captureChildView(toCapture, pointerId);
return true;
}
return false;
}
复制代码

这里的captureChildView方法用来保存信息,并设置拖拽状态。能注意到,这里还有个捕获view是否是child view的判断。


    public void captureChildView(@NonNull View childView, int activePointerId) {
if (childView.getParent() != mParentView) {
throw new IllegalArgumentException("captureChildView: parameter must be a descendant "
+ "of the ViewDragHelper's tracked parent view (" + mParentView + ")");
}

mCapturedView = childView;
mActivePointerId = activePointerId;
mCallback.onViewCaptured(childView, activePointerId);
setDragState(STATE_DRAGGING);
}
复制代码

MotionEvent.ACTION_POINTER_DOWN


当用户又使用一个手指接触屏幕时,会触发ACTION_POINTER_DOWN 事件,与上面的ACTION_DOWN 相似,就不细展开了。由于ViewDragHelper一次只能操作一个视图,所以这里会先进行状态判断,如果视图还未被捕获拖动,则逻辑与上面的ACTION_POINTER_DOWN一致,反之,会判断触摸点是否在当前视图内,如果符合条件,则更新Pointer,这里很重要,体现在ui效果上就是,一个手指按住view,另一个手指仍然可以拖拽此view。


    case MotionEvent.ACTION_POINTER_DOWN: {
final int pointerId = ev.getPointerId(actionIndex);
final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);

saveInitialMotion(x, y, pointerId);
// A ViewDragHelper can only manipulate one view at a time.
if (mDragState == STATE_IDLE) {
// If we're idle we can do anything! Treat it like a normal down event.
final View toCapture = findTopChildUnder((int) x, (int) y);
tryCaptureViewForDrag(toCapture, pointerId);

final int edgesTouched = mInitialEdgesTouched[pointerId];
if ((edgesTouched & mTrackingEdges) != 0) {
mCallback.onEdgeTouched(edgesTouched & mTrackingEdges, pointerId);
}
} else if (isCapturedViewUnder((int) x, (int) y)) {
tryCaptureViewForDrag(mCapturedView, pointerId);
}
break;
}
复制代码

MotionEvent.ACTION_MOVE


当手指在屏幕移动时,如果视图正在被拖动,则会先判断当前mActivePointerId是否有效,无效则跳过当前move事件。随后获取当前x, y并计算与上次x, y移动距离。之后触发dragTo拖动逻辑,最后保存保存这次的位置。核心方法dragTo分析见下文:


    case MotionEvent.ACTION_MOVE: {
if (mDragState == STATE_DRAGGING) {
// If pointer is invalid then skip the ACTION_MOVE.
if (!isValidPointerForActionMove(mActivePointerId)) break;

final int index = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(index);
final float y = ev.getY(index);
final int idx = (int) (x - mLastMotionX[mActivePointerId]);
final int idy = (int) (y - mLastMotionY[mActivePointerId]);

dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);

saveLastMotion(ev);
} else {
// Check to see if any pointer is now over a draggable view.
...
}
break;
}
复制代码

在move过程中,通过dragTo方法来传入目标x, y 和横向和竖向的偏移量,并通过callback回调来通知开发者,开发者可重写clampViewPositionHorizontal与clampViewPositionVertical这两个回调方法,来自定义clampedX,clampedY目标位置。随后使用offsetLeftAndRight和offsetTopAndBottom 方法分别在相应的方向偏移(clampedX - oldLeft)和(clampedY - oldTo)的像素。最后触发onViewPositionChanged位置修改的回调。


    private void dragTo(int left, int top, int dx, int dy) {
int clampedX = left;
int clampedY = top;
final int oldLeft = mCapturedView.getLeft();
final int oldTop = mCapturedView.getTop();
if (dx != 0) {
clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
ViewCompat.offsetLeftAndRight(mCapturedView, clampedX - oldLeft);
}
if (dy != 0) {
clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
ViewCompat.offsetTopAndBottom(mCapturedView, clampedY - oldTop);
}

if (dx != 0 || dy != 0) {
final int clampedDx = clampedX - oldLeft;
final int clampedDy = clampedY - oldTop;
mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY,
clampedDx, clampedDy);
}
}
复制代码

如果当手指在屏幕移动时,发现视图未处于拖动状态呢?首先会去检查是否有其他Pointer是否有效。随后触发边缘拖动回调,随后再进行状态检查,应该是为了避免此时状态由未拖动->拖动状态了,如:smoothSlideViewTo方法就有这个能力。如果此时mDragState处于未拖动状态,则会重新获取x,y 所在视图view并重新设置拖拽状态,这个逻辑与down逻辑一样。


    case MotionEvent.ACTION_MOVE: {
if (mDragState == STATE_DRAGGING) {
// If pointer is invalid then skip the ACTION_MOVE.
...
} else {
// Check to see if any pointer is now over a draggable view.
final int pointerCount = ev.getPointerCount();
for (int i = 0; i < pointerCount; i++) {
final int pointerId = ev.getPointerId(i);

// If pointer is invalid then skip the ACTION_MOVE.
if (!isValidPointerForActionMove(pointerId)) continue;

final float x = ev.getX(i);
final float y = ev.getY(i);
final float dx = x - mInitialMotionX[pointerId];
final float dy = y - mInitialMotionY[pointerId];

reportNewEdgeDrags(dx, dy, pointerId);
if (mDragState == STATE_DRAGGING) {
// Callback might have started an edge drag.
break;
}

final View toCapture = findTopChildUnder((int) x, (int) y);
if (checkTouchSlop(toCapture, dx, dy)
&& tryCaptureViewForDrag(toCapture, pointerId)) {
break;
}
}
saveLastMotion(ev);
}
break;
}
复制代码

MotionEvent.ACTION_POINTER_UP


当处于多触摸点时,当一手指从屏幕上松开时,首先判断正在拖动视图的触摸点是否是当前触摸点,如果是,则再去检查视图上是否还有其他有效的触摸点,如果没有则释放,此时view就惯性停住了。如果还有,则清理当前up掉的触摸点数据。


    case MotionEvent.ACTION_POINTER_UP: {
final int pointerId = ev.getPointerId(actionIndex);
// 判断当前触摸点是否是正在拖动视图的触摸点
if (mDragState == STATE_DRAGGING && pointerId == mActivePointerId) {
// 检查是否有其他有效触摸点
int newActivePointer = INVALID_POINTER;
final int pointerCount = ev.getPointerCount();
// 遍历ev内触摸点
for (int i = 0; i < pointerCount; i++) {
final int id = ev.getPointerId(i);
if (id == mActivePointerId) {
// This one's going away, skip.
continue;
}

final float x = ev.getX(i);
final float y = ev.getY(i);
// 如果在视图上,并且可拖动,则标记找到了
if (findTopChildUnder((int) x, (int) y) == mCapturedView
&& tryCaptureViewForDrag(mCapturedView, id)) {
newActivePointer = mActivePointerId;
break;
}
}

if (newActivePointer == INVALID_POINTER) {
// 如果没有发现其他触摸点在拖拽视图view,则释放掉就可以了
releaseViewForPointerUp();
}
}
// 清理当前up掉的触摸点数据
clearMotionHistory(pointerId);
break;
}
复制代码

MotionEvent.ACTION_UP


当手指从屏幕上离开时,会先判断当前状态,如果此时mDragState处于拖动状态,则释放,view惯性停住。通过cancel方法改变状态,清空当前触摸点数据并接触速度检测mVelocityTracker。


    case MotionEvent.ACTION_UP: {
if (mDragState == STATE_DRAGGING) {
releaseViewForPointerUp();
}
cancel();
break;
}
复制代码

好了,本文到这里,关于ViewDrafHHelper的介绍就结束了。如果本文对你有用,来点个赞吧,大家的肯定也是阿呆i坚持写作的动力。


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

收起阅读 »

Android修炼系列(十),事件分发从手写一个嵌套滑动框架开始

先放了一张效果图,是一个嵌套滑动的效果。博客的栗子我都上传到了gitHub上,感兴趣的可以下载看下。 在说代码之前,可以先看下最终的NestedViewGroup XML结构,NestedViewGroup内部包含顶部地图 MapView和滑动布局L...
继续阅读 »

先放了一张效果图,是一个嵌套滑动的效果。博客的栗子我都上传到了gitHub上,感兴趣的可以下载看下。





在说代码之前,可以先看下最终的NestedViewGroup XML结构,NestedViewGroup内部包含顶部地图 MapView和滑动布局LinearLayout,而LinearLayout布局的内部即我们常用的滑动控件 RecyclerView,在这里为何还要加层LinearLayout呢?这样做的好处是,我们可以更好的适配不同滑动控件,而不仅仅是将NestedViewGroup与RecyclerView 耦合住。


    <com.blog.a.nested.NestedViewGroup
android:id="@+id/dd_view_group"
android:layout_width="match_parent"
android:layout_height="match_parent"
didi:header_id="@+id/t_map_view"
didi:target_id="@+id/target_layout"
didi:inn_id="@+id/inner_rv"
didi:header_init_top="0"
didi:target_init_bottom="250">

<com.tencent.tencentmap.mapsdk.maps.MapView
android:id="@+id/t_map_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<LinearLayout
android:id="@+id/target_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="#fff">

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/inner_rv"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>

</LinearLayout>

</com.mjzuo.views.nested.NestedViewGroup>
复制代码

实现


在attrs.xml文件下为NestedViewGroup添加自定义属性,其中header_id对应顶部地图 MapView,target_id对应滑动布局LinearLayout,inn_id对应滑动控件RecyclerView。


<resources>
<declare-styleable name="CompNsViewGroup">
<attr name="header_id"/>
<attr name="target_id"/>
<attr name="inn_id"/>
<attr name="header_init_top" format="integer"/>
<attr name="target_init_bottom" format="integer"/>
</declare-styleable>
</resources>
复制代码

我们根据attrs.xml中的属性,获取XML中NestedViewGroup中的View ID。


        // 获取配置参数
final TypedArray array = context.getTheme().obtainStyledAttributes(attrs
, R.styleable.CompNsViewGroup
, defStyleAttr, 0);
mHeaderResId = array.getResourceId
(R.styleable.CompNsViewGroup_header_id, -1);
mTargetResId = array.getResourceId
(R.styleable.CompNsViewGroup_target_id, -1);
mInnerScrollId = array.getResourceId
(R.styleable.CompNsViewGroup_inn_id, -1);
if (mHeaderResId == -1 || mTargetResId == -1
|| mInnerScrollId == -1)
throw new RuntimeException("VIEW ID is null");
复制代码

我们根据attrs.xml中的属性,来初始化View的高度、距离等,计算高度时,需要考虑到状态栏因素:


        mHeaderInitTop = Utils.dip2px(getContext()
, array.getInt(R.styleable.CompNsViewGroup_header_init_top, 0));
mHeaderCurrTop = mHeaderInitTop;
// 屏幕高度 - 底部距离 - 状态栏高度
mTargetInitBottom = Utils.dip2px(getContext()
, array.getInt(R.styleable.CompNsViewGroup_target_init_bottom, 0));
// 注意:当前activity默认去掉了标题栏
mTargetInitTop = Utils.getScreenHeight(getContext()) - mTargetInitBottom
- Utils.getStatusBarHeight(getContext().getApplicationContext());
mTargetCurrTop = mTargetInitTop;
复制代码

通过上面获取到的View ID,我们能够直接引用到XML中的相关View实例,而后续的滑动,本质上就是针对该View所进行的一系列判断处理。


    @Override
protected void onFinishInflate() {
super.onFinishInflate();
mHeaderView = findViewById(mHeaderResId);
mTargetView = findViewById(mTargetResId);
mInnerScrollView = findViewById(mInnerScrollId);
}
复制代码

我们重写onMeasure方法,其不仅是给childView传入测量值和测量模式,还将我们自己测量的尺寸提供给父ViewGroup让其给我们提供期望大小的区域。


    @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

measureChildren(widthMeasureSpec, heightMeasureSpec);

int widthModle = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightModle = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);

....

setMeasuredDimension(widthSize, heightSize);
}
复制代码

我们重写onLayout方法,给childView确定位置。需要注意的是,原始bottom不是height高度,而是又向下挪了mTargetInitTop,我们可以想象成,我们一直将mTargetView挪动到了屏幕下方看不到的地方。


    @Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int childCount = getChildCount();
if (childCount == 0)
return;
final int width = getMeasuredWidth();
final int height = getMeasuredHeight();

// 注意:原始bottom不是height高度,而是又向下挪了mTargetInitTop
mTargetView.layout(getPaddingLeft()
, getPaddingTop() + mTargetCurrTop
, width - getPaddingRight()
, height + mTargetCurrTop
+ getPaddingTop() + getPaddingBottom());

int headerWidth = mHeaderView.getMeasuredWidth();
int headerHeight = mHeaderView.getMeasuredHeight();
mHeaderView.layout((width - headerWidth)/2
, mHeaderCurrTop + getPaddingTop()
, (width + headerWidth)/2
, headerHeight + mHeaderCurrTop + getPaddingTop());
}
复制代码

此功能实现的核心即事件的分发和拦截了。在接收到事件时,如果上次滚动还未结束,则先停下。随后判断TargetView内的RecyclerView能否向下滑动,如果还能滑动,则不拦截事件,将事件传递给 TargetView。如果点击在Header区域,则不拦截事件,将事件传递给地图MapView。


    @Override
public boolean onInterceptTouchEvent(MotionEvent event) {

// 如果上次滚动还未结束,则先停下
if (!mScroller.isFinished())
mScroller.forceFinished(true);

// 不拦截事件,将事件传递给TargetView
if (canChildScrollDown())
return false;

int action = event.getAction();

switch (action) {
case MotionEvent.ACTION_DOWN:
mDownY = event.getY();
mIsDragging = false;
// 如果点击在Header区域,则不拦截事件
isDownInTop = mDownY <= mTargetCurrTop - mTouchSlop;
break;

case MotionEvent.ACTION_MOVE:
final float y = event.getY();
if (isDownInTop) {
return false;
} else {
startDragging(y);
}

break;

case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mIsDragging = false;
break;
}

return mIsDragging;
}
复制代码

当NestedViewGroup拦截事件后,会调用自身的onTouchEvent方法,逻辑与 onInterceptTouchEvent 类似,这里需要注意的是,当事件在ViewGroup内,我们要怎么手动分发给TargetView呢?代码见下:


    @Override
public boolean onTouchEvent(MotionEvent event) {
if (canChildScrollDown())
return false;

// 添加速度监听
acquireVelocityTracker(event);
int action = event.getAction();

switch (action) {
case MotionEvent.ACTION_DOWN:
mIsDragging = false;
break;

case MotionEvent.ACTION_MOVE:
...
break;

case MotionEvent.ACTION_UP:
if (mIsDragging) {
mIsDragging = false;
mVelocityTracker.computeCurrentVelocity(500, maxFlingVelocity);
final float vy = mVelocityTracker.getYVelocity();
// 滚动的像素数太大了,这里只滚动像素数的0.1
vyPxCount = (int)(vy/3);
finishDrag(vyPxCount);
}
releaseVelocityTracker();
return false;

case MotionEvent.ACTION_CANCEL:
// 回收滑动监听
releaseVelocityTracker();
return false;
}

return mIsDragging;
}
复制代码

这是我们手指移动ACTION_MOVE 时的逻辑:


    final float y = event.getY();
startDragging(y);

if (mIsDragging) {
float dy = y - mLastMotionY;
if (dy >= 0) {
moveTargetView(dy);
} else if (mTargetCurrTop + dy <= 0) {
/**
* 此时,事件在ViewGroup内,
* 需手动分发给TargetView
*/
moveTargetView(dy);
int oldAction = event.getAction();
event.setAction(MotionEvent.ACTION_DOWN);
dispatchTouchEvent(event);
event.setAction(oldAction);
} else {
moveTargetView(dy);
}
mLastMotionY = y;
}
复制代码

通过canChildScrollDown方法,我们能够判断RecyclerView是否能够向下滑动。这里后续会抽出一个adapter类,来处理不同的滑动控件。这里通过canScrollVertically来判断当前视图是否可以继续滚动,其中正数表示实际是判断手指能否向上滑动,负数表示实际是判断手指能否向下滑动:


    public boolean canChildScrollDown() {
RecyclerView rv;
// 当前只做了RecyclerView的适配
if (mInnerScrollView instanceof RecyclerView) {
rv = (RecyclerView) mInnerScrollView;
return rv.canScrollVertically(-1);
}
return false;
}
复制代码

获取向上能够滑动的距离顶部距离,如果Item数量太少,导致rv不能占满一屏时,注意向上滑动的距离。


    public int toTopMaxOffset() {
final RecyclerView rv;
if (mInnerScrollView instanceof RecyclerView) {
rv = (RecyclerView) mInnerScrollView;
if (android.os.Build.VERSION.SDK_INT >= 18) {

return Math.max(0, mTargetInitTop -
(rv.computeVerticalScrollRange() - mTargetInitBottom));
}
}
return 0;
}
复制代码

手指向下滑动或TargetView距离顶部距离 > 0,则ViewGroup拦截事件。


    private void startDragging(float y) {
if (y > mDownY || mTargetCurrTop > toTopMaxOffset()) {
final float yDiff = Math.abs(y - mDownY);
if (yDiff > mTouchSlop && !mIsDragging) {
mLastMotionY = mDownY + mTouchSlop;
mIsDragging = true;
}
}
}
复制代码

这是获取TargetView和HeaderView顶部距离的方法,我们通过不断刷新顶部距离来实现滑动的效果,并在这里添加距离监听。


    private void moveTargetViewTo(int target) {
target = Math.max(target, toTopMaxOffset());
if (target >= mTargetInitTop)
target = mTargetInitTop;
// TargetView的top、bottom两个方向都是加上offsetY
ViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrTop);
// 更新当前TargetView距离顶部高度H
mTargetCurrTop = target;

int headerTarget;
// 下拉超过定值H
if (mTargetCurrTop >= mTargetInitTop) {
headerTarget = mHeaderInitTop;
} else if (mTargetCurrTop <= 0) {
headerTarget = 0;
} else {
// 滑动比例
float percent = mTargetCurrTop * 1.0f / mTargetInitTop;
headerTarget = (int) (percent * mHeaderInitTop);
}
// HeaderView的top、bottom两个方向都是加上offsetY
ViewCompat.offsetTopAndBottom(mHeaderView, headerTarget - mHeaderCurrTop);
mHeaderCurrTop = headerTarget;

if (mListener != null) {
mListener.onTargetToTopDistance(mTargetCurrTop);
mListener.onHeaderToTopDistance(mHeaderCurrTop);
}
}
复制代码

这是mScroller弹性滑动时的一些阈值判断。startScroll本身并没有做任何滑动相关的事,而是通过invalidate方法来实现View重绘,在View的draw方法中会调用computeScroll方法,但本例中并没有在computeScroll中配合scrollTo来实现滑动。注意这里的滑动,是指内容的滑动,而非View本身位置的滑动。


    private void finishDrag(int vyPxCount) {
if ((vyPxCount >= 0 && vyPxCount <= minFlingVelocity)
|| (vyPxCount <= 0 && vyPxCount >= -minFlingVelocity))
return;


if (vyPxCount > 0) {
// 速度 > 0,说明正向下滚动
// 防止超出临界值
if (mTargetCurrTop < mTargetInitTop) {
mScroller.startScroll(0, mTargetCurrTop, 0,
Math.min(vyPxCount, mTargetInitTop - mTargetCurrTop)
, 500);
invalidate();
}
} else if (vyPxCount < 0) {
// 速度 < 0,说明正向上滚动

if (mTargetCurrTop <= 0 && mScroller.getCurrVelocity() > 0) {
// todo: inner scroll 接着滚动
}

mScroller.startScroll(0, mTargetCurrTop
, 0, Math.max(vyPxCount, -mTargetCurrTop)
, 500);
invalidate();
}
}
复制代码

在View重绘后,computeScroll方法就会被调用,这里通过更新此时TargetView和HeaderView的顶部距离,来实现滑动到新的位置的目的。


    @Override
public void computeScroll() {
// 判断是否完成滚动,true:未结束
if (mScroller.computeScrollOffset()) {
moveTargetViewTo(mScroller.getCurrY());
invalidate();
}
}
复制代码


好了,本文到这里,关于嵌套滑动的demo就结束了,当然可优化的点还很多。如果本文对你有用,来点个赞吧,大家的肯定也是阿呆i坚持写作的动力。



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

收起阅读 »

技术协同 · 生态共赢 | 环信 IM here 开发者沙龙第3期

01活动介绍云通讯行业爆发新机遇。供给侧:全球通信设施、5G建设逐步推进,高速率、低延时、高质量的特性将解锁下一代社交网络、VR 和 AR 等新场景新业务。需求侧:远程社交、居家办公成为常态,在线教育、游戏社交、远程视频等需求激增...
继续阅读 »

01

活动介绍


云通讯行业爆发新机遇。供给侧:全球通信设施、5G建设逐步推进,高速率、低延时、高质量的特性将解锁下一代社交网络、VR 和 AR 等新场景新业务。需求侧:远程社交、居家办公成为常态,在线教育、游戏社交、远程视频等需求激增,行业蓝海空间无限。

环信,国内即时通讯行业的领行者,诚邀您参与环信IM here 开发者沙龙。

在这里,分享技术亮点、汇聚技术创新、畅聊行业趋势。

在这里,与环信专家1V1,现场给开发者解决集成BUG和问题!

在这里,与伙伴同行,深度交流,渠道资源共享,共塑行业新生态。


02

时间地点

沙龙时间:5月22日下午

沙龙地点:北京海淀区中关村南大街2号数码大厦A座31层

面向人群:环信生态开发者、IM客户、渠道合作伙伴


03

日程安排

14:00-14:30   签到

14:30-14:35   5分钟活动流程介绍

14:35-15:00   环信IM+ Telco产品+MQTT产品介绍

15:00-15:30   环信开发者面对面(现场技术答疑)

15:30-15:40   茶歇

15:40-16:20   环信生态渠道+伙伴交流

16:20-16:30   抽奖+彩蛋


05

温馨提示


1、现场代码演示建议自带电脑,伙伴产品交流请准备好产品ppt。

2、活动仅限20人,报名后会与您联系确认。


06

关于主办方

环信成立于2013年,是国内领先的企业级软件服务提供商,荣膺“Gartner Cool Vendor”。旗下主要产品线包括国内上线最早规模最大的即时通讯能力PaaS平台——环信即时通讯云,国内领先的全场景音视频PaaS平台——环信实时音视频云,全媒体智能客服SaaS平台——环信客服云,以及企业级人工智能服务能力平台——环信机器人。是国内最早覆盖云通讯、云客服、智能机器人的一体化产品技术储备企服公司。



(扫码报名吧~)

收起阅读 »

CocoaAsyncSocket源码Write(总结篇 二)

if (hasNewDataToWrite) { //拿到buffer偏移位置 const uint8_t *buffer = (const uint8_t *)[curr...
继续阅读 »
  • 下面是写入的三种方式

    • CFStreamForTLS


  • SSL写的方式


if (hasNewDataToWrite)
{
//拿到buffer偏移位置
const uint8_t *buffer = (const uint8_t *)[currentWrite->buffer bytes]
+ currentWrite->bytesDone
+ bytesWritten;

//得到需要读的长度
NSUInteger bytesToWrite = [currentWrite->buffer length] - currentWrite->bytesDone - bytesWritten;
//如果大于最大值,就等于最大值
if (bytesToWrite > SIZE_MAX) // NSUInteger may be bigger than size_t (write param 3)
{
bytesToWrite = SIZE_MAX;
}

size_t bytesRemaining = bytesToWrite;

//循环值
BOOL keepLooping = YES;
while (keepLooping)
{
//最大写的字节数?
const size_t sslMaxBytesToWrite = 32768;
//得到二者小的,得到需要写的字节数
size_t sslBytesToWrite = MIN(bytesRemaining, sslMaxBytesToWrite);
//已写字节数
size_t sslBytesWritten = 0;

//将结果从buffer中写到socket上(经由了这个函数,数据就加密了)
result = SSLWrite(sslContext, buffer, sslBytesToWrite, &sslBytesWritten);

//如果写成功
if (result == noErr)
{
//buffer指针偏移
buffer += sslBytesWritten;
//加上些的数量
bytesWritten += sslBytesWritten;
//减去仍需写的数量
bytesRemaining -= sslBytesWritten;
//判断是否需要继续循环
keepLooping = (bytesRemaining > 0);
}
else
{
//IO阻塞
if (result == errSSLWouldBlock)
{
waiting = YES;
//得到缓存的大小(后续长度会被自己写到SSL缓存去)
sslWriteCachedLength = sslBytesToWrite;
}
else
{
error = [self sslError:result];
}

//跳出循环
keepLooping = NO;
}

} // while (keepLooping)


这里还有对残余数据的处理:是通过指针buffer获取我们的keepLooping循环值,循环进行写入

 //将结果从buffer中写到socket上(经由了这个函数,数据就加密了)
result = SSLWrite(sslContext, buffer, sslBytesToWrite, &sslBytesWritten);
  • 普通socket写入



  • 也做了完成判断
//判断是否完成
BOOL done = NO;
//判断已写大小
if (bytesWritten > 0)
{
// Update total amount read for the current write
//更新当前总共写的大小
currentWrite->bytesDone += bytesWritten;
LogVerbose(@"currentWrite->bytesDone = %lu", (unsigned long)currentWrite->bytesDone);

// Is packet done?
//判断当前写包是否写完
done = (currentWrite->bytesDone == [currentWrite->buffer length]);
}

同样为的也是三种数据包:一次性包,粘包,断包

  //如果完成了
if (done)
{
//完成操作
[self completeCurrentWrite];

if (!error)
{
dispatch_async(socketQueue, ^{ @autoreleasepool{
//开始下一次的读取任务
[self maybeDequeueWrite];
}});
}
}
//未完成
else
{
// We were unable to finish writing the data,
// so we're waiting for another callback to notify us of available space in the lower-level output buffer.
//如果不是等待 而且没有出错
if (!waiting && !error)
{
// This would be the case if our write was able to accept some data, but not all of it.
//这是我们写了一部分数据的情况。

//去掉可接受数据的标记
flags &= ~kSocketCanAcceptBytes;
//再去等读source触发
if (![self usingCFStreamForTLS])
{
[self resumeWriteSource];
}
}

//如果已写大于0
if (bytesWritten > 0)
{
// We're not done with the entire write, but we have written some bytes

__strong id theDelegate = delegate;

//调用写的进度代理
if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didWritePartialDataOfLength:tag:)])
{
long theWriteTag = currentWrite->tag;

dispatch_async(delegateQueue, ^{ @autoreleasepool {

[theDelegate socket:self didWritePartialDataOfLength:bytesWritten tag:theWriteTag];
}});
}
}
}



那么整个 CocoaAsyncSocket Wirte的解析就到这里完成了,当你读完前面几篇,再来看这篇就跟喝水一样,故:知识在于积累


由于该框架源码篇幅过大,且有大部分相对抽象的数据操作逻辑,尽管楼主竭力想要简单的去陈述相关内容,但是阅读起来仍会有一定的难度


作者:Cooci
链接:https://www.jianshu.com/p/dfacaf629571


收起阅读 »

CocoaAsyncSocket源码Write(总结篇)

我们切入口//写数据对外方法 - (void)writeData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag { if ([data length] == 0) re...
继续阅读 »



我们切入口

//写数据对外方法
- (void)writeData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag
{
if ([data length] == 0) return;

//初始化写包
GCDAsyncWritePacket *packet = [[GCDAsyncWritePacket alloc] initWithData:data timeout:timeout tag:tag];

dispatch_async(socketQueue, ^{ @autoreleasepool {

LogTrace();

if ((flags & kSocketStarted) && !(flags & kForbidReadsWrites))
{
[writeQueue addObject:packet];
//离队执行
[self maybeDequeueWrite];
}
}});

// Do not rely on the block being run in order to release the packet,
// as the queue might get released without the block completing.
}



写法类似Read

  • 初始化写包 :GCDAsyncWritePacket
  • 写入包放入我们的写入队列(数组)[writeQueue addObject:packet];
  • 离队执行 [self maybeDequeueWrite];

写入包,添加队列没什么讲的了,

下面重点解析maybeDequeueWrite

- (void)maybeDequeueWrite
{
LogTrace();
NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");


// If we're not currently processing a write AND we have an available write stream
if ((currentWrite == nil) && (flags & kConnected))
{
if ([writeQueue count] > 0)
{
// Dequeue the next object in the write queue
currentWrite = [writeQueue objectAtIndex:0];
[writeQueue removeObjectAtIndex:0];

//TLS
if ([currentWrite isKindOfClass:[GCDAsyncSpecialPacket class]])
{
LogVerbose(@"Dequeued GCDAsyncSpecialPacket");

// Attempt to start TLS
flags |= kStartingWriteTLS;

// This method won't do anything unless both kStartingReadTLS and kStartingWriteTLS are set
[self maybeStartTLS];
}
else
{
LogVerbose(@"Dequeued GCDAsyncWritePacket");

// Setup write timer (if needed)
[self setupWriteTimerWithTimeout:currentWrite->timeout];

// Immediately write, if possible
[self doWriteData];
}
}
//写超时导致的错误
else if (flags & kDisconnectAfterWrites)
{
//如果没有可读任务,直接关闭socket
if (flags & kDisconnectAfterReads)
{
if (([readQueue count] == 0) && (currentRead == nil))
{
[self closeWithError:nil];
}
}
else
{
[self closeWithError:nil];
}
}
}
}
  • 我们首先做了一些是否连接,写入队列任务是否大于0等等一些判断
  • 接着我们从全局的writeQueue中,拿到第一条任务,去做读取,我们来判断这个任务的类型,如果是GCDAsyncSpecialPacket类型的,我们将开启TLS认证
  • 如果是是我们之前加入队列中的GCDAsyncWritePacket类型,我们则开始读取操作,调用doWriteData
  • 如果没有可读任务,直接关闭socket

其中 maybeStartTLS我们解析过了,我们就只要来看看核心写入方法:doWriteData

- (void)doWriteData
{
LogTrace();

// This method is called by the writeSource via the socketQueue

//错误,不写
if ((currentWrite == nil) || (flags & kWritesPaused))
{
LogVerbose(@"No currentWrite or kWritesPaused");

// Unable to write at this time

//
if ([self usingCFStreamForTLS])
{
// CFWriteStream only fires once when there is available data.
// It won't fire again until we've invoked CFWriteStreamWrite.
}
else
{
// If the writeSource is firing, we need to pause it
// or else it will continue to fire over and over again.

//如果socket中可接受写数据,防止反复触发写source,挂起
if (flags & kSocketCanAcceptBytes)
{
[self suspendWriteSource];
}
}
return;
}

//如果当前socket无法在写数据了
if (!(flags & kSocketCanAcceptBytes))
{
LogVerbose(@"No space available to write...");

// No space available to write.

//如果不是cfstream
if (![self usingCFStreamForTLS])
{
// Need to wait for writeSource to fire and notify us of
// available space in the socket's internal write buffer.
//则恢复写source,当有空间去写的时候,会触发回来
[self resumeWriteSource];
}
return;
}

//如果正在进行TLS认证
if (flags & kStartingWriteTLS)
{
LogVerbose(@"Waiting for SSL/TLS handshake to complete");

// The writeQueue is waiting for SSL/TLS handshake to complete.

if (flags & kStartingReadTLS)
{
//如果是安全通道,并且I/O阻塞,那么重新去握手
if ([self usingSecureTransportForTLS] && lastSSLHandshakeError == errSSLWouldBlock)
{
// We are in the process of a SSL Handshake.
// We were waiting for available space in the socket's internal OS buffer to continue writing.

[self ssl_continueSSLHandshake];
}
}
//说明不走`TLS`了,因为只支持写的TLS
else
{
// We are still waiting for the readQueue to drain and start the SSL/TLS process.
// We now know we can write to the socket.

//挂起写source
if (![self usingCFStreamForTLS])
{
// Suspend the write source or else it will continue to fire nonstop.
[self suspendWriteSource];
}
}

return;
}

// Note: This method is not called if currentWrite is a GCDAsyncSpecialPacket (startTLS packet)

//开始写数据

BOOL waiting = NO;
NSError *error = nil;
size_t bytesWritten = 0;

//安全连接
if (flags & kSocketSecure)
{
//CFStreamForTLS
if ([self usingCFStreamForTLS])
{
#if TARGET_OS_IPHONE

//
// Writing data using CFStream (over internal TLS)
//

const uint8_t *buffer = (const uint8_t *)[currentWrite->buffer bytes] + currentWrite->bytesDone;

//写的长度为buffer长度-已写长度
NSUInteger bytesToWrite = [currentWrite->buffer length] - currentWrite->bytesDone;

if (bytesToWrite > SIZE_MAX) // NSUInteger may be bigger than size_t (write param 3)
{
bytesToWrite = SIZE_MAX;
}
//往writeStream中写入数据, bytesToWrite写入的长度
CFIndex result = CFWriteStreamWrite(writeStream, buffer, (CFIndex)bytesToWrite);
LogVerbose(@"CFWriteStreamWrite(%lu) = %li", (unsigned long)bytesToWrite, result);

//写错误
if (result < 0)
{
error = (__bridge_transfer NSError *)CFWriteStreamCopyError(writeStream);
}
else
{
//拿到已写字节数
bytesWritten = (size_t)result;

// We always set waiting to true in this scenario.
//我们经常设置等待来信任这个方案
// CFStream may have altered our underlying socket to non-blocking.
//CFStream很可能修改socket为非阻塞
// Thus if we attempt to write without a callback, we may end up blocking our queue.
//因此,我们尝试去写,而不用回调。 我们可能终止我们的队列。
waiting = YES;
}

#endif
}
//SSL写的方式
else
{
// We're going to use the SSLWrite function.
//
// OSStatus SSLWrite(SSLContextRef context, const void *data, size_t dataLength, size_t *processed)
//
// Parameters:
// context - An SSL session context reference.
// data - A pointer to the buffer of data to write.
// dataLength - The amount, in bytes, of data to write.
// processed - On return, the length, in bytes, of the data actually written.
//
// It sounds pretty straight-forward,
//看起来相当直观,但是这里警告你应注意。
// but there are a few caveats you should be aware of.
//
// The SSLWrite method operates in a non-obvious (and rather annoying) manner.
// According to the documentation:
// 这个SSLWrite方法使用着一个不明显的方法(相当讨厌)导致了下面这些事。
// Because you may configure the underlying connection to operate in a non-blocking manner,
//因为你要辨别出下层连接 操纵 非阻塞的方法,一个写的操作将返回errSSLWouldBlock,表明需要写的数据少了。
// a write operation might return errSSLWouldBlock, indicating that less data than requested
// was actually transferred. In this case, you should repeat the call to SSLWrite until some
//在这种情况下你应该重复调用SSLWrite,直到一些其他结果被返回
// other result is returned.
// This sounds perfect, but when our SSLWriteFunction returns errSSLWouldBlock,
//这样听起来很完美,但是当SSLWriteFunction返回errSSLWouldBlock,SSLWrite返回但是却设置了进度长度?
// then the SSLWrite method returns (with the proper errSSLWouldBlock return value),
// but it sets processed to dataLength !!
//
// In other words, if the SSLWrite function doesn't completely write all the data we tell it to,
//另外,SSLWrite方法没有完整的写完我们给的所有数据,因此它没有告诉我们到底写了多少数据,
// then it doesn't tell us how many bytes were actually written. So, for example, if we tell it to
//因此。举个例子,如果我们告诉它去写256个字节,它可能只写了128个字节,但是告诉我们写了0个字节
// write 256 bytes then it might actually write 128 bytes, but then report 0 bytes written.
//
// You might be wondering:
//你可能会觉得奇怪,如果这个方法不告诉我们写了多少字节,那么该如何去更新参数来应对下一次的SSLWrite?
// If the SSLWrite function doesn't tell us how many bytes were written,
// then how in the world are we supposed to update our parameters (buffer & bytesToWrite)
// for the next time we invoke SSLWrite?
//
// The answer is that SSLWrite cached all the data we told it to write,
//答案就是,SSLWrite缓存了所有的数据我们要它写的。并且拉出这些数据,只要我们下次调用SSLWrite。
// and it will push out that data next time we call SSLWrite.

// If we call SSLWrite with new data, it will push out the cached data first, and then the new data.
//如果我们用新的data调用SSLWrite,它会拉出这些缓存的数据,然后才轮到新数据
// If we call SSLWrite with empty data, then it will simply push out the cached data.
// 如果我们调用SSLWrite用一个空的数据,则它仅仅会拉出缓存数据。
// For this purpose we're going to break large writes into a series of smaller writes.
//为了这个目的,我们去分开一个大数据写成一连串的小数据,它允许我们去报告进度给代理。
// This allows us to report progress back to the delegate.

OSStatus result;

//SSL缓存的写的数据
BOOL hasCachedDataToWrite = (sslWriteCachedLength > 0);
//是否有新数据要写
BOOL hasNewDataToWrite = YES;

if (hasCachedDataToWrite)
{
size_t processed = 0;

//去写空指针,就是拉取了所有的缓存SSL数据
result = SSLWrite(sslContext, NULL, 0, &processed);

//如果写成功
if (result == noErr)
{
//拿到写的缓存长度
bytesWritten = sslWriteCachedLength;
//置空缓存长度
sslWriteCachedLength = 0;
//判断当前需要写的buffer长度,是否和已写的大小+缓存 大小相等
if ([currentWrite->buffer length] == (currentWrite->bytesDone + bytesWritten))
{
// We've written all data for the current write.
//相同则不需要再写新数据了
hasNewDataToWrite = NO;
}
}
//有错
else
{
//IO阻塞,等待
if (result == errSSLWouldBlock)
{
waiting = YES;
}
//报错
else
{
error = [self sslError:result];
}

// Can't write any new data since we were unable to write the cached data.
//如果读写cache出错,我们暂时不能去读后面的数据
hasNewDataToWrite = NO;
}
}

//如果还有数据去读
if (hasNewDataToWrite)
{
//拿到buffer偏移位置
const uint8_t *buffer = (const uint8_t *)[currentWrite->buffer bytes]
+ currentWrite->bytesDone
+ bytesWritten;

//得到需要读的长度
NSUInteger bytesToWrite = [currentWrite->buffer length] - currentWrite->bytesDone - bytesWritten;
//如果大于最大值,就等于最大值
if (bytesToWrite > SIZE_MAX) // NSUInteger may be bigger than size_t (write param 3)
{
bytesToWrite = SIZE_MAX;
}

size_t bytesRemaining = bytesToWrite;

//循环值
BOOL keepLooping = YES;
while (keepLooping)
{
//最大写的字节数?
const size_t sslMaxBytesToWrite = 32768;
//得到二者小的,得到需要写的字节数
size_t sslBytesToWrite = MIN(bytesRemaining, sslMaxBytesToWrite);
//已写字节数
size_t sslBytesWritten = 0;

//将结果从buffer中写到socket上(经由了这个函数,数据就加密了)
result = SSLWrite(sslContext, buffer, sslBytesToWrite, &sslBytesWritten);

//如果写成功
if (result == noErr)
{
//buffer指针偏移
buffer += sslBytesWritten;
//加上些的数量
bytesWritten += sslBytesWritten;
//减去仍需写的数量
bytesRemaining -= sslBytesWritten;
//判断是否需要继续循环
keepLooping = (bytesRemaining > 0);
}
else
{
//IO阻塞
if (result == errSSLWouldBlock)
{
waiting = YES;
//得到缓存的大小(后续长度会被自己写到SSL缓存去)
sslWriteCachedLength = sslBytesToWrite;
}
else
{
error = [self sslError:result];
}

//跳出循环
keepLooping = NO;
}

} // while (keepLooping)

} // if (hasNewDataToWrite)
}
}

//普通socket
else
{
//
// Writing data directly over raw socket
//

//拿到当前socket
int socketFD = (socket4FD != SOCKET_NULL) ? socket4FD : (socket6FD != SOCKET_NULL) ? socket6FD : socketUN;

//得到指针偏移
const uint8_t *buffer = (const uint8_t *)[currentWrite->buffer bytes] + currentWrite->bytesDone;

NSUInteger bytesToWrite = [currentWrite->buffer length] - currentWrite->bytesDone;

if (bytesToWrite > SIZE_MAX) // NSUInteger may be bigger than size_t (write param 3)
{
bytesToWrite = SIZE_MAX;
}
//直接写
ssize_t result = write(socketFD, buffer, (size_t)bytesToWrite);
LogVerbose(@"wrote to socket = %zd", result);

// Check results
if (result < 0)
{
//IO阻塞
if (errno == EWOULDBLOCK)
{
waiting = YES;
}
else
{
error = [self errnoErrorWithReason:@"Error in write() function"];
}
}
else
{
//得到写的大小
bytesWritten = result;
}
}

// We're done with our writing.
// If we explictly ran into a situation where the socket told us there was no room in the buffer,
// then we immediately resume listening for notifications.
//
// We must do this before we dequeue another write,
// as that may in turn invoke this method again.
//
// Note that if CFStream is involved, it may have maliciously put our socket in blocking mode.
//注意,如果用CFStream,很可能会被恶意的放置数据 阻塞socket

//如果等待,则恢复写source
if (waiting)
{
//把socket可接受数据的标记去掉
flags &= ~kSocketCanAcceptBytes;

if (![self usingCFStreamForTLS])
{
//恢复写source
[self resumeWriteSource];
}
}

// Check our results

//判断是否完成
BOOL done = NO;
//判断已写大小
if (bytesWritten > 0)
{
// Update total amount read for the current write
//更新当前总共写的大小
currentWrite->bytesDone += bytesWritten;
LogVerbose(@"currentWrite->bytesDone = %lu", (unsigned long)currentWrite->bytesDone);

// Is packet done?
//判断当前写包是否写完
done = (currentWrite->bytesDone == [currentWrite->buffer length]);
}

//如果完成了
if (done)
{
//完成操作
[self completeCurrentWrite];

if (!error)
{
dispatch_async(socketQueue, ^{ @autoreleasepool{
//开始下一次的读取任务
[self maybeDequeueWrite];
}});
}
}
//未完成
else
{
// We were unable to finish writing the data,
// so we're waiting for another callback to notify us of available space in the lower-level output buffer.
//如果不是等待 而且没有出错
if (!waiting && !error)
{
// This would be the case if our write was able to accept some data, but not all of it.
//这是我们写了一部分数据的情况。

//去掉可接受数据的标记
flags &= ~kSocketCanAcceptBytes;
//再去等读source触发
if (![self usingCFStreamForTLS])
{
[self resumeWriteSource];
}
}

//如果已写大于0
if (bytesWritten > 0)
{
// We're not done with the entire write, but we have written some bytes

__strong id theDelegate = delegate;

//调用写的进度代理
if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didWritePartialDataOfLength:tag:)])
{
long theWriteTag = currentWrite->tag;

dispatch_async(delegateQueue, ^{ @autoreleasepool {

[theDelegate socket:self didWritePartialDataOfLength:bytesWritten tag:theWriteTag];
}});
}
}
}

// Check for errors
//如果有错,则报错断开连接
if (error)
{
[self closeWithError:[self errnoErrorWithReason:@"Error in write() function"]];
}

// Do not add any code here without first adding a return statement in the error case above.
}

  • 这里不同doRead的是没有提前通过flush写入链路层
  • 如果socket中可接受写数据,防止反复触发写source,挂起
  • 如果当前socket无法在写数据了,则恢复写source,当有空间去写的时候,会触发回来



  • 如果正在进行TLS认证 如果是安全通道,并且I/O阻塞,那么重新去握手








    收起阅读 »

    CocoaAsyncSocket源码Read(七)

    最后还是提下SSL的回调方法,数据解密的地方。两种模式的回调;Part7.两种SSL数据解密位置:1.CFStream:当我们调用:CFIndex result = CFReadStreamRead(readStream, buffer, defaultByt...
    继续阅读 »

    最后还是提下SSL的回调方法,数据解密的地方。两种模式的回调;

    Part7.两种SSL数据解密位置:

    1.CFStream:当我们调用:

    CFIndex result = CFReadStreamRead(readStream, buffer, defaultBytesToRead);


    数据就会被解密。
    2.SSL安全通道:当我们调用:

    OSStatus result = SSLRead(sslContext, buffer, (size_t)estimatedBytesAvailable, &bytesRead);


    会触发SSL绑定的函数回调:

    //读函数
    static OSStatus SSLReadFunction(SSLConnectionRef connection, void *data, size_t *dataLength)
    {
    //拿到socket
    GCDAsyncSocket *asyncSocket = (__bridge GCDAsyncSocket *)connection;

    //断言当前为socketQueue
    NSCAssert(dispatch_get_specific(asyncSocket->IsOnSocketQueueOrTargetQueueKey), @"What the deuce?");

    //读取数据,并且返回状态码
    return [asyncSocket sslReadWithBuffer:data length:dataLength];
    }

    接着我们在下面的方法进行了数据读取:

    //SSL读取数据最终方法
    - (OSStatus)sslReadWithBuffer:(void *)buffer length:(size_t *)bufferLength
    {
    //...
    ssize_t result = read(socketFD, buf, bytesToRead);
    //....
    }

    其实read这一步,数据是没有被解密的,然后传递回SSLReadFunction,在传递到SSLRead内部,数据被解密。


    本篇重点涉及该框架是如何利用缓冲区对数据进行读取、以及各种情况下的数据包处理,其中还包括普通的、和基于TLS的不同读取操作等等。
    注:由于该框架源码篇幅过大,且有大部分相对抽象的数据操作逻辑,尽管楼主竭力想要简单的去陈述相关内容,但是阅读起来仍会有一定的难度。


    附上一张核心代码逻辑图


    文中涉及代码比较多,建议大家结合源码一起阅读比较容易能加深理解

    之后会涉及到
    CocoaAsyncSocket
  • 初始化写包 :GCDAsyncWritePacket
  • 写入包放入我们的写入队列(数组)[writeQueue addObject:packet];
  • 离队执行 [self maybeDequeueWrite];


  • 主要介绍GCDAsyncSpecialPacketGCDAsyncWritePacket类型数据的处理,还有核心写入方法doWriteData三种不同方式的写入



    作者:Cooci
    链接:https://www.jianshu.com/p/dfacaf629571





    收起阅读 »

    CocoaAsyncSocket源码Read(六)

    讲了半天理论,想必大家看的有点不耐烦了,接下来看看代码实际是如何处理的吧:step1:从prebuffer中读取数据://先从提前缓冲区去读,如果缓冲区可读大小大于0 if ([preBuffer availableBytes] > 0) { ...
    继续阅读 »

    讲了半天理论,想必大家看的有点不耐烦了,接下来看看代码实际是如何处理的吧:

    step1:从prebuffer中读取数据:
    //先从提前缓冲区去读,如果缓冲区可读大小大于0
    if ([preBuffer availableBytes] > 0)
    {
    // There are 3 types of read packets:
    //
    // 1) Read all available data.
    // 2) Read a specific length of data.
    // 3) Read up to a particular terminator.
    //3种类型的读法,1、全读、2、读取特定长度、3、读取到一个明确的界限

    NSUInteger bytesToCopy;

    //如果当前读的数据界限不为空
    if (currentRead->term != nil)
    {
    // Read type #3 - read up to a terminator
    //直接读到界限
    bytesToCopy = [currentRead readLengthForTermWithPreBuffer:preBuffer found:&done];
    }
    else
    {
    // Read type #1 or #2
    //读取数据,读到指定长度或者数据包的长度为止
    bytesToCopy = [currentRead readLengthForNonTermWithHint:[preBuffer availableBytes]];
    }

    // Make sure we have enough room in the buffer for our read.
    //从上两步拿到我们需要读的长度,去看看有没有空间去存储
    [currentRead ensureCapacityForAdditionalDataOfLength:bytesToCopy];

    // Copy bytes from prebuffer into packet buffer

    //拿到我们需要追加数据的指针位置
    #pragma mark - 不明白
    //当前读的数据 + 开始偏移 + 已经读完的??
    uint8_t *buffer = (uint8_t *)[currentRead->buffer mutableBytes] + currentRead->startOffset +
    currentRead->bytesDone;
    //从prebuffer处复制过来数据,bytesToCopy长度
    memcpy(buffer, [preBuffer readBuffer], bytesToCopy);

    // Remove the copied bytes from the preBuffer
    //从preBuffer移除掉已经复制的数据
    [preBuffer didRead:bytesToCopy];

    LogVerbose(@"copied(%lu) preBufferLength(%zu)", (unsigned long)bytesToCopy, [preBuffer availableBytes]);

    // Update totals

    //已读的数据加上
    currentRead->bytesDone += bytesToCopy;
    //当前已读的数据加上
    totalBytesReadForCurrentRead += bytesToCopy;

    // Check to see if the read operation is done
    //判断是不是读完了
    if (currentRead->readLength > 0)
    {
    // Read type #2 - read a specific length of data
    //如果已读 == 需要读的长度,说明已经读完
    done = (currentRead->bytesDone == currentRead->readLength);
    }
    //判断界限标记
    else if (currentRead->term != nil)
    {
    // Read type #3 - read up to a terminator

    // Our 'done' variable was updated via the readLengthForTermWithPreBuffer:found: method
    //如果没做完,且读的最大长度大于0,去判断是否溢出
    if (!done && currentRead->maxLength > 0)
    {
    // We're not done and there's a set maxLength.
    // Have we reached that maxLength yet?

    //如果已读的大小大于最大的大小,则报溢出错误
    if (currentRead->bytesDone >= currentRead->maxLength)
    {
    error = [self readMaxedOutError];
    }
    }
    }
    else
    {
    // Read type #1 - read all available data
    //
    // We're done as soon as
    // - we've read all available data (in prebuffer and socket)
    // - we've read the maxLength of read packet.
    //判断已读大小和最大大小是否相同,相同则读完
    done = ((currentRead->maxLength > 0) && (currentRead->bytesDone == currentRead->maxLength));
    }

    }


    这个方法就是利用我们之前提到的3种类型,来判断数据包需要读取的长度,然后调用:


    memcpy(buffer, [preBuffer readBuffer], bytesToCopy);


    把数据从preBuffer中,移到了currentRead数据包中。

    step2:从socket中读取数据:
    // 从socket中去读取

    //是否读到EOFException ,这个错误指的是文件结尾了还在继续读,就会导致这个错误被抛出
    BOOL socketEOF = (flags & kSocketHasReadEOF) ? YES : NO; // Nothing more to read via socket (end of file)

    //如果没完成,且没错,没读到结尾,且没有可读数据了
    BOOL waiting = !done && !error && !socketEOF && !hasBytesAvailable; // Ran out of data, waiting for more

    //如果没完成,且没错,没读到结尾,有可读数据
    if (!done && !error && !socketEOF && hasBytesAvailable)
    {
    //断言,有可读数据
    NSAssert(([preBuffer availableBytes] == 0), @"Invalid logic");
    //是否读到preBuffer中去
    BOOL readIntoPreBuffer = NO;
    uint8_t *buffer = NULL;
    size_t bytesRead = 0;

    //如果flag标记为安全socket
    if (flags & kSocketSecure)
    {
    //...类似flushSSLBuffer的一系列操作
    }
    else
    {
    // Normal socket operation
    //普通的socket 操作

    NSUInteger bytesToRead;

    // There are 3 types of read packets:
    //
    // 1) Read all available data.
    // 2) Read a specific length of data.
    // 3) Read up to a particular terminator.

    //和上面类似,读取到边界标记??不是吧
    if (currentRead->term != nil)
    {
    // Read type #3 - read up to a terminator

    //读这个长度,如果到maxlength,就用maxlength。看如果可用空间大于需要读的空间,则不用prebuffer
    bytesToRead = [currentRead readLengthForTermWithHint:estimatedBytesAvailable
    shouldPreBuffer:&readIntoPreBuffer];
    }

    else
    {
    // Read type #1 or #2
    //直接读这个长度,如果到maxlength,就用maxlength
    bytesToRead = [currentRead readLengthForNonTermWithHint:estimatedBytesAvailable];
    }

    //大于最大值,则先读最大值
    if (bytesToRead > SIZE_MAX) { // NSUInteger may be bigger than size_t (read param 3)
    bytesToRead = SIZE_MAX;
    }

    // Make sure we have enough room in the buffer for our read.
    //
    // We are either reading directly into the currentRead->buffer,
    // or we're reading into the temporary preBuffer.

    if (readIntoPreBuffer)
    {
    [preBuffer ensureCapacityForWrite:bytesToRead];

    buffer = [preBuffer writeBuffer];
    }
    else
    {
    [currentRead ensureCapacityForAdditionalDataOfLength:bytesToRead];

    buffer = (uint8_t *)[currentRead->buffer mutableBytes]
    + currentRead->startOffset
    + currentRead->bytesDone;
    }

    // Read data into buffer

    int socketFD = (socket4FD != SOCKET_NULL) ? socket4FD : (socket6FD != SOCKET_NULL) ? socket6FD : socketUN;
    #pragma mark - 开始读取数据,最普通的形式 read

    //读数据
    ssize_t result = read(socketFD, buffer, (size_t)bytesToRead);
    LogVerbose(@"read from socket = %i", (int)result);
    //读取错误
    if (result < 0)
    {
    //EWOULDBLOCK IO阻塞
    if (errno == EWOULDBLOCK)
    //先等待
    waiting = YES;
    else
    //得到错误
    error = [self errnoErrorWithReason:@"Error in read() function"];
    //把可读取的长度设置为0
    socketFDBytesAvailable = 0;
    }
    //读到边界了
    else if (result == 0)
    {
    socketEOF = YES;
    socketFDBytesAvailable = 0;
    }
    //正常
    else
    {
    //设置读到的数据长度
    bytesRead = result;

    //如果读到的数据小于应该读的长度,说明这个包没读完
    if (bytesRead < bytesToRead)
    {
    // The read returned less data than requested.
    // This means socketFDBytesAvailable was a bit off due to timing,
    // because we read from the socket right when the readSource event was firing.
    socketFDBytesAvailable = 0;
    }
    //正常
    else
    {
    //如果 socketFDBytesAvailable比读了的数据小的话,直接置为0
    if (socketFDBytesAvailable <= bytesRead)
    socketFDBytesAvailable = 0;
    //减去已读大小
    else
    socketFDBytesAvailable -= bytesRead;
    }
    //如果 socketFDBytesAvailable 可读数量为0,把读的状态切换为等待
    if (socketFDBytesAvailable == 0)
    {
    waiting = YES;
    }
    }
    }


    本来想讲点什么。。发现确实没什么好讲的,无非就是判断应该读取的长度,然后调用:

    ssize_t result = read(socketFD, buffer, (size_t)bytesToRead);

    socket中得到读取的实际长度。

    唯一需要讲一下的可能是数据流向的问题,这里调用:

    bytesToRead = [currentRead readLengthForTermWithHint:estimatedBytesAvailable shouldPreBuffer:&readIntoPreBuffer];

    来判断数据是否先流向prebuffer,还是直接流向currentRead,而SSL的读取中也有类似方法:

    - (NSUInteger)optimalReadLengthWithDefault:(NSUInteger)defaultValue shouldPreBuffer:(BOOL *)shouldPreBufferPtr

    这个方法核心的思路就是,如果当前读取包,长度给明了,则直接流向currentRead,如果数据长度不清楚,那么则去判断这一次读取的长度,和currentRead可用空间长度去对比,如果长度比currentRead可用空间小,则流向currentRead,否则先用prebuffer来缓冲。

    至于细节方面,大家对着github中的源码注释看看吧,这么大篇幅的业务代码,一行行讲确实没什么意义。

    走完这两步读取,接着就是第三步:

    step3:判断数据包完成程度:

    这里有3种情况:
    1.数据包刚好读完;2.数据粘包;3.数据断包;
    注:这里判断粘包断包的长度,都是我们一开始调用read方法给的长度或者分界符得出的。

    很显然,第一种就什么都不用处理,完美匹配。
    第二种情况,我们把需要的长度放到currentRead,多余的长度放到prebuffer中去。
    第三种情况,数据还没读完,我们暂时为未读完。

    这里就不贴代码了。

    就这样普通读取数据的整个流程就走完了,而SSL的两种模式,和上述基本一致。

    我们接着根据之前读取的结果,来判断数据是否读完:

    //检查是否读完
    if (done)
    {
    //完成这次数据的读取
    [self completeCurrentRead];
    //如果没出错,没有到边界,prebuffer中还有可读数据
    if (!error && (!socketEOF || [preBuffer availableBytes] > 0))
    {
    //让读操作离队,继续进行下一次读取
    [self maybeDequeueRead];
    }
    }


    如果读完,则去做读完的操作,并且进行下一次读取。

    我们来看看读完的操作:
    //完成了这次的读数据
    - (void)completeCurrentRead
    {
    LogTrace();
    //断言currentRead
    NSAssert(currentRead, @"Trying to complete current read when there is no current read.");

    //结果数据
    NSData *result = nil;

    //如果是我们自己创建的Buffer
    if (currentRead->bufferOwner)
    {
    // We created the buffer on behalf of the user.
    // Trim our buffer to be the proper size.
    //修剪buffer到合适的大小
    //把大小设置到我们读取到的大小
    [currentRead->buffer setLength:currentRead->bytesDone];
    //赋值给result
    result = currentRead->buffer;
    }
    else
    {
    // We did NOT create the buffer.
    // The buffer is owned by the caller.
    // Only trim the buffer if we had to increase its size.
    //这是调用者的data,我们只会去加大尺寸
    if ([currentRead->buffer length] > currentRead->originalBufferLength)
    {
    //拿到的读的size
    NSUInteger readSize = currentRead->startOffset + currentRead->bytesDone;
    //拿到原始尺寸
    NSUInteger origSize = currentRead->originalBufferLength;

    //取得最大的
    NSUInteger buffSize = MAX(readSize, origSize);
    //把buffer设置为较大的尺寸
    [currentRead->buffer setLength:buffSize];
    }
    //拿到数据的头指针
    uint8_t *buffer = (uint8_t *)[currentRead->buffer mutableBytes] + currentRead->startOffset;

    //reslut为,从头指针开始到长度为写的长度 freeWhenDone为YES,创建完就释放buffer
    result = [NSData dataWithBytesNoCopy:buffer length:currentRead->bytesDone freeWhenDone:NO];
    }

    __strong id theDelegate = delegate;

    #pragma mark -总算到调用代理方法,接受到数据了
    if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didReadData:withTag:)])
    {
    //拿到当前的数据包
    GCDAsyncReadPacket *theRead = currentRead; // Ensure currentRead retained since result may not own buffer

    dispatch_async(delegateQueue, ^{ @autoreleasepool {
    //把result在代理queue中回调出去。
    [theDelegate socket:self didReadData:result withTag:theRead->tag];
    }});
    }
    //取消掉读取超时
    [self endCurrentRead];
    }


    这里对currentReaddata做了个长度的设置。然后调用代理把最终包给回调出去。最后关掉我们之前提到的读取超时。

    还是回到doReadData,就剩下最后一点处理了:

    //如果这次读的数量大于0
    else if (totalBytesReadForCurrentRead > 0)
    {
    // We're not done read type #2 or #3 yet, but we have read in some bytes

    __strong id theDelegate = delegate;

    //如果响应读数据进度的代理
    if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didReadPartialDataOfLength:tag:)])
    {
    long theReadTag = currentRead->tag;

    //代理queue中回调出去
    dispatch_async(delegateQueue, ^{ @autoreleasepool {

    [theDelegate socket:self didReadPartialDataOfLength:totalBytesReadForCurrentRead tag:theReadTag];
    }});
    }
    }


    这里未完成,如果这次读取大于0,如果响应读取进度的代理,则把当前进度回调出去。

    最后检查错误:
    //检查错误
    if (error)
    {
    //如果有错直接报错断开连接
    [self closeWithError:error];
    }
    //如果是读到边界错误
    else if (socketEOF)
    {
    [self doReadEOF];
    }

    //如果是等待
    else if (waiting)
    {
    //如果用的是CFStream,则读取数据和source无关
    //非CFStream形式
    if (![self usingCFStreamForTLS])
    {
    // Monitor the socket for readability (if we're not already doing so)
    //重新恢复source
    [self resumeReadSource];
    }
    }


    如果有错,直接断开socket,如果是边界错误,调用边界错误处理,如果是等待,说明当前包还没读完,如果非CFStreamTLS,则恢复source,等待下一次数据到达的触发。

    关于这个读取边界错误EOF,这里我简单的提下,其实它就是服务端发出一个边界错误,说明不会再有数据发送给我们了。我们讲无法再接收到数据,但是我们其实还是可以写数据,发送给服务端的。

    doReadEOF这个方法的处理,就是做了这么一件事。判断我们是否需要这种不可读,只能写的连接。

    我们来简单看看这个方法:
    Part6.读取边界错误处理:
    //读到EOFException,边界错误
    - (void)doReadEOF
    {
    LogTrace();
    //这个方法可能被调用很多次,如果读到EOF的时候,还有数据在prebuffer中,在调用doReadData之后?? 这个方法可能被持续的调用

    //标记为读EOF
    flags |= kSocketHasReadEOF;

    //如果是安全socket
    if (flags & kSocketSecure)
    {
    //去刷新sslbuffer中的数据
    [self flushSSLBuffers];
    }

    //标记是否应该断开连接
    BOOL shouldDisconnect = NO;
    NSError *error = nil;

    //如果状态为开始读写TLS
    if ((flags & kStartingReadTLS) || (flags & kStartingWriteTLS))
    {
    //我们得到EOF在开启TLS之前,这个TLS握手是不可能的,因此这是不可恢复的错误

    //标记断开连接
    shouldDisconnect = YES;
    //如果是安全的TLS,赋值错误
    if ([self usingSecureTransportForTLS])
    {
    error = [self sslError:errSSLClosedAbort];
    }
    }
    //如果是读流关闭状态
    else if (flags & kReadStreamClosed)
    {

    //不应该被关闭
    shouldDisconnect = NO;
    }
    else if ([preBuffer availableBytes] > 0)
    {
    //仍然有数据可读的时候不关闭
    shouldDisconnect = NO;
    }
    else if (config & kAllowHalfDuplexConnection)
    {

    //拿到socket
    int socketFD = (socket4FD != SOCKET_NULL) ? socket4FD : (socket6FD != SOCKET_NULL) ? socket6FD : socketUN;

    //轮询用的结构体

    /*
    struct pollfd {
    int fd; //文件描述符
    short events; //要求查询的事件掩码 监听的
    short revents; //返回的事件掩码 实际发生的
    };
    */


    struct pollfd pfd[1];
    pfd[0].fd = socketFD;
    //写数据不会导致阻塞。
    pfd[0].events = POLLOUT;
    //这个为当前实际发生的事情
    pfd[0].revents = 0;

    /*
    poll函数使用pollfd类型的结构来监控一组文件句柄,ufds是要监控的文件句柄集合,nfds是监控的文件句柄数量,timeout是等待的毫秒数,这段时间内无论I/O是否准备好,poll都会返回。timeout为负数表示无线等待,timeout为0表示调用后立即返回。执行结果:为0表示超时前没有任何事件发生;-1表示失败;成功则返回结构体中revents不为0的文件描述符个数。pollfd结构监控的事件类型如下:
    int poll(struct pollfd *ufds, unsigned int nfds, int timeout);
    */

    //阻塞的,但是timeout为0,则不阻塞,直接返回
    poll(pfd, 1, 0);

    //如果被触发的事件是写数据
    if (pfd[0].revents & POLLOUT)
    {
    // Socket appears to still be writeable

    //则标记为不关闭
    shouldDisconnect = NO;
    //标记为读流关闭
    flags |= kReadStreamClosed;

    // Notify the delegate that we're going half-duplex
    //通知代理,我们开始半双工
    __strong id theDelegate = delegate;

    //调用已经关闭读流的代理方法
    if (delegateQueue && [theDelegate respondsToSelector:@selector(socketDidCloseReadStream:)])
    {
    dispatch_async(delegateQueue, ^{ @autoreleasepool {

    [theDelegate socketDidCloseReadStream:self];
    }});
    }
    }
    else
    {
    //标记为断开
    shouldDisconnect = YES;
    }
    }
    else
    {
    shouldDisconnect = YES;
    }

    //如果应该断开
    if (shouldDisconnect)
    {
    if (error == nil)
    {
    //判断是否是安全TLS传输
    if ([self usingSecureTransportForTLS])
    {
    ///标记错误信息
    if (sslErrCode != noErr && sslErrCode != errSSLClosedGraceful)
    {
    error = [self sslError:sslErrCode];
    }
    else
    {
    error = [self connectionClosedError];
    }
    }
    else
    {
    error = [self connectionClosedError];
    }
    }
    //关闭socket
    [self closeWithError:error];
    }
    //不断开
    else
    {
    //如果不是用CFStream流
    if (![self usingCFStreamForTLS])
    {
    // Suspend the read source (if needed)
    //挂起读source
    [self suspendReadSource];
    }
    }
    }

    简单说一下,这个方法主要是对socket是否需要主动关闭进行了判断:这里仅仅以下3种情况,不会关闭socket

    1. 读流已经是关闭状态(如果加了这个标记,说明为半双工连接状态)。
    • preBuffer中还有可读数据,我们需要等数据读完才能关闭连接。
    • 配置标记为kAllowHalfDuplexConnection,我们则要开始半双工处理。我们调用了:

    poll(pfd, 1, 0);

    函数,如果触发了写事件POLLOUT,说明我们半双工连接成功,则我们可以在读流关闭的状态下,仍然可以向服务器写数据。

    其他情况下,一律直接关闭socket
    而不关闭的情况下,我们会挂起source。这样我们就只能可写不可读了。



    作者:Cooci_和谐学习_不急不躁
    链接:https://www.jianshu.com/p/5a2df8a6a54e
    来源:简书
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。









    收起阅读 »

    CocoaAsyncSocket源码Read(五)

    在我们来看flushSSLBuffers方法之前,我们先来看看这个一直提到的全局缓冲区prebuffer的定义,它其实就是下面这么一个类的实例:Part3.GCDAsyncSocketPreBuffer的定义@interface GCDAsyncSocketP...
    继续阅读 »

    在我们来看flushSSLBuffers方法之前,我们先来看看这个一直提到的全局缓冲区prebuffer的定义,它其实就是下面这么一个类的实例:

    Part3.GCDAsyncSocketPreBuffer的定义

    @interface GCDAsyncSocketPreBuffer : NSObject
    {
    //unsigned char
    //提前的指针,指向这块提前的缓冲区
    uint8_t *preBuffer;
    //size_t 它是一个与机器相关的unsigned类型,其大小足以保证存储内存中对象的大小。
    //它可以存储在理论上是可能的任何类型的数组的最大大小
    size_t preBufferSize;
    //读的指针
    uint8_t *readPointer;
    //写的指针
    uint8_t *writePointer;
    }

    里面存了3个指针,包括preBuffer起点指针、当前读写所处位置指针、以及一个preBufferSize,这个sizepreBuffer所指向的位置,在内存中分配的空间大小。

    我们来看看它的几个方法:

    //初始化
    - (id)initWithCapacity:(size_t)numBytes
    {
    if ((self = [super init]))
    {
    //设置size
    preBufferSize = numBytes;
    //申请size大小的内存给preBuffer
    preBuffer = malloc(preBufferSize);

    //为同一个值
    readPointer = preBuffer;
    writePointer = preBuffer;
    }
    return self;
    }


    包括一个初始化方法,去初始化preBufferSize大小的一块内存空间。然后3个指针都指向这个空间。

    - (void)dealloc
    {
    if (preBuffer)
    free(preBuffer);
    }

    销毁的方法:释放preBuffer。

    //确认读的大小
    - (void)ensureCapacityForWrite:(size_t)numBytes
    {
    //拿到当前可用的空间大小
    size_t availableSpace = [self availableSpace];

    //如果申请的大小大于可用的大小
    if (numBytes > availableSpace)
    {
    //需要多出来的大小
    size_t additionalBytes = numBytes - availableSpace;
    //新的总大小
    size_t newPreBufferSize = preBufferSize + additionalBytes;
    //重新去分配preBuffer
    uint8_t *newPreBuffer = realloc(preBuffer, newPreBufferSize);

    //读的指针偏移量(已读大小)
    size_t readPointerOffset = readPointer - preBuffer;
    //写的指针偏移量(已写大小)
    size_t writePointerOffset = writePointer - preBuffer;
    //提前的Buffer重新复制
    preBuffer = newPreBuffer;
    //大小重新赋值
    preBufferSize = newPreBufferSize;

    //读写指针重新赋值 + 上偏移量
    readPointer = preBuffer + readPointerOffset;
    writePointer = preBuffer + writePointerOffset;
    }
    }


    确保prebuffer可用空间的方法:这个方法会重新分配preBuffer,直到可用大小等于传递进来的numBytes,已用大小不会变。

    //仍然可读的数据,过程是先写后读,只有写的大于读的,才能让你继续去读,不然没数据可读了
    - (size_t)availableBytes
    {
    return writePointer - readPointer;
    }

    - (uint8_t *)readBuffer
    {
    return readPointer;
    }

    - (void)getReadBuffer:(uint8_t **)bufferPtr availableBytes:(size_t *)availableBytesPtr
    {
    if (bufferPtr) *bufferPtr = readPointer;
    if (availableBytesPtr) *availableBytesPtr = [self availableBytes];
    }

    //读数据的指针
    - (void)didRead:(size_t)bytesRead
    {
    readPointer += bytesRead;
    //如果读了这么多,指针和写的指针还相同的话,说明已经读完,重置指针到最初的位置
    if (readPointer == writePointer)
    {
    // The prebuffer has been drained. Reset pointers.
    readPointer = preBuffer;
    writePointer = preBuffer;
    }
    }
    //prebuffer的剩余空间 = preBufferSize(总大小) - (写的头指针 - preBuffer一开的指针,即已被写的大小)

    - (size_t)availableSpace
    {
    return preBufferSize - (writePointer - preBuffer);
    }

    - (uint8_t *)writeBuffer
    {
    return writePointer;
    }

    - (void)getWriteBuffer:(uint8_t **)bufferPtr availableSpace:(size_t *)availableSpacePtr
    {
    if (bufferPtr) *bufferPtr = writePointer;
    if (availableSpacePtr) *availableSpacePtr = [self availableSpace];
    }

    - (void)didWrite:(size_t)bytesWritten
    {
    writePointer += bytesWritten;
    }

    - (void)reset
    {
    readPointer = preBuffer;
    writePointer = preBuffer;
    }

    然后就是对读写指针进行处理的方法,如果读了多少数据readPointer就后移多少,写也是一样。
    而获取当前未读数据,则是用已写指针-已读指针,得到的差值,当已读=已写的时候,说明prebuffer数据读完,则重置读写指针的位置,还是指向初始化位置。

    讲完全局缓冲区对于指针的处理,我们接着往下说
    Part4.flushSSLBuffers方法:

    //缓冲ssl数据
    - (void)flushSSLBuffers
    {
    LogTrace();
    //断言为安全Socket
    NSAssert((flags & kSocketSecure), @"Cannot flush ssl buffers on non-secure socket");
    //如果preBuffer有数据可读,直接返回
    if ([preBuffer availableBytes] > 0)
    {
    return;
    }

    #if TARGET_OS_IPHONE
    //如果用的CFStream的TLS,把数据用CFStream的方式搬运到preBuffer中
    if ([self usingCFStreamForTLS])
    {
    //如果flag为kSecureSocketHasBytesAvailable,而且readStream有数据可读
    if ((flags & kSecureSocketHasBytesAvailable) && CFReadStreamHasBytesAvailable(readStream))
    {
    LogVerbose(@"%@ - Flushing ssl buffers into prebuffer...", THIS_METHOD);

    //默认一次读的大小为4KB??
    CFIndex defaultBytesToRead = (1024 * 4);

    //用来确保有这么大的提前buffer缓冲空间
    [preBuffer ensureCapacityForWrite:defaultBytesToRead];
    //拿到写的buffer
    uint8_t *buffer = [preBuffer writeBuffer];

    //从readStream中去读, 一次就读4KB,读到数据后,把数据写到writeBuffer中去 如果读的大小小于readStream中数据流大小,则会不停的触发callback,直到把数据读完为止。
    CFIndex result = CFReadStreamRead(readStream, buffer, defaultBytesToRead);
    //打印结果
    LogVerbose(@"%@ - CFReadStreamRead(): result = %i", THIS_METHOD, (int)result);

    //大于0,说明读写成功
    if (result > 0)
    {
    //把写的buffer头指针,移动result个偏移量
    [preBuffer didWrite:result];
    }

    //把kSecureSocketHasBytesAvailable 仍然可读的标记移除
    flags &= ~kSecureSocketHasBytesAvailable;
    }

    return;
    }

    #endif

    //不用CFStream的处理方法

    //先设置一个预估可用的大小
    __block NSUInteger estimatedBytesAvailable = 0;
    //更新预估可用的Block
    dispatch_block_t updateEstimatedBytesAvailable = ^{

    //预估大小 = 未读的大小 + SSL的可读大小
    estimatedBytesAvailable = socketFDBytesAvailable + [sslPreBuffer availableBytes];

    size_t sslInternalBufSize = 0;
    //获取到ssl上下文的大小,从sslContext中
    SSLGetBufferedReadSize(sslContext, &sslInternalBufSize);
    //再加上下文的大小
    estimatedBytesAvailable += sslInternalBufSize;
    };

    //调用这个Block
    updateEstimatedBytesAvailable();

    //如果大于0,说明有数据可读
    if (estimatedBytesAvailable > 0)
    {

    LogVerbose(@"%@ - Flushing ssl buffers into prebuffer...", THIS_METHOD);

    //标志,循环是否结束,SSL的方式是会阻塞的,直到读的数据有estimatedBytesAvailable大小为止,或者出错
    BOOL done = NO;
    do
    {
    LogVerbose(@"%@ - estimatedBytesAvailable = %lu", THIS_METHOD, (unsigned long)estimatedBytesAvailable);

    // Make sure there's enough room in the prebuffer
    //确保有足够的空间给prebuffer
    [preBuffer ensureCapacityForWrite:estimatedBytesAvailable];

    // Read data into prebuffer
    //拿到写的buffer
    uint8_t *buffer = [preBuffer writeBuffer];
    size_t bytesRead = 0;
    //用SSLRead函数去读,读到后,把数据写到buffer中,estimatedBytesAvailable为需要读的大小,bytesRead这一次实际读到字节大小,为sslContext上下文
    OSStatus result = SSLRead(sslContext, buffer, (size_t)estimatedBytesAvailable, &bytesRead);
    LogVerbose(@"%@ - read from secure socket = %u", THIS_METHOD, (unsigned)bytesRead);

    //把写指针后移bytesRead大小
    if (bytesRead > 0)
    {
    [preBuffer didWrite:bytesRead];
    }

    LogVerbose(@"%@ - prebuffer.length = %zu", THIS_METHOD, [preBuffer availableBytes]);

    //如果读数据出现错误
    if (result != noErr)
    {
    done = YES;
    }
    else
    {
    //在更新一下可读的数据大小
    updateEstimatedBytesAvailable();
    }

    }
    //只有done为NO,而且 estimatedBytesAvailable大于0才继续循环
    while (!done && estimatedBytesAvailable > 0);
    }
    }

    这个方法有点略长,包含了两种SSL的数据处理:

    1. CFStream类型:我们会调用下面这个函数去从stream并且读取数据并解密:
    CFIndex result = CFReadStreamRead(readStream, buffer, defaultBytesToRead);

    数据被读取到后,直接转移到了prebuffer中,并且调用:

    [preBuffer didWrite:result];

    让写指针后移读取到的数据大小。
    这里有两个关于CFReadStreamRead方法,需要注意的问题:
    1)就是我们调用它去读取4KB数据,并不仅仅是只读这么多,而是因为这个方法是会递归调用的,它每次只读4KB,直到把stream中的数据读完。
    2)我们之前设置的CFStream函数的回调,在数据来了之后只会被触发一次,以后数据再来都不会触发。直到我们调用这个方法,把stream中的数据读完,下次再来数据才会触发函数回调。这也是我们在使用CFStream的时候,不需要担心像source那样,有数据会不断的被触发回调,而需要挂起像source那样挂起stream(实际也没有这样的方法)。

    1. SSL安全通道类型:这里我们主要是循环去调用下面这个函数去读取数据:
    OSStatus result = SSLRead(sslContext, buffer, (size_t)estimatedBytesAvailable, &bytesRead);

    其他的基本和CFStream一致

    这里需要注意的是SSLRead这个方法,并不是直接从我们的socket中获取到的数据,而是从我们一开始绑定的SSL回调函数中,得到数据。而回调函数本身,也需要调用read函数从socket中获取到加密的数据。然后再经由SSLRead这个方法,数据被解密,并且传递给buffer

    至于SSLRead绑定的回调函数,是怎么处理数据读取的,因为它处理数据的流程,和我们doReadData后续数据读取处理基本相似,所以现在暂时不提。

    我们绕了一圈,讲完了这个包为空或者当前暂停状态下的前置处理,总结一下:
    1. 就是如果是SSL类型的数据,那么先解密了,缓冲到prebuffer中去。
    2. 判断当前socket可读数据大于0,非CFStreamSSL类型,则挂起source,防止反复触发。
    Part5.接着我们开始doReadData正常数据处理流程:

    首先它大的方向,依然是分为3种类型的数据处理:
    1.SSL安全通道; 2.CFStream类型SSL; 3.普通数据传输。
    因为这3种类型的代码,重复部分较大,处理流程基本类似,只不过调用读取方法所有区别

    //1.
    OSStatus result = SSLRead(sslContext, buffer, (size_t)estimatedBytesAvailable, &bytesRead);
    //2.
    CFIndex result = CFReadStreamRead(readStream, buffer, defaultBytesToRead);
    //3.
    ssize_t result = read(socketFD, buffer, (size_t)bytesToRead);

    SSLRead回调函数内部,也调用了第3种read读取,这个我们后面会说。
    现在这里我们将跳过前两种(方法部分调用可以见上面的flushSSLBuffers方法),只讲第3种普通数据的读取操作,而SSL的读取操作,基本一致。

    先来看看当前数据包任务是否完成,是如何定义的:

    由于框架提供的对外read接口:


    - (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;
    - (void)readDataToLength:(NSUInteger)length withTimeout:(NSTimeInterval)timeout tag:(long)tag;
    - (void)readDataToData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag;

    将数据读取是否完成的操作,大致分为这3个类型:
    1.全读;2读取一定的长度;3读取到某个标记符为止。

    当且仅当上面3种类型对应的操作完成,才视作当前包任务完成,才会回调我们在类中声明的读取消息的代理:

    - (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag

    否则就等待着,直到当前数据包任务完成。

    然后我们读取数据的流程大致如下:

    先从prebuffer中去读取,如果读完了,当前数据包任务仍未完成,那么再从socket中去读取。
    而判断包是否读完,都是用我们上面的3种类型,来对应处理的。



    作者:Cooci
    链接:https://www.jianshu.com/p/5a2df8a6a54e






    收起阅读 »

    CocoaAsyncSocket源码Read(四)

    前文讲完了两次TLS建立连接的流程,接着就是本篇的重头戏了:doReadData方法。在这里我不准备直接把这个整个方法列出来,因为就光这一个方法,加上注释有1200行,整个贴过来也无法展开描述,所以在这里我打算对它分段进行讲解:注:以下代码整个包括在这个方法定...
    继续阅读 »
    前文讲完了两次TLS建立连接的流程,接着就是本篇的重头戏了:doReadData方法。在这里我不准备直接把这个整个方法列出来,因为就光这一个方法,加上注释有1200行,整个贴过来也无法展开描述,所以在这里我打算对它分段进行讲解:

    注:以下代码整个包括在doReadData大括号中:

    //读取数据
    - (void)doReadData
    {
    ....
    }
    Part1.无法正常读取数据时的前置处理:
    //如果当前读取的包为空,或者flag为读取停止,这两种情况是不能去读取数据的
    if ((currentRead == nil) || (flags & kReadsPaused))
    {
    LogVerbose(@"No currentRead or kReadsPaused");

    // Unable to read at this time
    //如果是安全的通信,通过TLS/SSL
    if (flags & kSocketSecure)
    {
    //刷新SSLBuffer,把数据从链路上移到prebuffer中 (当前不读取数据的时候做)
    [self flushSSLBuffers];
    }

    //判断是否用的是 CFStream的TLS
    if ([self usingCFStreamForTLS])
    {

    }
    else
    {
    //挂起source
    if (socketFDBytesAvailable > 0)
    {
    [self suspendReadSource];
    }
    }
    return;
    }

    当我们当前读取的包是空或者标记为读停止状态的时候,则不会去读取数据。
    前者不难理解,因为我们要读取的数据最终是要传给currentRead中去的,所以如果currentRead为空,我们去读数据也没有意义。
    后者kReadsPaused标记是从哪里加上的呢?我们全局搜索一下,发现它才read超时的时候被添加。
    讲到这我们顺便来看这个读取超时的一个逻辑,我们每次做读取任务传进来的超时,都会调用这么一个方法:

    Part2.读取超时处理:
    [self setupReadTimerWithTimeout:currentRead->timeout];

    //初始化读的超时
    - (void)setupReadTimerWithTimeout:(NSTimeInterval)timeout
    {
    if (timeout >= 0.0)
    {
    //生成一个定时器source
    readTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, socketQueue);

    __weak GCDAsyncSocket *weakSelf = self;

    //句柄
    dispatch_source_set_event_handler(readTimer, ^{ @autoreleasepool {
    #pragma clang diagnostic push
    #pragma clang diagnostic warning "-Wimplicit-retain-self"

    __strong GCDAsyncSocket *strongSelf = weakSelf;
    if (strongSelf == nil) return_from_block;

    //执行超时操作
    [strongSelf doReadTimeout];

    #pragma clang diagnostic pop
    }});

    #if !OS_OBJECT_USE_OBJC
    dispatch_source_t theReadTimer = readTimer;

    //取消的句柄
    dispatch_source_set_cancel_handler(readTimer, ^{
    #pragma clang diagnostic push
    #pragma clang diagnostic warning "-Wimplicit-retain-self"

    LogVerbose(@"dispatch_release(readTimer)");
    dispatch_release(theReadTimer);

    #pragma clang diagnostic pop
    });
    #endif

    //定时器延时 timeout时间执行
    dispatch_time_t tt = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_SEC));
    //间隔为永远,即只执行一次
    dispatch_source_set_timer(readTimer, tt, DISPATCH_TIME_FOREVER, 0);
    dispatch_resume(readTimer);
    }
    }

    这个方法定义了一个GCD定时器,这个定时器只执行一次,间隔就是我们的超时,很显然这是一个延时执行,那小伙伴要问了,这里为什么我们不用NSTimer或者下面这种方式:
    [self performSelector:<#(nonnull SEL)#> withObject:<#(nullable id)#> afterDelay:<#(NSTimeInterval)#>

    原因很简单,performSelector是基于runloop才能使用的,它本质是转化成runloop基于非端口的源source0。很显然我们所在的socketQueue开辟出来的线程,并没有添加一个runloop。而NSTimer也是一样。

    所以这里我们用GCD Timer,因为它是基于XNU内核来实现的,并不需要借助于runloop

    这里当超时时间间隔到达时,我们会执行超时操作:

    [strongSelf doReadTimeout];


    //执行超时操作
    - (void)doReadTimeout
    {
    // This is a little bit tricky.
    // Ideally we'd like to synchronously query the delegate about a timeout extension.
    // But if we do so synchronously we risk a possible deadlock.
    // So instead we have to do so asynchronously, and callback to ourselves from within the delegate block.

    //因为这里用同步容易死锁,所以用异步从代理中回调

    //标记读暂停
    flags |= kReadsPaused;

    __strong id theDelegate = delegate;

    //判断是否实现了延时 补时的代理
    if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:shouldTimeoutReadWithTag:elapsed:bytesDone:)])
    {
    //拿到当前读的包
    GCDAsyncReadPacket *theRead = currentRead;

    //代理queue中回调
    dispatch_async(delegateQueue, ^{ @autoreleasepool {

    NSTimeInterval timeoutExtension = 0.0;

    //调用代理方法,拿到续的时长
    timeoutExtension = [theDelegate socket:self shouldTimeoutReadWithTag:theRead->tag
    elapsed:theRead->timeout
    bytesDone:theRead->bytesDone];

    //socketQueue中,做延时
    dispatch_async(socketQueue, ^{ @autoreleasepool {

    [self doReadTimeoutWithExtension:timeoutExtension];
    }});
    }});
    }
    else
    {
    [self doReadTimeoutWithExtension:0.0];
    }
    }
    //做读取数据延时
    - (void)doReadTimeoutWithExtension:(NSTimeInterval)timeoutExtension
    {
    if (currentRead)
    {
    if (timeoutExtension > 0.0)
    {
    //把超时加上
    currentRead->timeout += timeoutExtension;

    // Reschedule the timer
    //重新生成时间
    dispatch_time_t tt = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeoutExtension * NSEC_PER_SEC));
    //重置timer时间
    dispatch_source_set_timer(readTimer, tt, DISPATCH_TIME_FOREVER, 0);

    // Unpause reads, and continue
    //在把paused标记移除
    flags &= ~kReadsPaused;
    //继续去读取数据
    [self doReadData];
    }
    else
    {
    //输出读取超时,并断开连接
    LogVerbose(@"ReadTimeout");

    [self closeWithError:[self readTimeoutError]];
    }
    }
    }

    这里调用了续时代理,如果我们实现了这个代理,则可以增加这个超时时间,然后重新生成超时定时器,移除读取停止的标记kReadsPaused。继续去读取数据。
    否则我们就断开socket
    注意:这个定时器会被取消,如果当前数据包被读取完成,这样就不会走到定时器超时的时间,则不会断开socket

    我们接着回到doReadData中,我们讲到如果当前读取包为空或者状态为kReadsPaused,我们就去执行一些非读取数据的处理。
    这里我们第一步去判断当前连接是否为kSocketSecure,也就是安全通道的TLS。如果是我们则调用:

    if (flags & kSocketSecure)
    {
    //刷新,把TLS加密型的数据从链路上移到prebuffer中 (当前暂停的时候做)
    [self flushSSLBuffers];
    }

    按理说,我们有当前读取包的时候,在去从prebuffersocket中去读取,但是这里为什么要提前去读呢?
    我们来看看这个框架作者的解释:

    // Here's the situation:
    // We have an established secure connection.
    // There may not be a currentRead, but there might be encrypted data sitting around for us.
    // When the user does get around to issuing a read, that encrypted data will need to be decrypted.
    // So why make the user wait?
    // We might as well get a head start on decrypting some data now.
    // The other reason we do this has to do with detecting a socket disconnection.
    // The SSL/TLS protocol has it's own disconnection handshake.
    // So when a secure socket is closed, a "goodbye" packet comes across the wire.
    // We want to make sure we read the "goodbye" packet so we can properly detect the TCP disconnection.

    简单来讲,就是我们用TLS类型的Socket,读取数据的时候需要解密的过程,而这个过程是费时的,我们没必要让用户在读取数据的时候去等待这个解密的过程,我们可以提前在数据一到达,就去读取解密。
    而且这种方式,还能时刻根据TLSgoodbye包来准确的检测到TCP断开连接。



    作者:Cooci
    链接:https://www.jianshu.com/p/5a2df8a6a54e







    收起阅读 »

    CocoaAsyncSocket源码Read(三)

    这里我们就讲讲几个重要的关于SSL的函数,其余细节可以看看注释:创建SSL上下文对象:sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLServerSide, kSSLStreamType); ssl...
    继续阅读 »

    这里我们就讲讲几个重要的关于SSL的函数,其余细节可以看看注释:

    1. 创建SSL上下文对象:
    sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLServerSide, kSSLStreamType);
    sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLClientSide, kSSLStreamType);

    这个函数用来创建一个SSL上下文,我们接下来会把配置字典tlsSettings中所有的参数,都设置到这个sslContext中去,然后用这个sslContext进行TLS后续操作,握手等。

    1. 给SSL设置读写回调:
    status = SSLSetIOFuncs(sslContext, &SSLReadFunction, &SSLWriteFunction);

    这两个回调函数如下:

    //读函数
    static OSStatus SSLReadFunction(SSLConnectionRef connection, void *data, size_t *dataLength)
    {
    //拿到socket
    GCDAsyncSocket *asyncSocket = (__bridge GCDAsyncSocket *)connection;

    //断言当前为socketQueue
    NSCAssert(dispatch_get_specific(asyncSocket->IsOnSocketQueueOrTargetQueueKey), @"What the deuce?");

    //读取数据,并且返回状态码
    return [asyncSocket sslReadWithBuffer:data length:dataLength];
    }
    //写函数
    static OSStatus SSLWriteFunction(SSLConnectionRef connection, const void *data, size_t *dataLength)
    {
    GCDAsyncSocket *asyncSocket = (__bridge GCDAsyncSocket *)connection;

    NSCAssert(dispatch_get_specific(asyncSocket->IsOnSocketQueueOrTargetQueueKey), @"What the deuce?");

    return [asyncSocket sslWriteWithBuffer:data length:dataLength];
    }

    他们分别调用了sslReadWithBuffersslWriteWithBuffer两个函数进行SSL的读写处理,关于这两个函数,我们后面再来说。
    1. 发起SSL连接:
    status = SSLSetConnection(sslContext, (__bridge SSLConnectionRef)self);

    到这一步,前置的重要操作就完成了,接下来我们是对SSL进行一些额外的参数配置:
    我们根据tlsSettingsGCDAsyncSocketManuallyEvaluateTrust字段,去判断是否需要手动信任服务端证书,调用如下函数

    status = SSLSetSessionOption(sslContext, kSSLSessionOptionBreakOnServerAuth, true);

    这个函数是用来设置一些可选项的,当然不止kSSLSessionOptionBreakOnServerAuth这一种,还有许多种类型的可选项,感兴趣的朋友可以自行点进去看看这个枚举。

    接着我们按照字典中的设置项,一项一项去设置ssl上下文,类似:


    status = SSLSetPeerDomainName(sslContext, peer, peerLen);

    设置完这些有效的,我们还需要去检查无效的key,万一我们设置了这些废弃的api,我们需要报错处理。

    做完这些操作后,我们初始化了一个sslPreBuffer,这个ssl安全通道下的全局缓冲区:

    sslPreBuffer = [[GCDAsyncSocketPreBuffer alloc] initWithCapacity:(1024 * 4)];

    然后把prebuffer全局缓冲区中的数据全部挪到sslPreBuffer中去,这里为什么要这么做呢?按照我们上面的流程图来说,正确的数据流向应该是从sslPreBuffer->prebuffer的,楼主在这里也思考了很久,最后我的想法是,就是初始化的时候,数据的流向的统一,在我们真正数据读取的时候,就不需要做额外的判断了。

    到这里我们所有的握手前初始化工作都做完了。

    接着我们调用了ssl_continueSSLHandshake方法开始SSL握手

    //SSL的握手
    - (void)ssl_continueSSLHandshake
    {
    LogTrace();

    //用我们的SSL上下文对象去握手
    OSStatus status = SSLHandshake(sslContext);
    //拿到握手的结果,赋值给上次握手的结果
    lastSSLHandshakeError = status;

    //如果没错
    if (status == noErr)
    {
    LogVerbose(@"SSLHandshake complete");

    //把开始读写TLS,从标记中移除
    flags &= ~kStartingReadTLS;
    flags &= ~kStartingWriteTLS;

    //把Socket安全通道标记加上
    flags |= kSocketSecure;

    //拿到代理
    __strong id theDelegate = delegate;

    if (delegateQueue && [theDelegate respondsToSelector:@selector(socketDidSecure:)])
    {
    dispatch_async(delegateQueue, ^{ @autoreleasepool {
    //调用socket已经开启安全通道的代理方法
    [theDelegate socketDidSecure:self];
    }});
    }
    //停止读取
    [self endCurrentRead];
    //停止写
    [self endCurrentWrite];
    //开始下一次读写任务
    [self maybeDequeueRead];
    [self maybeDequeueWrite];
    }
    //如果是认证错误
    else if (status == errSSLPeerAuthCompleted)
    {
    LogVerbose(@"SSLHandshake peerAuthCompleted - awaiting delegate approval");

    __block SecTrustRef trust = NULL;
    //从sslContext拿到证书相关的细节
    status = SSLCopyPeerTrust(sslContext, &trust);
    //SSl证书赋值出错
    if (status != noErr)
    {
    [self closeWithError:[self sslError:status]];
    return;
    }

    //拿到状态值
    int aStateIndex = stateIndex;
    //socketQueue
    dispatch_queue_t theSocketQueue = socketQueue;

    __weak GCDAsyncSocket *weakSelf = self;

    //创建一个完成Block
    void (^comletionHandler)(BOOL) = ^(BOOL shouldTrust){ @autoreleasepool {
    #pragma clang diagnostic push
    #pragma clang diagnostic warning "-Wimplicit-retain-self"

    dispatch_async(theSocketQueue, ^{ @autoreleasepool {

    if (trust) {
    CFRelease(trust);
    trust = NULL;
    }

    __strong GCDAsyncSocket *strongSelf = weakSelf;
    if (strongSelf)
    {
    [strongSelf ssl_shouldTrustPeer:shouldTrust stateIndex:aStateIndex];
    }
    }});

    #pragma clang diagnostic pop
    }};

    __strong id theDelegate = delegate;

    if (delegateQueue && [theDelegate respondsToSelector:@selector(socket:didReceiveTrust:completionHandler:)])
    {
    dispatch_async(delegateQueue, ^{ @autoreleasepool {

    #pragma mark - 调用代理我们自己去https认证
    [theDelegate socket:self didReceiveTrust:trust completionHandler:comletionHandler];
    }});
    }
    //没实现代理直接报错关闭连接。
    else
    {
    if (trust) {
    CFRelease(trust);
    trust = NULL;
    }

    NSString *msg = @"GCDAsyncSocketManuallyEvaluateTrust specified in tlsSettings,"
    @" but delegate doesn't implement socket:shouldTrustPeer:";

    [self closeWithError:[self otherError:msg]];
    return;
    }
    }

    //握手错误为 IO阻塞的
    else if (status == errSSLWouldBlock)
    {
    LogVerbose(@"SSLHandshake continues...");

    // Handshake continues...
    //
    // This method will be called again from doReadData or doWriteData.
    }
    else
    {
    //其他错误直接关闭连接
    [self closeWithError:[self sslError:status]];
    }
    }

    这个方法就做了一件事,就是SSL握手,我们调用了这个函数完成握手:


    OSStatus status = SSLHandshake(sslContext);

    然后握手的结果分为4种情况:

    1. 如果返回为noErr,这个会话已经准备好了安全的通信,握手成功。
    • 如果返回的valueerrSSLWouldBlock,握手方法必须再次调用。
    • 如果返回为errSSLServerAuthCompleted,如果我们要调用代理,我们需要相信服务器,然后再次调用握手,去恢复握手或者关闭连接。
    • 否则,返回的value表明了错误的code

    其中需要说说的是errSSLWouldBlock,这个是IO阻塞下的错误,也就是服务器的结果还没来得及返回,当握手结果返回的时候,这个方法会被再次触发。

    还有就是errSSLServerAuthCompleted下,我们回调了代理:

    [theDelegate socket:self didReceiveTrust:trust completionHandler:comletionHandler];

    我们可以去手动对证书进行认证并且信任,当完成回调后,会调用到这个方法里来,再次进行握手:

    //修改信息后再次进行SSL握手
    - (void)ssl_shouldTrustPeer:(BOOL)shouldTrust stateIndex:(int)aStateIndex
    {
    LogTrace();

    if (aStateIndex != stateIndex)
    {
    return;
    }

    // Increment stateIndex to ensure completionHandler can only be called once.
    stateIndex++;

    if (shouldTrust)
    {
    NSAssert(lastSSLHandshakeError == errSSLPeerAuthCompleted, @"ssl_shouldTrustPeer called when last error is %d and not errSSLPeerAuthCompleted", (int)lastSSLHandshakeError);
    [self ssl_continueSSLHandshake];
    }
    else
    {

    [self closeWithError:[self sslError:errSSLPeerBadCert]];
    }
    }



    到这里,我们就整个完成安全通道下的TLS认证。

    接着我们来看看基于CFStreamTLS

    因为CFStream是上层API,所以它的TLS流程相当简单,我们来看看cf_startTLS这个方法:


    //CF流形式的TLS
    - (void)cf_startTLS
    {
    LogTrace();

    LogVerbose(@"Starting TLS (via CFStream)...");

    //如果preBuffer的中可读数据大于0,错误关闭
    if ([preBuffer availableBytes] > 0)
    {
    NSString *msg = @"Invalid TLS transition. Handshake has already been read from socket.";

    [self closeWithError:[self otherError:msg]];
    return;
    }

    //挂起读写source
    [self suspendReadSource];
    [self suspendWriteSource];

    //把未读的数据大小置为0
    socketFDBytesAvailable = 0;
    //去掉下面两种flag
    flags &= ~kSocketCanAcceptBytes;
    flags &= ~kSecureSocketHasBytesAvailable;

    //标记为CFStream
    flags |= kUsingCFStreamForTLS;

    //如果创建读写stream失败
    if (![self createReadAndWriteStream])
    {
    [self closeWithError:[self otherError:@"Error in CFStreamCreatePairWithSocket"]];
    return;
    }
    //注册回调,这回监听可读数据了!!
    if (![self registerForStreamCallbacksIncludingReadWrite:YES])
    {
    [self closeWithError:[self otherError:@"Error in CFStreamSetClient"]];
    return;
    }
    //添加runloop
    if (![self addStreamsToRunLoop])
    {
    [self closeWithError:[self otherError:@"Error in CFStreamScheduleWithRunLoop"]];
    return;
    }

    NSAssert([currentRead isKindOfClass:[GCDAsyncSpecialPacket class]], @"Invalid read packet for startTLS");
    NSAssert([currentWrite isKindOfClass:[GCDAsyncSpecialPacket class]], @"Invalid write packet for startTLS");

    //拿到当前包
    GCDAsyncSpecialPacket *tlsPacket = (GCDAsyncSpecialPacket *)currentRead;
    //拿到ssl配置
    CFDictionaryRef tlsSettings = (__bridge CFDictionaryRef)tlsPacket->tlsSettings;

    // Getting an error concerning kCFStreamPropertySSLSettings ?
    // You need to add the CFNetwork framework to your iOS application.

    //直接设置给读写stream
    BOOL r1 = CFReadStreamSetProperty(readStream, kCFStreamPropertySSLSettings, tlsSettings);
    BOOL r2 = CFWriteStreamSetProperty(writeStream, kCFStreamPropertySSLSettings, tlsSettings);

    //设置失败
    if (!r1 && !r2) // Yes, the && is correct - workaround for apple bug.
    {
    [self closeWithError:[self otherError:@"Error in CFStreamSetProperty"]];
    return;
    }

    //打开流
    if (![self openStreams])
    {
    [self closeWithError:[self otherError:@"Error in CFStreamOpen"]];
    return;
    }

    LogVerbose(@"Waiting for SSL Handshake to complete...");
    }
    1.这个方法很简单,首先它挂起了读写source,然后重新初始化了读写流,并且绑定了回调,和添加了runloop
    这里我们为什么要用重新这么做?看过之前connect篇的同学就知道,我们在连接成功之后,去初始化过读写流,这些操作之前都做过。而在这里重新初始化,并不会重新创建,只是修改读写流的一些参数,其中主要是下面这个方法,传递了一个YES过去:
    if (![self registerForStreamCallbacksIncludingReadWrite:YES])

    这个参数会使方法里多添加一种触发回调的方式:kCFStreamEventHasBytesAvailable
    当有数据可读时候,触发Stream回调。

    2.接着我们用下面这个函数把TLS的配置参数,设置给读写stream:

    //直接设置给读写stream
    BOOL r1 = CFReadStreamSetProperty(readStream, kCFStreamPropertySSLSettings, tlsSettings);
    BOOL r2 = CFWriteStreamSetProperty(writeStream, kCFStreamPropertySSLSettings, tlsSettings);

    3.最后打开读写流,整个CFStream形式的TLS就完成了。

    看到这,大家可能对数据触发的问题有些迷惑。总结一下,我们到现在一共有3种触发的回调:
    1. 读写source:这个和socket绑定在一起,一旦有数据到达,就会触发事件句柄,但是我们可以看到在cf_startTLS方法中我们调用了:

     //挂起读写source
    [self suspendReadSource];
    [self suspendWriteSource];

    所以,对于CFStream形式的TLS的读写并不是由source触发的,而其他的都是由source来触发。

    1. CFStream绑定的几种事件的读写回调函数:
    static void CFReadStreamCallback (CFReadStreamRef stream, CFStreamEventType type, void *pInfo)
    static void CFWriteStreamCallback (CFWriteStreamRef stream, CFStreamEventType type, void *pInfo)

    这个和CFStream形式的TLS相关,会触发这种形式的握手,流末尾等出现的错误,还有该形式下数据到达。
    因为我们在一开始的连接完成就初始化过stream,所以非CFStream形式下也回触发这个回调,只是不会在数据到达触发而已。

    1. SSL安全通道形式,绑定的SSL读写函数:
    static OSStatus SSLReadFunction(SSLConnectionRef connection, void *data, size_t *dataLength)
    static OSStatus SSLWriteFunction(SSLConnectionRef connection, const void *data, size_t *dataLength)

    这个函数并不是由系统触发,而是需要我们主动去调用SSLReadSSLWrite两个函数,回调才能被触发。























    收起阅读 »

    CocoaAsyncSocket源码Read(二)

    讲讲两种TLS建立连接的过程讲到这里,就不得不提一下,这里个框架开启TLS的过程。它对外提供了这么一个方法来开启TLS:- (void)startTLS:(NSDictionary *)tlsSettings 可以根据一个字典,去开启并且配置TLS,那么这个字...
    继续阅读 »
    讲讲两种TLS建立连接的过程

    讲到这里,就不得不提一下,这里个框架开启TLS的过程。它对外提供了这么一个方法来开启TLS

    - (void)startTLS:(NSDictionary *)tlsSettings

    可以根据一个字典,去开启并且配置TLS,那么这个字典里包含什么内容呢?
    一共包含以下这些key

    //配置SSL上下文的设置
    // Configure SSLContext from given settings
    //
    // Checklist:
    // 1\. kCFStreamSSLPeerName //证书名
    // 2\. kCFStreamSSLCertificates //证书数组
    // 3\. GCDAsyncSocketSSLPeerID //证书ID
    // 4\. GCDAsyncSocketSSLProtocolVersionMin //SSL最低版本
    // 5\. GCDAsyncSocketSSLProtocolVersionMax //SSL最高版本
    // 6\. GCDAsyncSocketSSLSessionOptionFalseStart
    // 7\. GCDAsyncSocketSSLSessionOptionSendOneByteRecord
    // 8\. GCDAsyncSocketSSLCipherSuites
    // 9\. GCDAsyncSocketSSLDiffieHellmanParameters (Mac)
    //
    // Deprecated (throw error): //被废弃的参数,如果设置了就会报错关闭socket
    // 10\. kCFStreamSSLAllowsAnyRoot
    // 11\. kCFStreamSSLAllowsExpiredRoots
    // 12\. kCFStreamSSLAllowsExpiredCertificates
    // 13\. kCFStreamSSLValidatesCertificateChain
    // 14\. kCFStreamSSLLevel
    其中有些Key的值,具体是什么意思,value如何设置,可以查查苹果文档,限于篇幅,我们就不赘述了,只需要了解重要的几个参数即可。
    后面一部分是被废弃的参数,如果我们设置了,就会报错关闭socket连接。
    除此之外,还有这么3个key被我们遗漏了,这3个key,是框架内部用来判断,并且做一些处理的标识:

    kCFStreamSSLIsServer  //判断当前是否是服务端
    GCDAsyncSocketManuallyEvaluateTrust //判断是否需要手动信任SSL
    GCDAsyncSocketUseCFStreamForTLS //判断是否使用CFStream形式的TLS

    这3个key的大意如注释,后面我们还会讲到,其中最重要的是GCDAsyncSocketUseCFStreamForTLS这个key,一旦我们设置为YES,将开启CFStream的TLS,关于这种基于流的TLS与普通的TLS的区别,我们来看看官方说明:

    • GCDAsyncSocketUseCFStreamForTLS (iOS only)

    • The value must be of type NSNumber, encapsulating a BOOL value.

    • By default GCDAsyncSocket will use the SecureTransport layer to perform encryption.

    • This gives us more control over the security protocol (many more configuration options),

    • plus it allows us to optimize things like sys calls and buffer allocation.

    • However, if you absolutely must, you can instruct GCDAsyncSocket to use the old-fashioned encryption

    • technique by going through the CFStream instead. So instead of using SecureTransport, GCDAsyncSocket

    • will instead setup a CFRead/CFWriteStream. And then set the kCFStreamPropertySSLSettings property

    • (via CFReadStreamSetProperty / CFWriteStreamSetProperty) and will pass the given options to this method.

    • Thus all the other keys in the given dictionary will be ignored by GCDAsyncSocket,

    • and will passed directly CFReadStreamSetProperty / CFWriteStreamSetProperty.

    • For more infomation on these keys, please see the documentation for kCFStreamPropertySSLSettings.

    • If unspecified, the default value is NO.

    从上述说明中,我们可以得知,CFStream形式的TLS仅仅可以被用于iOS平台,并且它是一种过时的加解密技术,如果我们没有必要,最好还是不要用这种方式的TLS

    至于它的实现,我们接着往下看。

    //开启TLS
    - (void)startTLS:(NSDictionary *)tlsSettings
    {
    LogTrace();

    if (tlsSettings == nil)
    {

    tlsSettings = [NSDictionary dictionary];
    }
    //新生成一个TLS特殊的包
    GCDAsyncSpecialPacket *packet = [[GCDAsyncSpecialPacket alloc] initWithTLSSettings:tlsSettings];

    dispatch_async(socketQueue, ^{ @autoreleasepool {

    if ((flags & kSocketStarted) && !(flags & kQueuedTLS) && !(flags & kForbidReadsWrites))
    {
    //添加到读写Queue中去
    [readQueue addObject:packet];
    [writeQueue addObject:packet];
    //把TLS标记加上
    flags |= kQueuedTLS;
    //开始读取TLS的任务,读到这个包会做TLS认证。在这之前的包还是不用认证就可以传送完
    [self maybeDequeueRead];
    [self maybeDequeueWrite];
    }
    }});

    }


    这个方法就是对外提供的开启TLS的方法,它把传进来的字典,包成一个TLS的特殊包,这个GCDAsyncSpecialPacket类包里面就一个字典属性:

    - (id)initWithTLSSettings:(NSDictionary *)settings;


    然后我们把这个包添加到读写queue中去,并且标记当前的状态,然后去执行maybeDequeueReadmaybeDequeueWrite
    需要注意的是,这里只有读到这个GCDAsyncSpecialPacket时,才开始TLS认证和握手。

    接着我们就来到了maybeDequeueRead这个方法,这个方法我们在前面第一条中讲到过,忘了的可以往上拉一下页面就可以看到。
    它就是让我们的ReadQueue中的读任务离队,并且开始执行这条读任务。

    • 当我们读到的是GCDAsyncSpecialPacket类型的包,则开始进行TLS认证。
    • 当我们读到的是GCDAsyncReadPacket类型的包,则开始进行一次读取数据的任务。
    • 如果ReadQueue为空,则对几种情况进行判断,是否是读取上一次数据失败,则断开连接。
      如果是基于TLSSocket,则把SSL安全通道的数据,移到全局缓冲区preBuffer中。如果数据仍然为空,则恢复读source,等待下一次读source的触发。

    接着我们来看看这其中第一条,当读到的是一个GCDAsyncSpecialPacket类型的包,我们会调用maybeStartTLS这个方法:


    //可能开启TLS
    - (void)maybeStartTLS
    {

    //只有读和写TLS都开启
    if ((flags & kStartingReadTLS) && (flags & kStartingWriteTLS))
    {
    //需要安全传输
    BOOL useSecureTransport = YES;

    #if TARGET_OS_IPHONE
    {
    //拿到当前读的数据
    GCDAsyncSpecialPacket *tlsPacket = (GCDAsyncSpecialPacket *)currentRead;
    //得到设置字典
    NSDictionary *tlsSettings = tlsPacket->tlsSettings;

    //拿到Key为CFStreamTLS的 value
    NSNumber *value = [tlsSettings objectForKey:GCDAsyncSocketUseCFStreamForTLS];

    if (value && [value boolValue])
    //如果是用CFStream的,则安全传输为NO
    useSecureTransport = NO;
    }
    #endif
    //如果使用安全通道
    if (useSecureTransport)
    {
    //开启TLS
    [self ssl_startTLS];
    }
    //CFStream形式的Tls
    else
    {
    #if TARGET_OS_IPHONE
    [self cf_startTLS];
    #endif
    }
    }
    }

    这里根据我们之前添加标记,判断是否读写TLS状态,是才继续进行接下来的TLS认证。
    接着我们拿到当前GCDAsyncSpecialPacket,取得配置字典中keyGCDAsyncSocketUseCFStreamForTLS的值:
    如果为YES则说明使用CFStream形式的TLS,否则使用SecureTransport安全通道形式的TLS。关于这个配置项,还有二者的区别,我们前面就讲过了。

    接着我们分别来看看这两个方法,先来看看ssl_startTLS

    这个方法非常长,大概有400多行,所以为了篇幅和大家阅读体验,楼主简化了一部分内容用省略号+注释的形式表示。

    //开启TLS
    - (void)ssl_startTLS
    {
    LogTrace();

    LogVerbose(@"Starting TLS (via SecureTransport)...");

    //状态标记
    OSStatus status;

    //拿到当前读的数据包
    GCDAsyncSpecialPacket *tlsPacket = (GCDAsyncSpecialPacket *)currentRead;
    if (tlsPacket == nil) // Code to quiet the analyzer
    {
    NSAssert(NO, @"Logic error");

    [self closeWithError:[self otherError:@"Logic error"]];
    return;
    }
    //拿到设置
    NSDictionary *tlsSettings = tlsPacket->tlsSettings;

    // Create SSLContext, and setup IO callbacks and connection ref

    //根据key来判断,当前包是否是服务端的
    BOOL isServer = [[tlsSettings objectForKey:(__bridge NSString *)kCFStreamSSLIsServer] boolValue];

    //创建SSL上下文
    #if TARGET_OS_IPHONE || (__MAC_OS_X_VERSION_MIN_REQUIRED >= 1080)
    {
    //如果是服务端的创建服务端上下文,否则是客户端的上下文,用stream形式
    if (isServer)
    sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLServerSide, kSSLStreamType);
    else
    sslContext = SSLCreateContext(kCFAllocatorDefault, kSSLClientSide, kSSLStreamType);
    //为空则报错返回
    if (sslContext == NULL)
    {
    [self closeWithError:[self otherError:@"Error in SSLCreateContext"]];
    return;
    }
    }

    #else // (__MAC_OS_X_VERSION_MIN_REQUIRED < 1080)
    {
    status = SSLNewContext(isServer, &sslContext);
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLNewContext"]];
    return;
    }
    }
    #endif

    //给SSL上下文设置 IO回调 分别为SSL 读写函数
    status = SSLSetIOFuncs(sslContext, &SSLReadFunction, &SSLWriteFunction);
    //设置出错
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetIOFuncs"]];
    return;
    }

    //在握手之调用,建立SSL连接 ,第一次连接 1
    status = SSLSetConnection(sslContext, (__bridge SSLConnectionRef)self);
    //连接出错
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetConnection"]];
    return;
    }

    //是否应该手动的去信任SSL
    BOOL shouldManuallyEvaluateTrust = [[tlsSettings objectForKey:GCDAsyncSocketManuallyEvaluateTrust] boolValue];
    //如果需要手动去信任
    if (shouldManuallyEvaluateTrust)
    {
    //是服务端的话,不需要,报错返回
    if (isServer)
    {
    [self closeWithError:[self otherError:@"Manual trust validation is not supported for server sockets"]];
    return;
    }
    //第二次连接 再去连接用kSSLSessionOptionBreakOnServerAuth的方式,去连接一次,这种方式可以直接信任服务端证书
    status = SSLSetSessionOption(sslContext, kSSLSessionOptionBreakOnServerAuth, true);
    //错误直接返回
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetSessionOption"]];
    return;
    }

    #if !TARGET_OS_IPHONE && (__MAC_OS_X_VERSION_MIN_REQUIRED < 1080)

    // Note from Apple's documentation:
    //
    // It is only necessary to call SSLSetEnableCertVerify on the Mac prior to OS X 10.8.
    // On OS X 10.8 and later setting kSSLSessionOptionBreakOnServerAuth always disables the
    // built-in trust evaluation. All versions of iOS behave like OS X 10.8 and thus
    // SSLSetEnableCertVerify is not available on that platform at all.

    //为了防止kSSLSessionOptionBreakOnServerAuth这种情况下,产生了不受信任的环境
    status = SSLSetEnableCertVerify(sslContext, NO);
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetEnableCertVerify"]];
    return;
    }

    #endif
    }

    //配置SSL上下文的设置

    id value;
    //这个参数是用来获取证书名验证,如果设置为NULL,则不验证
    // 1\. kCFStreamSSLPeerName

    value = [tlsSettings objectForKey:(__bridge NSString *)kCFStreamSSLPeerName];
    if ([value isKindOfClass:[NSString class]])
    {
    NSString *peerName = (NSString *)value;

    const char *peer = [peerName UTF8String];
    size_t peerLen = strlen(peer);

    //把证书名设置给SSL
    status = SSLSetPeerDomainName(sslContext, peer, peerLen);
    if (status != noErr)
    {
    [self closeWithError:[self otherError:@"Error in SSLSetPeerDomainName"]];
    return;
    }
    }
    //不是string就错误返回
    else if (value)
    {
    //这个断言啥用也没有啊。。
    NSAssert(NO, @"Invalid value for kCFStreamSSLPeerName. Value must be of type NSString.");

    [self closeWithError:[self otherError:@"Invalid value for kCFStreamSSLPeerName."]];
    return;
    }

    // 2\. kCFStreamSSLCertificates
    ...
    // 3\. GCDAsyncSocketSSLPeerID
    ...
    // 4\. GCDAsyncSocketSSLProtocolVersionMin
    ...
    // 5\. GCDAsyncSocketSSLProtocolVersionMax
    ...
    // 6\. GCDAsyncSocketSSLSessionOptionFalseStart
    ...
    // 7\. GCDAsyncSocketSSLSessionOptionSendOneByteRecord
    ...
    // 8\. GCDAsyncSocketSSLCipherSuites
    ...
    // 9\. GCDAsyncSocketSSLDiffieHellmanParameters (Mac)
    ...

    //弃用key的检查,如果有下列key对应的value,则都报弃用的错误

    // 10\. kCFStreamSSLAllowsAnyRoot
    ...
    // 11\. kCFStreamSSLAllowsExpiredRoots
    ...
    // 12\. kCFStreamSSLAllowsExpiredCertificates
    ...
    // 13\. kCFStreamSSLValidatesCertificateChain
    ...
    // 14\. kCFStreamSSLLevel
    ...

    // Setup the sslPreBuffer
    //
    // Any data in the preBuffer needs to be moved into the sslPreBuffer,
    // as this data is now part of the secure read stream.

    //初始化SSL提前缓冲 也是4Kb
    sslPreBuffer = [[GCDAsyncSocketPreBuffer alloc] initWithCapacity:(1024 * 4)];
    //获取到preBuffer可读大小
    size_t preBufferLength = [preBuffer availableBytes];

    //如果有可读内容
    if (preBufferLength > 0)
    {
    //确保SSL提前缓冲的大小
    [sslPreBuffer ensureCapacityForWrite:preBufferLength];
    //从readBuffer开始读,读这个长度到 SSL提前缓冲的writeBuffer中去
    memcpy([sslPreBuffer writeBuffer], [preBuffer readBuffer], preBufferLength);
    //移动提前的读buffer
    [preBuffer didRead:preBufferLength];
    //移动sslPreBuffer的写buffer
    [sslPreBuffer didWrite:preBufferLength];
    }
    //拿到上次错误的code,并且让上次错误code = 没错
    sslErrCode = lastSSLHandshakeError = noErr;

    // Start the SSL Handshake process
    //开始SSL握手过程
    [self ssl_continueSSLHandshake];
    }


    这个方法的结构也很清晰,主要就是建立TLS连接,并且配置SSL上下文对象:sslContext,为TLS握手做准备。











    收起阅读 »

    CocoaAsyncSocket源码Read(一)

    本文为CocoaAsyncSocket源码阅读 将重点涉及该框架是如何利用缓冲区对数据进行读取、以及各种情况下的数据包处理,其中还包括普通的、和基于TLS的不同读取操作等等。注:由于该框架源码篇幅过大,且有大部分相对抽象的数据操作逻辑,尽管楼主竭力想...
    继续阅读 »
    本文为CocoaAsyncSocket源码阅读 将重点涉及该框架是如何利用缓冲区对数据进行读取、以及各种情况下的数据包处理,其中还包括普通的、和基于TLS的不同读取操作等等。
    注:由于该框架源码篇幅过大,且有大部分相对抽象的数据操作逻辑,尽管楼主竭力想要简单的去陈述相关内容,但是阅读起来仍会有一定的难度。如果不是诚心想学习IM相关知识,在这里就可以离场了...

    附上一张 SSL / TSL


    • 1.浅析Read读取,并阐述数据从socket到用户手中的流程。✅
    • 2.讲讲两种TLS建立连接的过程。✅
    • 3.深入讲解Read的核心方法---doReadData的实现。❌
    正文:
    一.浅析Read读取,并阐述数据从socket到用户手中的流程

    大家用过这个框架就知道,我们每次读取数据之前都需要主动调用这么一个Read方法:

    [gcdSocket readDataWithTimeout:-1 tag:110];


    设置一个超时和tag值,这样我们就可以在这个超时的时间里,去读取到达当前socket的数据了。

    那么本篇Read就从这个方法开始说起,我们点进框架里,来到这个方法:

    - (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag
    {
    [self readDataWithTimeout:timeout buffer:nil bufferOffset:0 maxLength:0 tag:tag];
    }

    - (void)readDataWithTimeout:(NSTimeInterval)timeout
    buffer:(NSMutableData *)buffer
    bufferOffset:(NSUInteger)offset
    tag:(long)tag
    {
    [self readDataWithTimeout:timeout buffer:buffer bufferOffset:offset maxLength:0 tag:tag];
    }

    //用偏移量 maxLength 读取数据
    - (void)readDataWithTimeout:(NSTimeInterval)timeout
    buffer:(NSMutableData *)buffer
    bufferOffset:(NSUInteger)offset
    maxLength:(NSUInteger)length
    tag:(long)tag
    {
    if (offset > [buffer length]) {
    LogWarn(@"Cannot read: offset > [buffer length]");
    return;
    }

    GCDAsyncReadPacket *packet = [[GCDAsyncReadPacket alloc] initWithData:buffer
    startOffset:offset
    maxLength:length
    timeout:timeout
    readLength:0
    terminator:nil
    tag:tag];

    dispatch_async(socketQueue, ^{ @autoreleasepool {

    LogTrace();

    if ((flags & kSocketStarted) && !(flags & kForbidReadsWrites))
    {
    //往读的队列添加任务,任务是包的形式
    [readQueue addObject:packet];
    [self maybeDequeueRead];
    }
    }});
    }


    这个方法很简单。最终调用,去创建了一个GCDAsyncReadPacket类型的对象packet,简单来说这个对象是用来标识读取任务的。然后把这个packet对象添加到读取队列中。然后去调用:

    [self maybeDequeueRead];


    去从队列中取出读取任务包,做读取操作。

    还记得我们之前Connect篇讲到的GCDAsyncSocket这个类的一些属性,其中有这么一个:

    //当前这次读取数据任务包
    GCDAsyncReadPacket *currentRead;

    这个属性标识了我们当前这次读取的任务,当读取到packet任务时,其实这个属性就被赋值成packet,做数据读取。

    接着来看看GCDAsyncReadPacket这个类,同样我们先看看属性:

    @interface GCDAsyncReadPacket : NSObject
    {
    @public
    //当前包的数据 ,(容器,有可能为空)
    NSMutableData *buffer;
    //开始偏移 (数据在容器中开始写的偏移)
    NSUInteger startOffset;
    //已读字节数 (已经写了个字节数)
    NSUInteger bytesDone;

    //想要读取数据的最大长度 (有可能没有)
    NSUInteger maxLength;
    //超时时长
    NSTimeInterval timeout;
    //当前需要读取总长度 (这一次read读取的长度,不一定有,如果没有则可用maxLength)
    NSUInteger readLength;

    //包的边界标识数据 (可能没有)
    NSData *term;
    //判断buffer的拥有者是不是这个类,还是用户。
    //跟初始化传不传一个buffer进来有关,如果传了,则拥有者为用户 NO, 否则为YES
    BOOL bufferOwner;
    //原始传过来的data长度
    NSUInteger originalBufferLength;
    //数据包的tag
    long tag;
    }

    这个类的内容还是比较多的,但是其实理解起来也很简单,它主要是来装当前任务的一些标识和数据,使我们能够正确的完成我们预期的读取任务。
    这些属性,大家同样过一个眼熟即可,后面大家就能理解它们了。

    这个类还有一堆方法,包括初始化的、和一些数据的操作方法,其具体作用如下注释:

    //初始化
    - (id)initWithData:(NSMutableData *)d
    startOffset:(NSUInteger)s
    maxLength:(NSUInteger)m
    timeout:(NSTimeInterval)t
    readLength:(NSUInteger)l
    terminator:(NSData *)e
    tag:(long)i;

    //确保容器大小给多余的长度
    - (void)ensureCapacityForAdditionalDataOfLength:(NSUInteger)bytesToRead;
    ////预期中读的大小,决定是否走preBuffer
    - (NSUInteger)optimalReadLengthWithDefault:(NSUInteger)defaultValue shouldPreBuffer:(BOOL *)shouldPreBufferPtr;
    //读取指定长度的数据
    - (NSUInteger)readLengthForNonTermWithHint:(NSUInteger)bytesAvailable;

    //上两个方法的综合
    - (NSUInteger)readLengthForTermWithHint:(NSUInteger)bytesAvailable shouldPreBuffer:(BOOL *)shouldPreBufferPtr;

    //根据一个终结符去读数据,直到读到终结的位置或者最大数据的位置,返回值为该包的确定长度
    - (NSUInteger)readLengthForTermWithPreBuffer:(GCDAsyncSocketPreBuffer *)preBuffer found:(BOOL *)foundPtr;
    ////查找终结符,在prebuffer之后,返回值为该包的确定长度
    - (NSInteger)searchForTermAfterPreBuffering:(ssize_t)numBytes;

    这里暂时仍然不准备去讲这些方法,等我们用到了在去讲它。

    我们通过上述的属性和这些方法,能够把数据正确的读取到packet的属性buffer中,再用代理回传给用户。

    这个GCDAsyncReadPacket类暂时就先这样了,我们接着往下看,前面讲到调用maybeDequeueRead开始读取任务,我们接下来就看看这个方法:

    //让读任务离队,开始执行这条读任务
    - (void)maybeDequeueRead
    {
    LogTrace();
    NSAssert(dispatch_get_specific(IsOnSocketQueueOrTargetQueueKey), @"Must be dispatched on socketQueue");

    // If we're not currently processing a read AND we have an available read stream

    //如果当前读的包为空,而且flag为已连接
    if ((currentRead == nil) && (flags & kConnected))
    {
    //如果读的queue大于0 (里面装的是我们封装的GCDAsyncReadPacket数据包)
    if ([readQueue count] > 0)
    {
    // Dequeue the next object in the write queue
    //使得下一个对象从写的queue中离开

    //从readQueue中拿到第一个写的数据
    currentRead = [readQueue objectAtIndex:0];
    //移除
    [readQueue removeObjectAtIndex:0];

    //我们的数据包,如果是GCDAsyncSpecialPacket这种类型,这个包里装了TLS的一些设置
    //如果是这种类型的数据,那么我们就进行TLS
    if ([currentRead isKindOfClass:[GCDAsyncSpecialPacket class]])
    {
    LogVerbose(@"Dequeued GCDAsyncSpecialPacket");

    // Attempt to start TLS
    //标记flag为正在读取TLS
    flags |= kStartingReadTLS;

    // This method won't do anything unless both kStartingReadTLS and kStartingWriteTLS are set
    //只有读写都开启了TLS,才会做TLS认证
    [self maybeStartTLS];
    }
    else
    {
    LogVerbose(@"Dequeued GCDAsyncReadPacket");

    // Setup read timer (if needed)
    //设置读的任务超时,每次延时的时候还会调用 [self doReadData];
    [self setupReadTimerWithTimeout:currentRead->timeout];

    // Immediately read, if possible
    //读取数据
    [self doReadData];
    }
    }

    //读的队列没有数据,标记flag为,读了没有数据则断开连接状态
    else if (flags & kDisconnectAfterReads)
    {
    //如果标记有写然后断开连接
    if (flags & kDisconnectAfterWrites)
    {
    //如果写的队列为0,而且写为空
    if (([writeQueue count] == 0) && (currentWrite == nil))
    {
    //断开连接
    [self closeWithError:nil];
    }
    }
    else
    {
    //断开连接
    [self closeWithError:nil];
    }
    }
    //如果有安全socket。
    else if (flags & kSocketSecure)
    {
    [self flushSSLBuffers];

    //如果可读字节数为0
    if ([preBuffer availableBytes] == 0)
    {
    //
    if ([self usingCFStreamForTLS]) {
    // Callbacks never disabled
    }
    else {
    //重新恢复读的source。因为每次开始读数据的时候,都会挂起读的source
    [self resumeReadSource];
    }
    }
    }
    }
    }

    详细的细节看注释即可,这里我们讲讲主要的作用:

    1. 我们首先做了一些是否连接,读队列任务是否大于0等等一些判断。当然,如果判断失败,那么就不在读取,直接返回。
    • 接着我们从全局的readQueue中,拿到第一条任务,去做读取,我们来判断这个任务的类型,如果是GCDAsyncSpecialPacket类型的,我们将开启TLS认证。(后面再来详细讲)

    如果是是我们之前加入队列中的GCDAsyncReadPacket类型,我们则开始读取操作,调用doReadData,这个方法将是整个Read篇的核心方法。

    • 如果队列中没有任务,我们先去判断,是否是上一次是读取了数据,但是没有数据的标记,如果是的话我们则断开socket连接(注:还记得么,我们之前应用篇有说过,调取读取任务时给一个超时,如果超过这个时间,还没读取到任务,则会断开连接,就是在这触发的)。
    • 如果我们是安全的连接(基于TLS的Socket),我们就去调用flushSSLBuffers,把数据从SSL通道中,移到我们的全局缓冲区preBuffer中。

    讲到这,大家可能觉得有些迷糊,为了能帮助大家理解,这里我准备了一张流程图,来讲讲整个框架读取数据的流程:




    1. 这张图就是整个数据的流向了,这里我们读取数据分为两种情况,一种是基于TLS,一种是普通的数据读取。
    • 而基于TLS的数据读取,又分为两种,一种是基于CFStream,另一种则是安全通道SecureTransport形式。
    • 这两种类型的TLS都会在各自的通道内,完成数据的解密,然后解密后的数据又流向了全局缓冲区prebuffer
    • 这个全局缓冲区prebuffer就像一个蓄水池,如果我们一直不去做读取任务的话,它里面的数据会越来越多,当我们读取其中所有数据,它就会回归最初的状态。
    • 我们用currentRead的方式,从prebuffer中读取数据,当读到我们想要的位置时,就会回调代理,用户得到数据。





    收起阅读 »

    Gradle 爬坑指南 -- 导论

    Gradle 内容真是超乎寻常的多,在写本文之前我以为有个万把字就差不多了,但随着越看越多,我发现想写的话一本书都是可以写出来的 〒▽〒 因为内容多,我只能拆成多篇文章了,希望能写全吧 我写文章都是喜欢以小白为出发点的,希望对那些一点都不了解 Gralde 的...
    继续阅读 »

    Gradle 内容真是超乎寻常的多,在写本文之前我以为有个万把字就差不多了,但随着越看越多,我发现想写的话一本书都是可以写出来的 〒▽〒


    因为内容多,我只能拆成多篇文章了,希望能写全吧


    我写文章都是喜欢以小白为出发点的,希望对那些一点都不了解 Gralde 的朋友能所有帮助,也希望能大大缩短大家学习 Gralde 的时间成本。Gradle 这东西对于一般人真的是难,非常难理解。相关的技术文章都是18年后才开始涌现出来的,之前的文章(尤其是15年那会AS出现时的文章)真的是非常非常少,可见难度之大。我想也只有之前精通后端,熟悉 Ant,Maven 构建工具,转到 Android 的那些高手们才能一上来就玩转 Gradle 吧 (੭ˊᵕˋ)੭*ଘ


    前言


    长征一直是被世人当做奇迹来看待的,实在是不能想象要拥有如何的毅力才能跨越这些人类禁区,我想也只有:负重前行、披荆斩棘、为了唯一的光明、相互鼓励、相互扶持 的先人们才能做的到吧 ┗|`O′|┛ 嗷~~


    可能是因为本猿感情丰富一些、平日爱做白日梦的关系吧,一直一来我都把软件开发这条路当做新时代版的万里长征:未来很光明、路途很遥远、当下很艰难。谁能说编程学习路线上那每一座难关不是雪山、不是草地?迈过去的难度非常大,太多的人徘徊在关口呻吟、亦或是被碰的头破血流,但是只要迈过去了,后面的风景何其精彩。即便前路还有万千苦难,但我已成战士,百折不挠是我的血,握紧手里的剑,我能扶摇天际 (☆-v-)


    曾几何时,大家还记得俯于案头而苦恼、百思不得其解而难过、怀疑自己能力而悲愤?Gradle 反反复复看了好几次,次次个把星期,但就是不得其解、不得其意、不得已精髓。而别人一个星期,Gradle 各种花样,各种黑科技,难倒大家不羡慕?为啥别人行我不行,为啥对比差距这么大?究其原因就是:低效学习! 基础薄弱怎能助自己攀登高峰,怀着没吃过猪肉还没见过猪跑的心情,怎能烹饪一手好红烧肉呢。全聚德的烤鸭之所以闻名全国,那是因为不仅了解、掌握食材的整个成长过程,更是自己优中选优选择最好的鸭苗,掌握鸭苗全程的成长,才能有的放矢,才能做出最好的烤鸭子


    这里安利下自己的文章:Android 筑基导论,希望能让大家能静下心来想一想,梳理一下自己的过往,明白万丈高楼平地起,什么才是助自己走向高峰的基石 (o≖◡≖)



    感谢前辈们孜孜不倦的输出,我才能把 Gradle 斩于马下,非常感谢 (๑•̀ㅂ•́)و✧


    写的有点啰嗦,希望啰嗦一点把 gradle 讲清楚,掌握合适的学习脉络,其实 Gradle 不难入手的


    本文主要是从 AS 角度来讲解 Gradle ✧(≖ ◡ ≖✿)



    单词


    学习 Gradle 第一个拦路虎就是有点多、不认识的单词了,总是看见不认识的单词、不理解是什么意思,其实挺劝退的,至少我的感受是这样。下面这些单词大家熟悉下:



    • Script --> 脚本(build.gradle)

    • Plugin --> 插件(apply plugin: 'com.android.application')

    • generic --> 通用、一般

    • task --> 任务(cleanBuildCache)

    • graph --> 图表、曲线图

    • assemble --> 集合、收集、打包

    • compile --> 编译

    • Evaluate --> 评估、构建

    • resolve --> 决定

    • Execution --> 执行

    • Closure --> 闭包、终止(android{...})

    • confuse --> 混乱

    • Script Block --> 脚本块

    • delegate --> 委托

    • transform --> 变换

    • channel --> 渠道

    • flavor --> 味道、差异、这里代指渠道

    • dimension --> 维度

    • variant --> 变体

    • annotationprocessor --> 注解处理器

    • ProGuard --> 混淆

    • console --> 控制台

    • company --> 仓库


    参考资料



    maven 中心仓库 大家百度打开这个连接,进去直接搜索我们想看的第三当框架,能看到所有版本的信息,点开可以看到 maven 地址





    我的学习文章


    成体系的东西,尤其是你妹接触过的,我认为在学习阶段必须要写文章,要记录下来,即便网上这类文章有太多太多,你也必须写自己的文章。写完了你才能把自己学到的梳理清楚,动键盘开始码字你才知道有多少自己其实并没有学明白,记下来你才能记得清楚,忘了好回头看


    第一阶段


    该阶段目的在于了解 Gradle 构建工具、Groovy 语法,了解其中概念,知道什么是 Task、Plugin,了解 Gradle 内置对象、生命周期 Hook 函数,构建过程。这些都是 Gradle 比较粗粒度的知识体系,只有了解这些你才能上手、入门 Gradle



    第二阶段


    该阶段在于深入学习 Gradle 的细节,及其基于自定义插件实现各种功能,学到这里基本就能出师了,Gradle 以后就不是问题了,再看见有新的内容也能看得懂、学的会了,就能跟上业界 Gradle 主流水平了



    Gradle 学习指南


    1. Gradle 基本学习路线



    1. 先了解什么是 Gradle、Groovy

    2. 熟悉 Groovy 语法

    3. 熟悉什么是 plugin 插件、task 任务

    4. 熟悉 Gradle 核心对象:Gradle、Setting、Project,Gradle 构建流程、生命周期、及其 hook 勾子函数

    5. 熟悉 Android 项目构建,application 这个插件

    6. 了解自定义 task,自定义 plugin 并上传 maven 以及使用

    7. 各种 自定义 plugin 花样


    这是基本的学习思路,结合我提供的学习资料,我想至少可以给大家减少很多寻找资料、理清脉络、反复折腾的时间,下面贴一下具体学习指南


    2. Gradle 学习资料食用指南


    Gradle 很复杂、学习难度很大的,一遍基本是不够的,请大家耐心反复看几遍 <( ̄ˇ ̄)/



    1. 首先还是希望大家能先阅读下本文,先对 Gradle 有些基本理解再看后面,尤其是一点 Gradle 基础都没有的同学,我是真的建议大家先把我这篇文章看完,我写文章从来都是从小白出发

    2. 来自Gradle开发团队的Gradle入门教程 --> 优先推荐大家看看这个,来自 Gradle 管方团队的推广真不是盖的,概念解释清晰,言简意赅、逻辑条理 Nice,会帮你理解 Gradle 的全貌,虽然具体内容不是很多。但是带着官方的理解再去看其他资料能减少很多概念理解上的歧义,能帮助提升大家的学习效率

    3. Gradle系统详解 --> 这个4个来小时,尝试把 Gradle 都讲一遍,但是效果不怎么好,推荐大家过一遍,增加了解

    4. gradle快速入门 | 自定义编写Gradle插件 --> 这2篇都是讲 plugin 插件、task 的,大家看完之后对这2块会有比较深的了解

    5. 用Artifactory和Retryofit助力Gradle提高开发效率 --> 这个是讲解 Gradle 自动化打包、上传、发布的,适合有需求的朋友看

    6. 掘金小册-Mastering Gradle --> 这本掘金小册真的是新人杀手、劝退指南。文章虽然内容混乱,质量还是不错的,推荐大家对 Gradle 有系统了解之后再来看

    7. Gradle--官方文档 --> 管方文档最后还是推荐大家看看的,结合 Google 浏览器的自动翻译插件,还是能看的,完全没问题

    8. 剩下的就是 Gradle 插件的应用了原理了,这块推荐大家看看掘金 jsonchao 同学的系列文章,既全面,又有深度


    这一套下来,基本上 Gradle 对于大家来说就没什么大问题了,剩下的就是在项目中实际应用了。再有掘金上有黑科技文章出来大家也不会看不懂,不会用了。总体耗时会长一些,但是真的可以一次学习,终身受益,不用反复折腾了


    Android 插件打包流程图




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

    后台返回数据错误json解析总报错怎么办!这个框架解决你的烦恼!

    集成步骤在项目根目录下的 build.gradle 文件中加入buildscript { ...... } allprojects { repositories { // JitPack 远程仓库:https...
    继续阅读 »

    集成步骤

    • 在项目根目录下的 build.gradle 文件中加入
    buildscript {
    ......
    }
    allprojects {
    repositories {
    // JitPack 远程仓库:https://jitpack.io
    maven { url 'https://jitpack.io' }
    }
    }
    • 在项目 app 模块下的 build.gradle 文件中加入
    android {
    // 支持 JDK 1.8
    compileOptions {
    targetCompatibility JavaVersion.VERSION_1_8
    sourceCompatibility JavaVersion.VERSION_1_8
    }
    }

    dependencies {
    // Gson 解析容错:https://github.com/getActivity/GsonFactory
    implementation 'com.github.getActivity:GsonFactory:5.2'
    // Json 解析框架:https://github.com/google/gson
    implementation 'com.google.code.gson:gson:2.8.5'
    }

    使用文档

    • 请使用框架返回的 Gson 对象来代替项目中的 Gson 对象
    // 获取单例的 Gson 对象(已处理容错)
    Gson gson = GsonFactory.getSingletonGson();
    • 因为框架中的 Gson 对象已经对解析规则进行了容错处理

    其他 API

    // 设置自定义的 Gson 对象
    GsonFactory.setSingletonGson(Gson gson);

    // 创建一个 Gson 构建器(已处理容错)
    GsonBuilder gsonBuilder = GsonFactory.newGsonBuilder();

    // 注册类型适配器
    GsonFactory.registerTypeAdapterFactory(TypeAdapterFactory factory);

    // 注册构造函数创建器
    GsonFactory.registerInstanceCreator(Type type, InstanceCreator<?> creator);

    // 设置 Json 解析容错监听
    GsonFactory.setJsonCallback(new JsonCallback() {

    @Override
    public void onTypeException(TypeToken<?> typeToken, String fieldName, JsonToken jsonToken) {
    // Log.e("GsonFactory", "类型解析异常:" + typeToken + "#" + fieldName + ",后台返回的类型为:" + jsonToken);
    // 上报到 Bugly 错误列表
    CrashReport.postCatchedException(new IllegalArgumentException("类型解析异常:" + typeToken + "#" + fieldName + ",后台返回的类型为:" + jsonToken));
    }
    });

    容错介绍

    • 目前支持容错的数据类型有:

      • Bean 类

      • 数组集合

      • String(字符串)

      • boolean / Boolean(布尔值)

      • int / Integer(整数,属于数值类)

      • long /Long(长整数,属于数值类)

      • float / Float(单精度浮点数,属于数值类)

      • double / Double(双精度浮点数,属于数值类)

      • BigDecimal(精度更高的浮点数,属于数值类)

    • 基本涵盖 99.99% 的开发场景,可以运行 Demo 中的单元测试用例来查看效果:

    数据类型容错的范围数据示例
    bean集合、字符串、布尔值、数值[]""false0
    集合bean、字符串、布尔值、数值{}""false0
    字符串bean、集合、布尔值、数值{}[]false0
    布尔值bean、集合、字符串、数值{}[]""0
    数值bean、集合、字符串、布尔值{}[]""false
    • 大家可能觉得 Gson 解析容错没什么,那是因为我们对 Gson 解析失败的场景没有了解过:

      • 类型不对:后台有数据时返回 JsonObject,没数据返回 [],Gson 会直接抛出异常

      • 措手不及:如果客户端定义的是整数,但是后台返回浮点数,Gson 会直接抛出异常

      • 意想不到:如果客户端定义的是布尔值,但是后台返回的是 0 或者 1,Gson 会直接抛出异常

    • 以上情况框架已经做了容错处理,具体处理规则如下:

      • 如果后台返回的类型和客户端定义的类型不匹配,框架就不解析这个字段

      • 如果客户端定义的是整数,但后台返回浮点数,框架就对数值进行取整并赋值给字段

      • 如果客户端定义布尔值,但是后台返回整数,框架则将非 0 的数值则赋值为 true,否则为 false

    常见疑问解答

    • Retrofit + RxJava 怎么替换?
    Retrofit retrofit = new Retrofit.Builder()
    .addConverterFactory(GsonConverterFactory.create(GsonFactory.getSingletonGson()))
    .build();
    • 有没有必要处理 Json 解析容错?

    我觉得非常有必要,因为后台返回的数据结构是什么样我们把控不了,但是有一点是肯定的,我们都不希望它崩,因为一个接口的失败导致整个 App 崩溃退出实属不值得,但是 Gson 很敏感,动不动就崩。

    • 我们后台用的是 Java,有必要处理容错吗?

    如果你们的后台用的是 PHP,那我十分推荐你使用这个框架,因为 PHP 返回的数据结构很乱,这块经历过的人都懂,没经历过的人怎么说都不懂。

    如果你们的后台用的是 Java,那么可以根据实际情况而定,例如我现在的公司用的就是 Java 后台,但是 Bugly 有上报一个关于 Gson 解析的 Crash,所以后台的话只能信一半。


    代码下载:GsonFactory-master.zip

    收起阅读 »

    Android下拉刷新完全解析,教你如何一分钟实现下拉刷新功能

    最近项目中需要用到 ListView 下拉刷新的功能,一开始想图省事,在网上直接找一个现成的,可是尝试了网上多个版本的下拉刷新之后发现效果都不怎么理想。有些是因为功能不完整或有 Bug,有些是因为使用起来太复杂,十全十美的还真没找到。因此我也是放弃了在网上找现...
    继续阅读 »

    Android下拉刷新完全解析,教你如何一分钟实现下拉刷新功能

    最近项目中需要用到 ListView 下拉刷新的功能,一开始想图省事,在网上直接找一个现成的,可是尝试了网上多个版本的下拉刷新之后发现效果都不怎么理想。有些是因为功能不完整或有 Bug,有些是因为使用起来太复杂,十全十美的还真没找到。因此我也是放弃了在网上找现成代码的想法,自己花功夫编写了一种非常简单的下拉刷新实现方案,现在拿出来和大家分享一下。相信在阅读完本篇文章之后,大家都可以在自己的项目中一分钟引入下拉刷新功能。

    首先讲一下实现原理。这里我们将采取的方案是使用组合 View 的方式,先自定义一个布局继承自 LinearLayout,然后在这个布局中加入下拉头和 ListView 这两个子元素,并让这两个子元素纵向排列。初始化的时候,让下拉头向上偏移出屏幕,这样我们看到的就只有 ListView 了。然后对 ListView 的 touch 事件进行监听,如果当前 ListView 已经滚动到顶部并且手指还在向下拉的话,那就将下拉头显示出来,松手后进行刷新操作,并将下拉头隐藏。原理示意图如下:

    那我们现在就来动手实现一下,新建一个项目起名叫 PullToRefreshTest,先在项目中定义一个下拉头的布局文件 pull_to_refresh.xml,代码如下所

    在这个布局中,我们包含了一个下拉指示箭头,一个下拉状态文字提示,和一个上次更新的时间。当然,还有一个隐藏的旋转进度条,只有正在刷新的时候我们才会将它显示出来。

    布局中所有引用的字符串我们都放在 strings.xml 中,如下所示

    PullToRefreshTest
    下拉可以刷新
    释放立即刷新
    正在刷新…
    暂未更新过
    上次更新于%1$s前
    刚刚更新
    时间有问题


    然后新建一个 RefreshableView 继承自 LinearLayout,代码如下所示:

    public class RefreshableView extends LinearLayout implements OnTouchListener {

    /**
    * 下拉状态
    */
    public static final int STATUS_PULL_TO_REFRESH = 0;

    /**
    * 释放立即刷新状态
    */
    public static final int STATUS_RELEASE_TO_REFRESH = 1;

    /**
    * 正在刷新状态
    */
    public static final int STATUS_REFRESHING = 2;

    /**
    * 刷新完成或未刷新状态
    */
    public static final int STATUS_REFRESH_FINISHED = 3;

    /**
    * 下拉头部回滚的速度
    */
    public static final int SCROLL_SPEED = -20;

    /**
    * 一分钟的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_MINUTE = 60 * 1000;

    /**
    * 一小时的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_HOUR = 60 * ONE_MINUTE;

    /**
    * 一天的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_DAY = 24 * ONE_HOUR;

    /**
    * 一月的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_MONTH = 30 * ONE_DAY;

    /**
    * 一年的毫秒值,用于判断上次的更新时间
    */
    public static final long ONE_YEAR = 12 * ONE_MONTH;

    /**
    * 上次更新时间的字符串常量,用于作为SharedPreferences的键值
    */
    private static final String UPDATED_AT = "updated_at";

    /**
    * 下拉刷新的回调接口
    */
    private PullToRefreshListener mListener;

    /**
    * 用于存储上次更新时间
    */
    private SharedPreferences preferences;

    /**
    * 下拉头的View
    */
    private View header;

    /**
    * 需要去下拉刷新的ListView
    */
    private ListView listView;

    /**
    * 刷新时显示的进度条
    */
    private ProgressBar progressBar;

    /**
    * 指示下拉和释放的箭头
    */
    private ImageView arrow;

    /**
    * 指示下拉和释放的文字描述
    */
    private TextView description;

    /**
    * 上次更新时间的文字描述
    */
    private TextView updateAt;

    /**
    * 下拉头的布局参数
    */
    private MarginLayoutParams headerLayoutParams;

    /**
    * 上次更新时间的毫秒值
    */
    private long lastUpdateTime;

    /**
    * 为了防止不同界面的下拉刷新在上次更新时间上互相有冲突,使用id来做区分
    */
    private int mId = -1;

    /**
    * 下拉头的高度
    */
    private int hideHeaderHeight;

    /**
    * 当前处理什么状态,可选值有STATUS_PULL_TO_REFRESH, STATUS_RELEASE_TO_REFRESH,
    * STATUS_REFRESHING 和 STATUS_REFRESH_FINISHED
    */
    private int currentStatus = STATUS_REFRESH_FINISHED;;

    /**
    * 记录上一次的状态是什么,避免进行重复操作
    */
    private int lastStatus = currentStatus;

    /**
    * 手指按下时的屏幕纵坐标
    */
    private float yDown;

    /**
    * 在被判定为滚动之前用户手指可以移动的最大值。
    */
    private int touchSlop;

    /**
    * 是否已加载过一次layout,这里onLayout中的初始化只需加载一次
    */
    private boolean loadOnce;

    /**
    * 当前是否可以下拉,只有ListView滚动到头的时候才允许下拉
    */
    private boolean ableToPull;

    /**
    * 下拉刷新控件的构造函数,会在运行时动态添加一个下拉头的布局。
    *
    * @param context
    * @param attrs
    */
    public RefreshableView(Context context, AttributeSet attrs) {
    super(context, attrs);
    preferences = PreferenceManager.getDefaultSharedPreferences(context);
    header = LayoutInflater.from(context).inflate(R.layout.pull_to_refresh, null, true);
    progressBar = (ProgressBar) header.findViewById(R.id.progress_bar);
    arrow = (ImageView) header.findViewById(R.id.arrow);
    description = (TextView) header.findViewById(R.id.description);
    updateAt = (TextView) header.findViewById(R.id.updated_at);
    touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
    refreshUpdatedAtValue();
    setOrientation(VERTICAL);
    addView(header, 0);
    }

    /**
    * 进行一些关键性的初始化操作,比如:将下拉头向上偏移进行隐藏,给ListView注册touch事件。
    */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
    super.onLayout(changed, l, t, r, b);
    if (changed && !loadOnce) {
    hideHeaderHeight = -header.getHeight();
    headerLayoutParams = (MarginLayoutParams) header.getLayoutParams();
    headerLayoutParams.topMargin = hideHeaderHeight;
    listView = (ListView) getChildAt(1);
    listView.setOnTouchListener(this);
    loadOnce = true;
    }
    }

    /**
    * 当ListView被触摸时调用,其中处理了各种下拉刷新的具体逻辑。
    */
    @Override
    public boolean onTouch(View v, MotionEvent event) {
    setIsAbleToPull(event);
    if (ableToPull) {
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
    yDown = event.getRawY();
    break;
    case MotionEvent.ACTION_MOVE:
    float yMove = event.getRawY();
    int distance = (int) (yMove - yDown);
    // 如果手指是下滑状态,并且下拉头是完全隐藏的,就屏蔽下拉事件
    if (distance <= 0 && headerLayoutParams.topMargin <= hideHeaderHeight) {
    return false;
    }
    if (distance < touchSlop) {
    return false;
    }
    if (currentStatus != STATUS_REFRESHING) {
    if (headerLayoutParams.topMargin > 0) {
    currentStatus = STATUS_RELEASE_TO_REFRESH;
    } else {
    currentStatus = STATUS_PULL_TO_REFRESH;
    }
    // 通过偏移下拉头的topMargin值,来实现下拉效果
    headerLayoutParams.topMargin = (distance / 2) + hideHeaderHeight;
    header.setLayoutParams(headerLayoutParams);
    }
    break;
    case MotionEvent.ACTION_UP:
    default:
    if (currentStatus == STATUS_RELEASE_TO_REFRESH) {
    // 松手时如果是释放立即刷新状态,就去调用正在刷新的任务
    new RefreshingTask().execute();
    } else if (currentStatus == STATUS_PULL_TO_REFRESH) {
    // 松手时如果是下拉状态,就去调用隐藏下拉头的任务
    new HideHeaderTask().execute();
    }
    break;
    }
    // 时刻记得更新下拉头中的信息
    if (currentStatus == STATUS_PULL_TO_REFRESH
    || currentStatus == STATUS_RELEASE_TO_REFRESH) {
    updateHeaderView();
    // 当前正处于下拉或释放状态,要让ListView失去焦点,否则被点击的那一项会一直处于选中状态
    listView.setPressed(false);
    listView.setFocusable(false);
    listView.setFocusableInTouchMode(false);
    lastStatus = currentStatus;
    // 当前正处于下拉或释放状态,通过返回true屏蔽掉ListView的滚动事件
    return true;
    }
    }
    return false;
    }

    /**
    * 给下拉刷新控件注册一个监听器。
    *
    * @param listener
    * 监听器的实现。
    * @param id
    * 为了防止不同界面的下拉刷新在上次更新时间上互相有冲突, 请不同界面在注册下拉刷新监听器时一定要传入不同的id。
    */
    public void setOnRefreshListener(PullToRefreshListener listener, int id) {
    mListener = listener;
    mId = id;
    }

    /**
    * 当所有的刷新逻辑完成后,记录调用一下,否则你的ListView将一直处于正在刷新状态。
    */
    public void finishRefreshing() {
    currentStatus = STATUS_REFRESH_FINISHED;
    preferences.edit().putLong(UPDATED_AT + mId, System.currentTimeMillis()).commit();
    new HideHeaderTask().execute();
    }

    /**
    * 根据当前ListView的滚动状态来设定 {@link #ableToPull}
    * 的值,每次都需要在onTouch中第一个执行,这样可以判断出当前应该是滚动ListView,还是应该进行下拉。
    *
    * @param event
    */
    private void setIsAbleToPull(MotionEvent event) {
    View firstChild = listView.getChildAt(0);
    if (firstChild != null) {
    int firstVisiblePos = listView.getFirstVisiblePosition();
    if (firstVisiblePos == 0 && firstChild.getTop() == 0) {
    if (!ableToPull) {
    yDown = event.getRawY();
    }
    // 如果首个元素的上边缘,距离父布局值为0,就说明ListView滚动到了最顶部,此时应该允许下拉刷新
    ableToPull = true;
    } else {
    if (headerLayoutParams.topMargin != hideHeaderHeight) {
    headerLayoutParams.topMargin = hideHeaderHeight;
    header.setLayoutParams(headerLayoutParams);
    }
    ableToPull = false;
    }
    } else {
    // 如果ListView中没有元素,也应该允许下拉刷新
    ableToPull = true;
    }
    }

    /**
    * 更新下拉头中的信息。
    */
    private void updateHeaderView() {
    if (lastStatus != currentStatus) {
    if (currentStatus == STATUS_PULL_TO_REFRESH) {
    description.setText(getResources().getString(R.string.pull_to_refresh));
    arrow.setVisibility(View.VISIBLE);
    progressBar.setVisibility(View.GONE);
    rotateArrow();
    } else if (currentStatus == STATUS_RELEASE_TO_REFRESH) {
    description.setText(getResources().getString(R.string.release_to_refresh));
    arrow.setVisibility(View.VISIBLE);
    progressBar.setVisibility(View.GONE);
    rotateArrow();
    } else if (currentStatus == STATUS_REFRESHING) {
    description.setText(getResources().getString(R.string.refreshing));
    progressBar.setVisibility(View.VISIBLE);
    arrow.clearAnimation();
    arrow.setVisibility(View.GONE);
    }
    refreshUpdatedAtValue();
    }
    }

    /**
    * 根据当前的状态来旋转箭头。
    */
    private void rotateArrow() {
    float pivotX = arrow.getWidth() / 2f;
    float pivotY = arrow.getHeight() / 2f;
    float fromDegrees = 0f;
    float toDegrees = 0f;
    if (currentStatus == STATUS_PULL_TO_REFRESH) {
    fromDegrees = 180f;
    toDegrees = 360f;
    } else if (currentStatus == STATUS_RELEASE_TO_REFRESH) {
    fromDegrees = 0f;
    toDegrees = 180f;
    }
    RotateAnimation animation = new RotateAnimation(fromDegrees, toDegrees, pivotX, pivotY);
    animation.setDuration(100);
    animation.setFillAfter(true);
    arrow.startAnimation(animation);
    }

    /**
    * 刷新下拉头中上次更新时间的文字描述。
    */
    private void refreshUpdatedAtValue() {
    lastUpdateTime = preferences.getLong(UPDATED_AT + mId, -1);
    long currentTime = System.currentTimeMillis();
    long timePassed = currentTime - lastUpdateTime;
    long timeIntoFormat;
    String updateAtValue;
    if (lastUpdateTime == -1) {
    updateAtValue = getResources().getString(R.string.not_updated_yet);
    } else if (timePassed < 0) {
    updateAtValue = getResources().getString(R.string.time_error);
    } else if (timePassed < ONE_MINUTE) {
    updateAtValue = getResources().getString(R.string.updated_just_now);
    } else if (timePassed < ONE_HOUR) {
    timeIntoFormat = timePassed / ONE_MINUTE;
    String value = timeIntoFormat + "分钟";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    } else if (timePassed < ONE_DAY) {
    timeIntoFormat = timePassed / ONE_HOUR;
    String value = timeIntoFormat + "小时";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    } else if (timePassed < ONE_MONTH) {
    timeIntoFormat = timePassed / ONE_DAY;
    String value = timeIntoFormat + "天";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    } else if (timePassed < ONE_YEAR) {
    timeIntoFormat = timePassed / ONE_MONTH;
    String value = timeIntoFormat + "个月";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    } else {
    timeIntoFormat = timePassed / ONE_YEAR;
    String value = timeIntoFormat + "年";
    updateAtValue = String.format(getResources().getString(R.string.updated_at), value);
    }
    updateAt.setText(updateAtValue);
    }

    /**
    * 正在刷新的任务,在此任务中会去回调注册进来的下拉刷新监听器。
    *
    * @author guolin
    */
    class RefreshingTask extends AsyncTask {

    @Override
    protected Void doInBackground(Void... params) {
    int topMargin = headerLayoutParams.topMargin;
    while (true) {
    topMargin = topMargin + SCROLL_SPEED;
    if (topMargin <= 0) {
    topMargin = 0;
    break;
    }
    publishProgress(topMargin);
    sleep(10);
    }
    currentStatus = STATUS_REFRESHING;
    publishProgress(0);
    if (mListener != null) {
    mListener.onRefresh();
    }
    return null;
    }

    @Override
    protected void onProgressUpdate(Integer... topMargin) {
    updateHeaderView();
    headerLayoutParams.topMargin = topMargin[0];
    header.setLayoutParams(headerLayoutParams);
    }

    }

    /**
    * 隐藏下拉头的任务,当未进行下拉刷新或下拉刷新完成后,此任务将会使下拉头重新隐藏。
    *
    * @author guolin
    */
    class HideHeaderTask extends AsyncTask {

    @Override
    protected Integer doInBackground(Void... params) {
    int topMargin = headerLayoutParams.topMargin;
    while (true) {
    topMargin = topMargin + SCROLL_SPEED;
    if (topMargin <= hideHeaderHeight) {
    topMargin = hideHeaderHeight;
    break;
    }
    publishProgress(topMargin);
    sleep(10);
    }
    return topMargin;
    }

    @Override
    protected void onProgressUpdate(Integer... topMargin) {
    headerLayoutParams.topMargin = topMargin[0];
    header.setLayoutParams(headerLayoutParams);
    }

    @Override
    protected void onPostExecute(Integer topMargin) {
    headerLayoutParams.topMargin = topMargin;
    header.setLayoutParams(headerLayoutParams);
    currentStatus = STATUS_REFRESH_FINISHED;
    }
    }

    /**
    * 使当前线程睡眠指定的毫秒数。
    *
    * @param time
    * 指定当前线程睡眠多久,以毫秒为单位
    */
    private void sleep(int time) {
    try {
    Thread.sleep(time);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }

    /**
    * 下拉刷新的监听器,使用下拉刷新的地方应该注册此监听器来获取刷新回调。
    *
    * @author guolin
    */
    public interface PullToRefreshListener {

    /**
    * 刷新时会去回调此方法,在方法内编写具体的刷新逻辑。注意此方法是在子线程中调用的, 你可以不必另开线程来进行耗时操作。
    */
    void onRefresh();

    }

    }


    这个类是整个下拉刷新功能中最重要的一个类,注释已经写得比较详细了,我再简单解释一下。首先在 RefreshableView 的构造函数中动态添加了刚刚定义的 pull_to_refresh 这个布局作为下拉头,然后在 onLayout 方法中将下拉头向上偏移出了屏幕,再给 ListView 注册了 touch 事件。之后每当手指在 ListView 上滑动时,onTouch 方法就会执行。在 onTouch 方法中的第一行就调用了 setIsAbleToPull 方法来判断 ListView 是否滚动到了最顶部,只有滚动到了最顶部才会执行后面的代码,否则就视为正常的 ListView 滚动,不做任何处理。当 ListView 滚动到了最顶部时,如果手指还在向下拖动,就会改变下拉头的偏移值,让下拉头显示出来,下拉的距离设定为手指移动距离的 1/2,这样才会有拉力的感觉。如果下拉的距离足够大,在松手的时候就会执行刷新操作,如果距离不够大,就仅仅重新隐藏下拉头。

    具体的刷新操作会在 RefreshingTask 中进行,其中在 doInBackground 方法中回调了 PullToRefreshListener 接口的 onRefresh 方法,这也是大家在使用 RefreshableView 时必须要去实现的一个接口,因为具体刷新的逻辑就应该写在 onRefresh 方法中,后面会演示使用的方法。

    另外每次在下拉的时候都还会调用 updateHeaderView 方法来改变下拉头中的数据,比如箭头方向的旋转,下拉文字描述的改变等。更加深入的理解请大家仔细去阅读 RefreshableView 中的代码。

    现在我们已经把下拉刷新的所有功能都完成了,接下来就要看一看如何在项目中引入下拉刷新了。打开或新建 activity_main.xml 作为程序主界面的布局,加入如下代码:

    可以看到,我们在自定义的 RefreshableView 中加入了一个 ListView,这就意味着给这个 ListView 加入了下拉刷新的功能,就是这么简单!
    然后我们再来看一下程序的主 Activity,打开或新建 MainActivity,加入如下代码:

    public class MainActivity extends Activity {

    RefreshableView refreshableView;
    ListView listView;
    ArrayAdapter adapter;
    String[] items = { "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L" };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    requestWindowFeature(Window.FEATURE_NO_TITLE);
    setContentView(R.layout.activity_main);
    refreshableView = (RefreshableView) findViewById(R.id.refreshable_view);
    listView = (ListView) findViewById(R.id.list_view);
    adapter = new ArrayAdapter(this, android.R.layout.simple_list_item_1, items);
    listView.setAdapter(adapter);
    refreshableView.setOnRefreshListener(new PullToRefreshListener() {
    @Override
    public void onRefresh() {
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    refreshableView.finishRefreshing();
    }
    }, 0);
    }

    }


    可以看到,我们通过调用 RefreshableView 的 setOnRefreshListener 方法注册了一个监听器,当 ListView 正在刷新时就会回调监听器的 onRefresh 方法,刷新的具体逻辑就在这里处理。而且这个方法已经自动开启了线程,可以直接在 onRefresh 方法中进行耗时操作,比如向服务器请求最新数据等,在这里我就简单让线程睡眠 3 秒钟。另外在 onRefresh 方法的最后,一定要调用 RefreshableView 中的 finishRefreshing 方法,这个方法是用来通知 RefreshableView 刷新结束了,不然我们的 ListView 将一直处于正在刷新的状态。

    不知道大家有没有注意到,setOnRefreshListener 这个方法其实是有两个参数的,我们刚刚也是传入了一个不起眼的 0。那这第二个参数是用来做什么的呢?由于 RefreshableView 比较智能,它会自动帮我们记录上次刷新完成的时间,然后下拉的时候会在下拉头中显示距上次刷新已过了多久。这是一个非常好用的功能,让我们不用再自己手动去记录和计算时间了,但是却存在一个问题。如果当前我们的项目中有三个地方都使用到了下拉刷新的功能,现在在一处进行了刷新,其它两处的时间也都会跟着改变!因为刷新完成的时间是记录在配置文件中的,由于在一处刷新更改了配置文件,导致在其它两处读取到的配置文件时间已经是更改过的了。那解决方案是什么?就是每个用到下拉刷新的地方,给 setOnRefreshListener 方法的第二个参数中传入不同的 id 就行了。这样各处的上次刷新完成时间都是单独记录的,相互之间就不会再有影响。

    好了,全部的代码都在这里了,让我们来运行一下,看看效果吧。

    效果看起来还是非常不错的。我们最后再来总结一下,在项目中引入 ListView 下拉刷新功能只需三步:

    1. 在 Activity 的布局文件中加入自定义的 RefreshableView,并让 ListView 包含在其中。

    2. 在 Activity 中调用 RefreshableView 的 setOnRefreshListener 方法注册回调接口。

    3. 在 onRefresh 方法的最后,记得调用 RefreshableView 的 finishRefreshing 方法,通知刷新结束。

    从此以后,在项目的任何地方,一分钟引入下拉刷新功能妥妥的。

    好了,今天的讲解到此结束,有疑问的朋友请在下面留言。

    收起阅读 »

    Android JPEG 压缩那些事

    JPEG 基础知识 JPEG(Joint Photographic Experts Group,联合图像专家小组)是一种针对照片影像广泛使用的有损压缩标准方法。 使用 JPEG 格式压缩的图片文件,最普遍使用的扩展名格式为 .jpg,其他常用的扩展名还包括 ....
    继续阅读 »

    JPEG 基础知识


    JPEG(Joint Photographic Experts Group,联合图像专家小组)是一种针对照片影像广泛使用的有损压缩标准方法。


    使用 JPEG 格式压缩的图片文件,最普遍使用的扩展名格式为 .jpg,其他常用的扩展名还包括 .JPEG、.jpe、.jfif 以及 .jif。


    JEPG 编码原理


    虽然 JEPG 文件可以以各种方式进行编码,但最常见的是使用 JFIF 编码,编码过程包括以下几个步骤:




    • 色彩空间转换


      将图像从 RGB 转换为 Y'CbCr 的不同颜色空间。Y' 分量表示像素的亮度,而 CbCr 代表"色差值"(分为蓝色和红色分量)。Y'CbCr 颜色空间允许更大的压缩,而不会对感知图像质量产生重大影响。



      关于"色差"


      "色差"这个概念起源于电视行业,最早的电视都是黑白的,那时候传输电视信号只需要传输亮度信号,也就是Y信号即可,彩色电视出现之后,人们在Y信号之外增加了两条色差信号以传输颜色信息,这么做的目的是为了兼容黑白电视机,因为黑白电视只需要处理信号中的Y信号即可。


      根据三基色原理,人们发现红绿蓝三种颜色所贡献的亮度是不同的,绿色的“亮度”最大,蓝色最暗,设红色所贡献的亮度的份额为KR,蓝色贡献的份额为KB,那么亮度为


      1


      根据经验,KR=0.299,KB=0.114,那么


      2


      蓝色和红色的色差的定义如下


      3


      最终可以得到RGB转换为YCbCr的数学公式为


      4





    • 下采样


      与图像的颜色(Cb 和 Cr 分量)相比,人类眼睛对图像的亮度(Y' 分量)在图像精细度上更敏感。



      对于人眼来说,图像中明暗的变化更容易被感知到,这是由于人眼的构造引起的。视网膜上有两种感光细胞,能够感知亮度变化的视杆细胞,以及能够感知颜色的视锥细胞,由于视杆细胞在数量上远大于视锥细胞,所以我们更容易感知到明暗细节。比如说下面这张图


      原图


      只保留 Y' 分量


      Y


      只保留 Cb 分量


      Cb


      只保留 Cr 分量


      Cr



      利用这个特性,可以对 Y'CbCr 颜色空间做进一步的下采样,即降低 CbCr 分量的空间分辨率。


      下采样率为 "4:4:4" 表示不进行下采样;


      下采样率为 "4:2:2" 表示水平方向上减少 2 倍


      下采样率为 "4:2:0" 表示水平和垂直方向上减少 2 倍(最常用)


      image-20210411164013275



      下采样率通常表示为三部分比率 j:a:b,如果存在透明度则为四部分,这描述了 j个像素宽和 2 个像素高的概念区域中亮度和色度样本的数量。



      • j 表示水平方向采样率参考(概念区域的宽度)

      • a 表示第一行的色差采样(Cr,Cb)

      • b 表示第二行和第一行的色差采样(Cr,Cb)变化





    • 块分割


      在下采样之后,每个通道必须被分割为 8x8 的像素块,最小编码单位(MCU) 取决于使用的下采样。


      如果下采样率为 "4:4:4",则最小编码单位块的大小为 8x8;


      如果下采样率为 "4:2:2",则最小编码单位块的大小为 16x8;


      如果下采样率为 "4:2:0",则最小编码单位块的大小为 16x16;


      image-20210509221734480


      如果通道数据不能被切割为整数倍的块,则通常会使用纯色去填充,例如黑色。




    • 离散余弦变换




    • 量化


      人眼善于在相对较大的区域看到较小的亮度差异,但不能很好地区分高频亮度变化的确切强度。这使得人们可以大大减少高频分量中的信息量。只需将频域中的每个分量除以该分量的常量,然后四舍五入(有损运算)为最接近的整数即可。


      这个步骤是不可逆的




    • 使用无损算法(霍夫曼编码的一种变体)进一步压缩所有 8×8 块的数据。




    JEPG 压缩效果











































    图片质量([1,100])大小(bytes)压缩比例
    JPEG example JPG RIP 100.jpg最高质量(100)814472.7:1
    JPEG example JPG RIP 050.jpg高质量(50)1467915:1
    JPEG example JPG RIP 025.jpg中等质量(25)940723:1
    JPEG example JPG RIP 010.jpg低质量(10)478746:1
    JPEG example JPG RIP 001.jpg最低质量(1)1523144:1

    JEPG 编码实现




    • libjpeg


      广泛使用的 C 库,用于读取和写入 JPEG 图像文件。




    • libjpeg-turbo


      高性能的 JEPG 图像解编码器,使用 SIMD 指令来加速在 x86、x86-64、Arm 和 PowerPC 系统上的 JEPG 文件压缩和解压缩,以及在 x86、x86-64 系统上的渐进式压缩。


      在 x86 和 x86-64 系统上,libjpeg-turbo 的速度是 libjpeg 的 2-6 倍,在其他系统上,也能大大优于 libjpeg。




    Android 图像解码


    Android 上展示一张图像,都需要将图像解码成 Bitmap 对象,Bitmap 表示图像像素的集合,像素占用的内存大小取决 Bitmap 配置,目前 Android 支持的配置有如下:




    • ALPHA_8


      只存储透明度通道




    • ARGB_4444


      每个像素使用 2 字节存储




    • ARGB_8888


      每个像素使用 4 字节存储(默认)




    • HARDWARE


      特殊配置,Bitmap 数据存储在专门的图形内存(Native)




    • RGBA_F16


      每个像素使用 8 字节存储




    • RGB_565


      每个像素使用 2 字节存储,只有 RGB 通道。




    源码解析


    通常我们可以调用 BitmapFactory.decodeStream 方法从图像流中解码,Java 层只是个简单的入口,相关实现都在 Native 层的 BitmapFactory.doDecode 方法中。


    // frameworks/base/libs/hwui/jni/BitmapFactory.cpp
    static jobject doDecode(JNIEnv* env, std::unique_ptr<SkStreamRewindable> stream,jobject padding, jobject options, jlong inBitmapHandle,jlong colorSpaceHandle) {
    // ...
    }
    复制代码

    一. 初始化相关参数




    • sampleSize


      采样率




    • onlyDecodeSize


      是否只解码尺寸




    • prefCodeType


      优先使用的颜色类型




    • isHardware


      是否存储在专门的图像内存




    • isMutable


      是否可变




    • scale


      缩放系数




    • requireUnpremultiplied


      颜色通道是否不需要"预乘"透明通道




    • javaBitmap


      可复用的 Bitmap




    二. 创建解码器


    根据解码的图像格式,创建不同的解码器 SkCodec。



























    图像格式SkCodec
    JPEGSkJpegCodec
    WebPSkWebpCodec
    GifSkGifCodec
    PNGSkPngCodec

    SkCodec 负责核心实现,SkAndroidCodec 则是 SkCodec 的包装类,用于提供一些 Android 特有的 API。同样的,SkAndroidCodec 也是根据图像格式,创建不同的 SkAndroidCodec。



















    图像格式SkAndroidCodec
    JPEG,PNG,GifSkSampledCodec
    WebPSkAndroidCodecadapter

    三. 创建内存分配器


    根据是否存在可复用的 Bitmap,和是否需要缩放,使用不同的内存分配器 Allocator。


    image-20210418224749597

    四. 分配像素内存


    调用 SkBitmap.tryAllocPixels 方法尝试分配所需的像素内存,存在以下情况,可能会导致分配失败。



    • Java Heap OOM

    • Native Heap OOM

    • 使用的可复用 Bitmap 太小


    五. 执行解码


    调用 SkAndroidCodec.getAndroidPixels 方法开始执行编码操作。


    SkCodec::Result SkAndroidCodec::getAndroidPixels(const SkImageInfo& requestInfo,
    void* requestPixels, size_t requestRowBytes, const AndroidOptions* options) {
    // ...
    return this->onGetAndroidPixels(requestInfo,requestPixels,requestRowBytes,*options);
    }
    复制代码

    SkAndroid.onGetAndroidPixels 方法有两个实现,分别是 SkSampledCodec 和 SkAndroidCodecadapter。


    这里我们以 JPEG 图像解码为例,从上文可知,它使用的是 SkSampledCodec 和 SkJpegCodec,SkJpegCodec 是核心实现。


    image-20210419142507664

    Android 除了支持使用 BitmapFactory 进行完整的解码,也支持使用 BitmapRegionDecoder 进行局部解码,这个在处理特大的图像时特别有用。


    Android JPEG 压缩


    Android 在图像压缩上一直有个令人诟病的问题,同等大小的图像文件,iOS 显示上总是更加细腻,也就是压缩效果更好,关于这个问题更详细的讨论,可以看这篇文章:github.com/bither/bith…


    总的来说,就是 Android 底层使用的自家维护的一个开源 2D 渲染引擎 Skia,Skia 在 JPEG 图像文件的解编码上依赖的是 libjpeg 库,libjpeg 压缩参数叫:optimize_coding,这个参数为 TRUE,可以带来更好的压缩效果,同时也会消耗更多的 时间。


    在 7.0 以下,Google 为了兼容性能较差的设备,而将这个值设置为 FALSE,7.0 及其以上,已经设置为 TRUE。



    关于 optimize_coding 为 FALSE,更多的讨论可以看 groups.google.com/g/skia-disc…


    7.0 以下:androidxref.com/6.0.1_r10/x…


    7.0 及其以上:androidxref.com/7.0.0_r1/xr…



    所以,现在比较主流的做法是,在 7.0 以下版本,可以基于 libjpeg-turbo 实现 JPEG 图像文件的压缩。


    源码解析


    可以通过调用 Bitmap.compress 方法来进行图像压缩,可选配置有:




    • format


      压缩图像格式,有 JPEG、PNG、WEBP。




    • quality


      压缩质量,可选值有 0-100。




    同样的,Java 层只是提供 API 入口,实现还是在 Native 层的 Bitmap.Bitmap_comperss() 方法。


    // framework/base/libs/hwui/jni/Bitmap.cpp
    static jboolean Bitmap_compress(JNIEnv* env, jobject clazz, jlong bitmapHandle,jint format, jint quality,jobject jstream, jbyteArray jstorage) {
    }
    复制代码

    一. 创建编码器


    根据图像格式创建不同的编码器。























    图像格式编码器
    JPEGSkJpegEncoder
    PNGSkPngEnccoder
    WebPSkWebpEncoder

    二. 设置编码参数


    Android JPEG 解码是依赖于 libjpeglibjpeg-turbo


    在开始压缩编码之前,会先设置一系列参数。




    • 图像尺寸




    • 颜色类型


      常用的颜色类型有:


      JCS_EXT_BGRA,           /* blue/green/red/alpha */
      JCS_EXT_BGRA, /* blue/green/red/alpha */
      复制代码



    • 下采样率


      目前 Android 支持 "4:2:0"(默认),"4:2:2" 和 "4:4:4"。




    • 最佳霍夫曼编码表


      默认为 true,表示使用最佳霍夫曼编码表,虽然会降低压缩性能,但提高了压缩效率。


      // Tells libjpeg-turbo to compute optimal Huffman coding tables
      // for the image. This improves compression at the cost of
      // slower encode performance.
      fCInfo.optimize_coding = TRUE;
      复制代码



    • 质量


      这个参数会影响 JPEG 编码中 "量化" 这个步骤




    三. 执行编码


    // external/skia/src/imagess/SkImageEncoder.cpp
    bool SkEncoder::encodeRows(int numRows) {
    // ...
    this->onEncodeRows(numRows);
    }
    复制代码

    JPEG 图像编码由 SkJpegEncoder 实现。


    // txternal/skia/src/images/SkJpegEncoder.cpp
    bool SkJpegEncoder::onEncodeRows(int numRows) {
    // ...
    for (int i = 0; i < numRows; i++) {
    // 执行 libjpeg-turbo 编码操作
    jpeg_write_scanlines(fEncoderMgr->cinfo(), &jpegSrcRow, 1);
    }
    }
    复制代码

    采样算法


    当调整图像的尺寸时,就需要对原始图像像素数据进行重新处理,这称为图像的采样处理。


    目前 Android 默认支持 Nearest neighbor(邻近采样)Bilinear(双线性采样) 这两种采样算法。




    • Nearest neighbor(邻近采样)


      重新采样的栅格中每个像素获取与原始栅格中的最近像素相同的值,这个处理时间是最快的,但也会导致图像产生锯齿。




    • Bilinear(双线性采样)


      重新采样的栅格中的每个像素都是原始栅格中 2x2 4 个最近像素的加权平均值的结果。




    除了以上两种,还有以下几种效果的更好的算法:




    • Bicubic(双立方采样)


      重新采样的栅格中的每个像素都是原始栅格中 4x4 16 个最近像素值的加权值的结果,更接近的像素会有更高的权重。




    • Lanczos


      高阶插值算法,它考虑了更多周围像素,并保留了最多的图像信息。




    • Magic Kernel


      快速又高效,却能产生惊人的清晰和锐利的结果,更详细的介绍:www.johncostella.com/magic/。




    Spectrum


    Spectrum 是 Facebook 开源的跨平台图像转码依赖库,与 Android 系统默认自带的 jpeg-turbo 相对,它有以下优势:



    • JPEG 编码基于 mozjpeg,相对于 jpeg-turbo,它提高了压缩率,但也增加了压缩处理时间。

    • 支持 Bicubic(双立方采样)和 Magic Kernel 采样算法。

    • 核心使用 CPP 实现,可以同时在 Android 和 iOS 平台实现一致的压缩效果。

    • 支持更多自定义配置,包括色度采样模式等等。


    基准测试


    基于 google/butteraugli 来比较原图像和压缩图像之间的质量差异,这个数值越小越好。


    设备信息:华为 P20 Pro,Android 10


    A 压缩质量 80




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo80S4442.9433522.5MB2255ms71%
    mozjpeg80S4442.4862662.8MB3567ms67%
    mozjpeg80S4202.493475(-15%)2.3MB2703ms73%(+2%)

    B 压缩质量 75




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo75S4443.0758842.3MB2252ms73%
    mozjpeg75S4442.6989832.4MB3188ms72%
    mozjpeg75S4202.670076(-13%)2MB2470ms77%(+4%)

    C 压缩质量 70




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo70S4442.7397942.1MB2230ms75%
    mozjpeg70S4442.8385952.2MB3089ms74%
    mozjpeg70S4202.810702(+2%)1.8MB2404ms79%(+4%)

    D 压缩质量 65




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo65S4443.7341051.9MB2227ms78%
    mozjpeg65S4443.1777062MB2775ms77%
    mozjpeg65S4203.251182(-12%)1.6MB2116ms81%(+3%)

    E 压缩质量 60




















































    核心压缩质量色度采样模式质量差异文件大小耗时压缩率
    原图-S444-8.7MB--
    jpeg-turbo60S4444.5269811.8MB2189ms79%
    mozjpeg60S4443.4863471.8MB2454ms79%
    mozjpeg60S4203.479777(-23%)1.5MB2035ms82%(+3%)

    从以上数据可知,使用 mozjpeg + S420 相对于 jpeg-turbo + S444 而言,压缩率平均有 3% 的提升,图像质量有 12% 的提升。



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

    精简快速的图片加载框架!

    Demo效果图说明支持加载网络图片(String格式url)/本地资源(mipmap和drawable)/网络.9图片/gif加载/自定义样式(圆形/圆角/centerCrop)/dataBindingv1.1.0起支持读取zip中图片加载至任意View中,无...
    继续阅读 »

    Demo效果图

    话不多说先放图

    说明

    支持加载网络图片(String格式url)/本地资源(mipmap和drawable)/网络.9图片/gif加载/自定义样式(圆形/圆角/centerCrop)/dataBinding

    v1.1.0起支持读取zip中图片加载至任意View中,无需解压.

    更多使用方法和示例代码请下载demo源码查看

    github : BaseImageLoader

    设计说明

    根据BaseImageLoader持有图片View层的contextBaseImageConfig类实现Glide原生的生命周期感知和多样化的自定义配置加载

    BaseImageConfig使用建造者模式,使用更灵活更方便,也可自行继承BaseImageConfig减少类名长度和实现自定义功能

    主要功能

    • loadImage 动态配置config加载你需求的资源图片
    • loadImageAs 获取网络url返回的资源,可获取drawable/bitmap/file/gif四种文件格式,可控知否获取资源的同时加载到View上
    • clear 取消加载或清除内存/储存中的缓存
    • BaseImageView 与动态config完全相同功能的自定义ImageView,支持xml中自定义属性配置各种加载需求
    • autoLoadImage 开发者自行指定zip压缩包的路径.并绑定当前View的根布局,配合View的tag字段自动加载zip中符合tag中图片名称的图片

    添加依赖

    implementation 'com.alex:BaseImageLoader:1.1.0'

    使用的依赖库

    • api 'com.github.bumptech.glide:glide:4.11.0'
    • annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'

    开发者如需剔除重复依赖自行处理

    使用说明

    1.添加权限

    需要添问网络和内存读写权限

    2.项目通用配置

    功能配置全部可选,如不配置则:

    默认内存缓存大小20mb

    默认bitmap池缓存30mb

    默认硬盘缓存250mb

    默认缓存文件夹名称BaseImageLoaderCache

    默认缓存策略为AUTOMATIC,自动模式

        BaseImageSetting.getInstance()
    .setMemoryCacheSize(30)//设置内存缓存大小 单位mb
    .setBitmapPoolSize(50)//设置bitmap池缓存大小 单位mb
    .setDiskCacheSize(80)//设置储存储存缓存大小 单位mb
    .setLogLevel(Log.ERROR)//设置log等级
    .setPlaceholder(R.drawable.ic_baseline_adb_24)//设置通用占位图片,全项目生效
    .setErrorPic(R.mipmap.ic_launcher)//设置通用加载错误图片,全项目生效
    .setCacheFileName("BaseImageLoaderDemo")//设置储存缓存文件夹名称,api基于Glide v4
    .setCacheStrategy(CacheStrategy.AUTOMATIC)//设置缓存策略
    .setCacheSize(50)//设置自动加载图片缓存数量,默认50
    ;

    3.使用

    1.获取BaseImageLoader对象

    根据开发者项目设计模式,MVC/MVP/MVVM自行获取BaseImageLoader类对象,并自行管理生命周期.

    BaseImageLoader自行提供单例,BaseImageLoader.getInstance();

    2.加载至ImageView(包括但不限于任何继承于View或ViewGroup的view)

        BaseImageLoader mImageLoader = new BaseImageLoader();
    mImageLoader.loadImage(this, ImageConfig.builder()
    .url(Uri.parse(imageUrl))//url
    .imageView(img1)//imageView
    .placeholder(R.drawable.ic_baseline_adb_24)//占位图
    .errorPic(R.mipmap.ic_launcher)//加载错误图片
    .cacheStrategy(CacheStrategy.ALL)//缓存策略
    .centerCrop(true)//centerCrop
    .crossFade(true)//淡出淡入
    .isCircle(true)//是否圆形显示
    .setAsBitmap(true)//是否以bitmap加载图片,默认为drawable格式
    .setRadius(30)//设置通用圆角,单位dp
    .setTopRightRadius(10)//左上圆角,单位dp
    .setTopLeftRadius(20)//右上圆角,单位dp
    .setBottomRightRadius(30)//左下圆角,单位dp
    .setBottomLeftRadius(40)//右下圆角,单位dp
    .show());

    注意:

    避免过度绘制和二次绘制,其中优先级

    isCircle(true) > setRadius(int) > setTopRightRadius(int) = setTopLeftRadius(int) = setBottomRightRadius(int) = setBottomLeftRadius(int)

    1. 设置isCircle(true)会使通用圆角设置不生效,减少绘制次数

    2. 设置setRadius()会使分别控制单独圆角不生效,减少绘制次数

    3.资源文件直出

    方法一:

        /**
    * 加载图片同时获取不同格式的资源
    * @param context {@link Context}
    * @param url 资源url或资源文件
    * @param listener 获取的资源回调结果
    */
    void loadImageAs(@NonNull Context context, @NonNull Object url, @NonNull L listener);

    /**
    * 根据图片类型直出对象
    * 需要根据参数类型判断获取的字段,比如使用OnBitmapResult,就只有getBitmap方法不为null
    * 根据是否传入imageView是否直接显示图片,如果想自己处理过资源再加载则不传入imageView
    *
    */
    mImageLoader.loadImageAs(this, imageUrlTest, new OnBitmapResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getBitmap() + "");
    }
    });

    方法二:

        /**
    *
    * @param context {@link Context}
    * @param url 资源url或资源文件
    * @param imageView 显示的imageView
    * @param listener 获取的资源回调结果
    */
    void loadImageAs(@NonNull Context context, @NonNull Object url, @Nullable ImageView imageView, @NonNull L listener);

    /**
    * 加载图片且获得bitmap格式图片 且以 imageView.setImageBitmap(bitmap) 模式加载图片
    */
    mImageLoader.loadImageAs(this, imageUrlTest, img14, new OnBitmapResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getBitmap() + "");
    }
    });

    /**
    * 使用File类型获取result时,默认result.getFile()是在设置的cache目录中
    * 加载图片且获得File文件 但是以Glide默认方式加载图片(drawable格式) imageView.setImageDrawable(drawable);
    */
    mImageLoader.loadImageAs(this, imageUrlTest, img14, new OnFileResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getFile() + "");
    }
    });

    /**
    * 加载gif且获得gif文件 以 imageView.setImageDrawable(GifDrawable); 模式加载图片
    */
    mImageLoader.loadImageAs(this, gifUrl, img14, new OnGifResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getGif() + "");
    }
    });

    /**
    * 加载图片且获得drawable格式图片 以Glide默认方式加载图片(drawable格式) imageView.setImageDrawable(drawable);
    */
    mImageLoader.loadImageAs(this, imageUrlTest, img14, new OnDrawableResult() {
    @Override
    public void OnResult(ImageResult result) {
    Log.e("result", result.getDrawable() + "");
    }
    });

    4.自定义BaseImageView

    xml中:

        <me.alex.baseimageloader.view.BaseImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    app:asBitmap="true"
    app:bottomLeftRadius="40dp"
    app:bottomRightRadius="30dp"
    app:cacheStrategy="ALL"
    app:errorPic="@mipmap/ic_launcher"
    app:isCenterCrop="true"
    app:isCircle="true"
    app:isCrossFade="true"
    app:placeholder="@drawable/ic_baseline_adb_24"
    app:radius="30dp"
    app:topLeftRadius="20dp"
    app:topRightRadius="10dp"
    app:url="https://xxx.xxx.com/photo/xxxxxx.png" />

    api与代码设置相同

    支持DataBinding:

        <me.alex.baseimageloader.view.BaseImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    app:url="@{data.imageUrl}" />

    详见demo中dataBinding简单使用 优先级规则同上

    4.自动加载图片

    String ZIP_FILE_PATH = me.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS).getAbsolutePath() + File.separator + "imgs.zip";

    //ZIP_FILE_PATH真实路径为:/storage/emulated/0/Android/data/me.alex.baseimageloaderdemo/files/Documents/imgs.zip

    ScrollView autoLoadViewGroup = findViewById(R.id.autoLoadViewGroup);

    BaseImageLoader.getInstance().autoLoadImage(this, autoLoadViewGroup, ZIP_FILE_PATH);

    xml中

    <?xml version="1.0" encoding="utf-8"?>
    <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/autoLoadViewGroup"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center_horizontal"
    android:orientation="vertical"
    android:overScrollMode="never">

    <LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center_horizontal"
    android:orientation="vertical">

    <me.alex.baseimageloader.view.BaseImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="10dp"
    android:tag="img1.png" />

    <me.alex.baseimageloader.view.BaseImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="10dp"
    android:tag="img2.png"
    app:isCircle="true" />

    <ImageView
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="10dp"
    android:tag="img3.png" />

    <LinearLayout
    android:layout_width="100dp"
    android:layout_height="100dp"
    android:layout_marginTop="10dp"
    android:tag="img1.png" />
    </LinearLayout>
    </ScrollView>
    • zip文件夹位置开发者自行设置,demo中是将assets中的imgs.zip复制至指定路径然后加载.
    • 配合xml中View对象的tag参数匹配zip中的文件名称.
    • 如使用BaseImageView+tag加载图片,支持自定义属性
    • Demo中加载6张图片耗时60ms左右
    • 本功能开发本意是减少apk体积 , 减少重复资源下载 , 开发者可在业务流程中自行处理zip文件的下载和存放位置 , 自行处理数据安全

    参数说明

    1.BaseImageSetting:

    函数名入参类型参数说明
    setMemoryCacheSize()int设置内存缓存大小 单位mb
    setBitmapPoolSize()int设置bitmap池缓存大小 单位mb
    setDiskCacheSize()int设置储存储存缓存大小 单位mb
    setLogLevel()int设置框架日志打印等级,详看android.util.Log类与Glide v4文档
    placeholder()intv1.0.0版本仅支持resource类型int参数
    errorPic()intv1.0.0版本仅支持resource类型int参数
    setCacheFileName()String设置储存缓存文件夹名称,api基于Glide v4

    2.BaseImageLoader:

    /**
    * 加载图片 使用继承自BaseImageConfig的配置
    *
    * @param context {@link Context}
    * @param config {@link BaseImageConfig} 图片加载配置信息
    */
    void loadImage(@NonNull Context context, @NonNull T config);
    /**
    * 自动加载图片
    * @param context {@link Context}
    * @param viewGroup xml中的根标签View
    * @param zipFileRealPath zip文件夹路径
    */
    void autoLoadImage(@NonNull Context context, @NonNull ViewGroup viewGroup, @NonNull String zipFileRealPath);
    /**
    * 加载图片同时获取不同格式的资源
    * @param context {@link Context}
    * @param url 资源url或资源文件
    * @param listener 获取的资源回调结果
    */
    void loadImageAs(@NonNull Context context, @NonNull Object url, @NonNull L listener);
    /**
    *
    * @param context {@link Context}
    * @param url 资源url或资源文件
    * @param imageView 显示的imageView
    * @param listener 获取的资源回调结果
    */
    void loadImageAs(@NonNull Context context, @NonNull Object url, @Nullable ImageView imageView, @NonNull L listener);
    /**
    * 停止加载 或 清除缓存
    *
    * @param context {@link Context}
    * @param config {@link BaseImageConfig} 图片加载配置信息
    */
    void clear(@NonNull Context context, @NonNull T config);

    代码下载:BaseImageLoader-master.zip

    收起阅读 »

    Flutter 中键盘弹起时,Scaffold 发生了什么变化

    最近刚好有网友咨询一个问题,那就顺便借着这个问题给大家深入介绍下 Flutter 中键盘弹起时, Scaffold 的内部发生了什么变化,让大家更好理解 Flutter 中的输入键盘和 Scaffold 的关系。 如下图...
    继续阅读 »

    最近刚好有网友咨询一个问题,那就顺便借着这个问题给大家深入介绍下 Flutter 中键盘弹起时, Scaffold 的内部发生了什么变化,让大家更好理解 Flutter 中的输入键盘和 Scaffold 的关系。


    如下图所示,当时的问题是:当界面内有 TextField 输入框时,点击键盘弹起后,界面内底部的按键和 FloatButton 会被挤到键盘上面,有什么办法可以让底部按键和 FloatButton 不被顶上来吗?





    其实解决这个问题很简单,那就是只要 「把 Scaffold 的 resizeToAvoidBottomInset 配置为 false 」 ,结果如下图所示,键盘弹起后底部按键和 FloatButton 不会再被顶上来,问题解决。 「那为什么键盘弹起会和 resizeToAvoidBottomInset 有关系?」





    Scaffold 的 resize

    Scaffold 是 Flutter 中最常用的页面脚手架,前面知道了通过 resizeToAvoidBottomInset ,我们可以配置在键盘弹起时页面的底部按键和 FloatButton 不会再被顶上来,其实这个行为是因为 Scaffold 的 body 大小被 resize 了。


    那这个过程是怎么发生的呢?首先如下图所示,我们在 Scaffold 的源码里可以看到,当 resizeToAvoidBottomInset 为 true 时,会使用 mediaQuery.viewInsets.bottom 作为 minInsets 的参数,也就是可以确定: 「键盘弹起时的界面 resize 和 mediaQuery.viewInsets.bottom 有关系」 。





    而如下图所示, Scaffold 内部的布局主要是靠 CustomMultiChildLayout , CustomMultiChildLayout 的布局逻辑主要在 MultiChildLayoutDelegate 对象里。


    前面获取到的 minInsets 会被用到 _ScaffoldLayout 这个 MultiChildLayoutDelegate 里面,也就是说  Scaffold 的内部是通过 CustomMultiChildLayout 实现的布局,具体实现逻辑在 _ScaffoldLayout 这个 Delegate 里」 。




    关于 CustomMultiChildLayout 的详细使用介绍在之前的文章 《详解自定义布局实战》 里可以找到。



    接着看 _ScaffoldLayout , 在 _ScaffoldLayout 进行布局时,会通过传入的 minInsets 来决定 body 显示的 contentBottom , 所以可以看到 「事实上传入的 minInsets 改变的是 Scaffold 布局的 bottom 位置」 。



    > 上图代码中使用的 _ScaffoldSlot.body 这个枚举其实是作为 LayoutId 的值, MultiChildLayoutDelegate 在布局时可以通过 LayoutId 获取到对应 child 进行布局操作,详细可见: 《详解自定义布局实战》



    那么 Scaffold 的 body 是什么呢?如上图代码所示,其实 Scaffold 的 body 是一个叫 _BodyBuilder 的对象,而这个 _BodyBuilder 内部其实是一个 LayoutBuilder 。(注意,在 widget.appbar 不为 null 时,会 removeTopPadding )


    所以如下图代码所示 body 在添加时, 「它父级的 MediaQueryData 会被重载,特别是 removeTopPadding 会被清空, viewInsets.bottom 也是会被重置」 。





    最后如下代码所示, _BodyBuilder 的 LayoutBuilder 里会获取到一个 top 和 bottom 的参数,这两个参数都通过前面在 _ScaffoldLayout 布局时传入的 constraints 去判断得到,最终 copyWith 得到新的 MediaQuery 。



    这里就涉及到一个有意思的点,在 _BodyBuilder 里的通过 copyWith 得到新的 MediaQuery 会影响什么呢?如下代码所示,这里用一个简单的例子来解释下。


    class MainWidget extends StatelessWidget {
    final TextEditingController controller =
    new TextEditingController(text: "init Text");
    @override
    Widget build(BuildContext context) {
    print("Main MediaQuery padding: ${MediaQuery.of(context).padding} viewInsets.bottom: ${MediaQuery.of(context).viewInsets.bottom}");
    return Scaffold(
    appBar: AppBar(
    title: new Text("MainWidget"),
    ),
    extendBody: true,
    body: Column(
    children: [
    new Expanded(child: InkWell(onTap: (){
    FocusScope.of(context).requestFocus(FocusNode());
    })),
    ///增加 CustomWidget
    CustomWidget(),
    new Container(
    margin: EdgeInsets.all(10),
    child: new Center(
    child: new TextField(
    controller: controller,
    ),
    ),
    ),
    new Spacer(),
    ],
    ),
    );
    }
    }
    class CustomWidget extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
    print("Custom MediaQuery padding: ${MediaQuery.of(context).padding} viewInsets.bottom: ${MediaQuery.of(context).viewInsets.bottom}\n \n");
    return Container();
    }
    }
    `


    如上代码所示:

    举个例子,如下图所示,从 Android 的 Java 层弹出键盘开始,会把改变后的视图信息传递给 C++ 层,最后回调到 Dart 层,从而触发 MaterialApp 内的 didChangeMetrics 方法执行 setState(() {}); ,进而让 _MediaQueryFromWindow 内的 build 更新了 MediaQueryData ,最终改变了 Scaffod 的 body 大小。





    那么到这里,你知道如何在 Flutter 里正确地去获取键盘的高度了吧?


    最后

    从一个简单的 resizeToAvoidBottomInset 去拓展到 Scaffod 的内部布局和 MediaQueryData 与键盘的关系,其实这也是学习框架过程中很好的知识延伸,通过特定的问题去深入理解框架的实现原理,最后再把知识点和问题关联起来,这样问题在此之后便不再是问题,因为入脑了~



    转自:https://www.agora.io/cn/community/blog/121-category/21060

    收起阅读 »

    SwiftUI动画—只需5个步骤即可构建加载微调器

    自SwiftUI出现以来,编写UI代码的方式就已经改变了,它为我们发展创造力提供了很多功能,这些功能之一与动画状态转换有关。本文会指引你构建自定义加载微调器,下图就是基于此设计的:此外,你可以在我的项目存储库中找到所有操作细节:https://github.c...
    继续阅读 »


    自SwiftUI出现以来,编写UI代码的方式就已经改变了,它为我们发展创造力提供了很多功能,这些功能之一与动画状态转换有关。

    本文会指引你构建自定义加载微调器,下图就是基于此设计


    此外,你可以在我的项目存储库中找到所有操作细节:

    让我们开始吧!

    1.创建

    针对此动画,我们将使用 Circle 结构。如你所见,此次进行的小改动将帮助我们获得所需的微调框外观。

    struct Spinner: View {

    var body: some View {
    ZStack {
    SpinnerCircle()
    }.frame(width: 200, height: 200)
    }
    }

    struct SpinnerCircle: View {

    var body: some View {
    Circle()
    .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
    }
    }
    • 创建一个名为 Spinner 的新SwiftUI文件。
    • 再创建一个名为 SpinnerCircle 的视图,其中包含一个 Circle 。
    • 使用 stroke(style:) 修改器获得微调器外观。
    • 最后,在主视图中,构建一个200像素的 SpinnerCircle 框架。


    当前预览

    2.修剪

    要包含的下一个修饰符是 trim(from:to:) ,这样我们就可以利用提供的参数来绘制部分形状。具体工作方式如下:


    考虑到这一点,我们在 SpinnerCircle 视图中做了一些更改:

    struct SpinnerCircle: View {
    var start: CGFloat
    var end: CGFloat

    var body: some View {
    Circle()
    .trim(from: start, to: end)
    .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
    }
    }

    3.旋转

    最后,我们将包含另一个修饰符。 rotationEffect() 可以使我们的视图旋转到特定的角度。

    拥有半个修剪圆( .trim(from:0.0 to: 0.5) ),并且 rotationEffect(d) 中的 d 是先前定义的一个变量,然后旋转修饰符将导致:


    要做的更改:

    struct SpinnerCircle: View {
    var start: CGFloat
    var end: CGFloat
    var rotation: Angle
    var color: Color

    var body: some View {
    Circle()
    .trim(from: start, to: end)
    .stroke(style: StrokeStyle(lineWidth: 20, lineCap: .round))
    .fill(color)
    .rotationEffect(rotation)
    }
    }

    我们完成了!并且已经准备好进行动画处理。还要注意的是,我们包括了 fill 修饰符和 color 属性。

    在这一点上, Spinner 整体视图是不完整的,不用担心,这一问题在下一部分会得到解决。

    4.对其进行动画处理

    回到主视图,我们要定义一些常量,例如微调器旋转的持续时间以及将微调器置于初始位置的度数:

    struct Spinner: View {

    let rotationTime: Double = 0.75
    let fullRotation: Angle = .degrees(360)
    static let initialDegree: Angle = .degrees(270)

    var body: some View { ... }
    }

    动画将基于已更改的 SpinnerCircle 修剪和旋转属性,因此包含带有 State 的以下变量:

        @State var spinnerStart: CGFloat = 0.0
    @State var spinnerEndS1: CGFloat = 0.03
    @State var rotationDegreeS1 = initialDegree

    最后,主体中包含 SpinnerCircle 视图、一些动画方法和一个 .onAppear() 块:

        var body: some View {
    ZStack {
    // S1
    SpinnerCircle(start: spinnerStart, end: spinnerEndS1, rotation: rotationDegreeS1, color: darkBlue)

    }.frame(width: 200, height: 200)
    .onAppear() {
    Timer.scheduledTimer(withTimeInterval: animationTime, repeats: true) { (mainTimer) in
    self.animateSpinner()
    }
    }
    }

    // MARK: Animation methods
    func animateSpinner(with timeInterval: Double, completion: @escaping (() -> Void)) {
    Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { _ in
    withAnimation(Animation.easeInOut(duration: rotationTime)) {
    completion()
    }
    }
    }

    func animateSpinner() {

    }
    • 动画方法将在特定时间间隔内更新微调器的圆圈属性,从而创建单独的动画。
    • 一旦视图出现,微调框将使用 Timer 不断进行动画处理。
    • animationTime 代表动画的总时间。它是动画方法中定义所有时间间隔的总和。
    • 将其定义为 let animationTime: Double = 1.9 。

    我们准备好了,那就开始制作动画吧!

    在 animateSpinner() 方法中,包括在 rotationTime 期间填充 Circle 形状所需的代码,具体如下:

    animateSpinner(with:rotationTime){self.spinnerEndS1 = 1.0}


    当前预览

    在下面的旋转中( rotationTime x 2 ),我们将返回到初始形状:

    animateSpinner(with:(rotationTime * 2)){     
    self.spinnerEndS1 = 0.03
    }


    当前预览

    最后的一个技巧。我们将在上一个动画之前进行一个完整的旋转(360°)。这样,我们就可以实现所需的动画了:

    animateSpinner(with:(rotationTime * 2
    -0.025 ){ self.rotationDegreeS1 + = fullRotation
    }


    当前预览

    • 通过测试得到 0.025 。我们尝试几个值,直到找到一个合适的值为我们提供一个流畅的动画。

    5.完成

    其余练习只是对先前步骤的重复。如果要检查我们正使用的设计,你会注意到其余部分也都是 Circle 。

    因此,如果我们在主视图中添加此颜色和一些颜色详细信息,则它看起来就像:

    ZStack {
    darkGray
    .edgesIgnoringSafeArea(.all)

    ZStack {
    // S3
    SpinnerCircle(start: spinnerStart, end: spinnerEndS2S3, rotation: rotationDegreeS3, color: darkViolet)

    // S2
    SpinnerCircle(start: spinnerStart, end: spinnerEndS2S3, rotation: rotationDegreeS2, color: darkPink)

    // S1
    SpinnerCircle(start: spinnerStart, end: spinnerEndS1, rotation: rotationDegreeS1, color: darkBlue)

    }.frame(width: 200, height: 200)
    }
    • spinnerEndS2S3 的初始化方式与 spinnerEndS1 相同。
    • rotationDegreeS2 和 rotationDegreeS3 与 rotationDegreeS1 相同。
    • 使用的颜色是自定义的颜色,可以检查存储库或使用你自己的颜色。

    另一方面, animateSpinner() 将包含所需动画:

        func animateSpinner() {
    animateSpinner(with: rotationTime) { self.spinnerEndS1 = 1.0 }

    animateSpinner(with: (rotationTime * 2) - 0.025) {
    self.rotationDegreeS1 += fullRotation
    self.spinnerEndS2S3 = 0.8
    }

    animateSpinner(with: (rotationTime * 2)) {
    self.spinnerEndS1 = 0.03
    self.spinnerEndS2S3 = 0.03
    }

    animateSpinner(with: (rotationTime * 2) + 0.0525) { self.rotationDegreeS2 += fullRotation }

    animateSpinner(with: (rotationTime * 2) + 0.225) { self.rotationDegreeS3 += fullRotation }
    }

    这样,就大功告成了:


    当前预览

    如果你对SwiftUI动画感兴趣并希望看到更多内容,可以着手于该项目或在Instagram上关注我:

    转自:https://www.agora.io/cn/community/blog/121-category/21094

    收起阅读 »

    在SFU上实现RED音频冗余功能

    SFU
    最近,Chrome添加了使用RFC 2198中定义的RED格式给音频流添加冗余的选项。Fippo之前写过一篇文章解释该过程和实现,建议大家研读。大致总结一下这篇文章的话,主要讲述了RED的工作原理是在同一个数据包中添加具有不同时间戳的冗余有效载荷。如果你在出现...
    继续阅读 »

    最近,Chrome添加了使用RFC 2198中定义的RED格式给音频流添加冗余的选项。Fippo之前写过一篇文章解释该过程和实现,建议大家研读。大致总结一下这篇文章的话,主要讲述了RED的工作原理是在同一个数据包中添加具有不同时间戳的冗余有效载荷。如果你在出现损耗的网络中丢失了一个数据包,若另一个数据包被成功接收,其中可能会含有丢失的数据,产生更好的音频质量。

    上述假设发生在简化的一对一场景下,但音频质量问题往往对多方大型通话影响最大。本篇作为Fippo文章的后续,Jitsi 设计师、 Improving Scale and Media Quality with Cascading SFU 的作者Boris Grozev会在本文中向我们介绍他为应对在更复杂的环境添加音频冗余而进行的设计和测试,该环境中存在大量端通过SFU路由媒体。


    Fippo在之前的文章中介绍了如何无需任何类似SFU的中间件,就能在标准的端对端呼叫中添加冗余数据包。那么当你在中间件插入SFU时会发生什么呢?需要考虑以下问题:

    • 如何处理会议中不同客户有不同RED功能的情况?可能会议中只有一部分人支持RED。事实上该情况现在很常见,因为RED是WebRTC、Chromium、Chrome中相对较新添加的功能。
    • 哪些流应该添加冗余?我们是否应该给所有音频流添加冗余,即便这样会产生额外成本?还是只为当前活动的扬声器(或2-3个扬声器)添加冗余?
    • 哪些部分应该添加冗余?在多SFU级联场景中,我们是否需要为SFU-SFU流添加冗余?

    接下来我们会深入讨论这些问题,介绍我们最近在Jitsi Videobridge中的实现内容,并分享更多测试结果。

    RED 客户端和非RED 客户端的混搭

    如果会议所有客户端都支持RED,他们便无需在服务器上进行任何特殊处理,即可使用它,SFU还是像往常一样转发音频流,只不过恰好包含冗余罢了。然而,如果会议中一些客户端支持RED,一些客户端不支持RED,这就有点复杂了。基于发送方和/或接收方是否支持RED,我们需要考虑以下四种情况:

    1. 非RED到非RED

    2. RED到RED

    3. 非RED到RED

    4. RED到非RED

    非RED 到非RED

    第一种情况比较简单:从一个非RED客户端将流转发到一个非RED客户端。流上没有冗余,我们也不被允许添加任何冗余。没什么好办法能改善该情况了。

    RED 到RED

    第二种也很简单:将RED流转发到支持RED的客户端。最简单的做法就是简单转发不变的转发流,这也是一个合理的解决方案。因为不必要对RED流重新编码,我们只需将其转发过去即可。

    非RED 到RED

    SFU的最后一种情况是将Opus流转发到支持RED的客户端,即对RED进行编码,也就是Fippo文章中所说的一对一情况,但增加了一些下述的限制

    RED 到非RED

    第三种是比较困难的情况,即将RED流转发到没有RED的客户端。当然,我们可以直接删除RED并丢弃冗余,但如果SFU和客户端之间丢包,这也不能提高音频质量。该缺陷揭示了RFC2198 RED格式的一个限制。因为中间件SFU需要产生有效的RTP数据流,它需要知道从冗余块中恢复的数据包应该使用哪个RTP序列号,但该信息并不包含在RED header里。这是因为该格式被设计为由端点,而不是由时间戳足以用于回放的中间件来解释,所以其中只包含了一个 “时间戳偏移”(TO)字段

    0 1 2 3

    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1

    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

    |F| block PT | timestamp offset | block length |

    +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+


    如果我们总是使用distance 1,也就是总有一个冗余的数据包(详见Fippo有关distance的文章),对SFU来说不成问题。因为所有的RED包都有一个冗余块,其序列号就是RED包的序列号前面的那一串。同样,如果我们总是使用distance 2(两个冗余数据包),并为所有数据包添加冗余,是不会产生问题的。如果流采用为数据包特设添加冗余的distance 2,且该数据包包含语音活动时(即VAD位被设置),问题就来了。

    (对于RED ) VAD 无益

    假设原始流有三个数据包(seq=1,2,3)。其中一个包含语音,另外两个不包(没有VAD)。当RED编码器处理数据包3时,它只对数据包1添加冗余,因为数据包2不包含语音。



    问题是:当我们在SFU上收到这样一个数据包时,我们怎么知道RED块是否包含数据包1或数据包2的副本呢?如果编码器使用的是distance 1,或者数据包1和2中的语音标志不见了,RED数据包看起来是一样的,其实包含了数据包2的冗余。


    当我们从SFU的RED数据包中提取冗余时,要如何决定对它使用什么序列号呢?一种方法是看时间戳,对数据包的持续时间做出假设。由于我们使用Opus的RTP时钟速率为48000,帧数为20毫秒,即可表示为red_seq = seq – timestamp_offset * (20 / 48000) 。这样做出的假设就很多了。我们也可以更进一步,读取Opus数据包的持续时间。所以我们不必假设它是20毫秒,但也要考虑到如下问题:

    1. 它不能用于e2e加密流。

    2. 它是编解码器专用的。

    3. 技术上来说,opus流可以在中途改变帧大小。

    改变RED

    我们无法在当前的规范中找到一个好的解决方案。所以我们给RED编码器序列计数器增加了一个新的限制,以确保冗余数据包的序列号总是直接在主数据包的序列号之前。对于上面的示例流,RED编码器有三个有效选项。


    第一张图中的选项操作已被禁止了,即给数据包1添加冗余,但不给数据包2添加。这就是Fippo最新的WebRTC源补丁所实现的功能

    所有的流都应该有冗余吗?

    在一对一的情况下,若双方也使用视频,那么相对于组合比特率来说RED的开支很小。对于一个典型的32kbps Opus流,distance 2的RED增加约64kbps的成本。而在我们的服务中,一典型的视频流所占成本为2Mbps,所以总体增加的开销在3%左右——并非微不足道,而是相对较小。

    然而在多方会议中,若SFU向每个接收端转发多个流,且许多视频流是低比特率(即缩略图)运行时,增加的成本可能会更多。

    我们从最简单的技术方案开始进行实现,即为所有流添加冗余,并假设大多数会议同时有多人发言。因为SFU不会转发标记为静音的音频,所以同时发言的人少意味着音频流少。因此在大多数情况下,每个与会者只会在少数音频流上收到RED,减少了开销 。事实上,根据我们从meet.jit.si得到的数据来看,78%的实时会议的发言人为三人及以下(即音频水平不为零的流),所以大多数情况下,在接收端点没有太多RED过载的可能性。


    我们还可以做些什么来进一步减少开销呢?我们可以只为活跃的发言人,或者最活跃的2-3个发言人添加冗余。此外,我们还可以根据可用带宽和会议中的视频流做出更具体、复杂的决定。

    级联式SFU

    另外要考虑的是如何处理级联SFU中的RED。在这种情况下,我们用SFU到SFU的连接以获取更大规模。我们部署了稳定的高带宽链路,以及在SFU之间没有明显的数据包丢失,所以我们选择不主动给没有冗余的流添加冗余。但是我们也不会主动删除冗余,因为我们有很多低成本的带宽。最终的流程如下图所示:


    测试结果

    为了验证新的RED功能,我们创建了一个测试床以测量系统在不同的丢包情况下的表现。我们的默认配置是将SFU上编码的流设置为distance=2,vad-only=true。并且我们给以下两条链路都引入了20%、40%和60%的均匀丢包情况,即发送方和SFU之间大的链路(1.)和SFU和接收方之间的链路(2.)。数据包丢失的位置不同并不会造成大的影响。点击下方你可以收听发送端丢失的例子,所有的测试案例请点击此处收听。


    注:请访问原文地址(见文章末尾处),播放音频示例。

    通过收听例子,我们会发现因为RED的存在,音频质量有了明显提升。

    POLQA 测试

    在另一个具有类似设置的实验中,我在8×8的同事Garth Judge使用POLQA标准和工具集量化了RED带来的影响。以下是他的检测结果。


    结论

    最近,使用RED标准的WebRTC音频冗余在Chrome浏览器进行了现场试验,试验显示该技术巨大的前景。要在多方会议环境中有效使用RED,就需要SFU上额外的服务器支持。我们的测试表明,这些SFU对RED的增强产生了切实效果。

    但我们还有更多工作要做,包括选择要添加冗余的流的子集、使用VAD选择要添加的数据包,以及可能使用Opus LBRR(低比特率冗余)来降低冗余带来的额外比特率等等。

    实验室的测试也表明,重度丢包下的音频质量得到了显著改善。请大家继续关注现场测试结果。

    转自:https://www.agora.io/cn/community/blog/121-category/21120

    收起阅读 »

    如何集成拨号功能至WebRTC应用

    如何才能把拨入或拨出功能添加到你的WebRTC视频应用中呢?在何种情况下,你会把公用电话交换网(PSTN)上的传统拨号电话连接到WebRTC音视频会议呢?下面我们就来探讨一下如何把拨号功能集成到WebRTC。你可以观看我们WebRTC.ventures工程团队...
    继续阅读 »

    如何才能把拨入或拨出功能添加到你的WebRTC视频应用中呢?在何种情况下,你会把公用电话交换网(PSTN)上的传统拨号电话连接到WebRTC音视频会议呢?下面我们就来探讨一下如何把拨号功能集成到WebRTC。

    你可以观看我们WebRTC.ventures工程团队提供的下列视频和其他提示,这些都是我们YouTube视频WebRTC Tips系列的一部分。除此之外,你也可以继续阅读下文。

    视频网址

    拨号集成至WebRTC 视频应用的案例

    呼叫中心 ——许多情况下,代理人和客户直接见面大有裨益。因为你也许能借此了解到用户或产品的情况,亦或能进行手语翻译。

    视频会议拨入 ——拨入选项可以让与会者使用他们的电话加入会议。这是一种重要的备份,以防网络连接不畅或用户的麦克风有问题。(点击此处,了解通话前测试的重要性。)

    点击呼叫按钮 ——现在,我们经常会在网站的右下角看到一个联系客服的图标。该客服可能是一个聊天机器人,甚至可能在你和代理人之间启动安全的WebRTC视频会话。此外,第三种选择是使用你的麦克风和浏览器启动音频呼叫。多见于有传统呼叫中心的公司。

    软件电话 ——软件电话模仿了电脑上桌面电话的功能。它可以内置到应用程序中,拨出到PSTN。软件电话使用WebRTC来捕捉拨号方的音频,但它会连接到传统电话网络中呼出电话。这在销售领域很常见。例如在CRM系统中工作的销售人员可以使用同一系统给客户打电话。一切都会被追踪且保存在一个地方。

    如何将拨号集成到WebRTC 视频应用中?

    内置到CPaaS ——除了使用传统的媒体服务器,许多通信平台即服务(CPaaS)提供商(如Vonage和Twilio)还提供拨入或拨出功能。正如我们在其他文章中讨论过的,CPaaS的一大优势是他们会为你处理所有的交互。

    SIP网关服务 ——如果你正在构建自己的WebRTC应用,那么可以考虑将拨号集成到视频应用的另一种商业方案。会话启动协议(SIP)是一种信令协议,可以实现多种类型的互联网通信会话,包括拨号。8×8等供应商会提供SIP网关作为一个商业的现收现付平台。

    DIY ——你也可以完全开源。如果你的公司已经自己掌握了PBX软件,并且使用的是比较复杂的电话拓扑结构,开源是很好的选择。

    架构示例


    使用Janus开源媒体服务器的拨号架构示例

    至此,我们已经展示了许多可以将拨入功能构建到视频会议中的方法。上述的架构是一个使用Janus(由Meetecho开发的开源WebRTC服务器)的示例。Janus媒体服务器没有内置拨号功能,但它有一个SIP插件,你可以使用SIP协议连接到其他系统。我们会选择连接到一个商业SIP网关,或使用我们自己的开源配置。在上述这个例子中,我们使用Asterisk作为我们的专用分支交换机(PBX)。如果你有我们公司自己的内部电话网络,也是个不错的选择。总之最后你实现了在视频会议中内置拨入和拨出功能。

    转自:https://www.agora.io/cn/community/blog/121-category/21158

    收起阅读 »

    「Typing」开源—— 3步打造属于自己的实时文字互动社交App

    为了与开发者一起更好地探索互动实时消息的更多可能性,我们基于声网云信令/ RTM(Real-time Messaging)SDK 开源了一个实时文字互动 Demo——「Typing」。从体验来讲,「Typing」与音视频通话更为类似。对方打字时的速度或每一个停...
    继续阅读 »

    为了与开发者一起更好地探索互动实时消息的更多可能性,我们基于声网云信令/ RTM(Real-time Messaging)SDK 开源了一个实时文字互动 Demo——「Typing」。从体验来讲,「Typing」与音视频通话更为类似。对方打字时的速度或每一个停顿都可以被看见,并且实时展示的文字信息与数据也不会有历史留存。

    开源地址:https://github.com/AgoraIO-Community/typing/releases


    「Typing」Demo演示
    这样一种几乎“无时延”、无留存信息的互动方式在很多针对 Z 世代群体(Generation-Z,一般是指在1995年——2009年出生的一代)进行开发的 App 中也受到了广泛的应用。

    比如主打 00 后社交新模式的「Honk」就是一款致力于“消除”社交延时的文字对话互动 App,希望通过“真阅后即焚”的 100% 实时、无历史数据留存的私密体验,让使用者体验到几乎无时间差的熟人社交型文字互动。在「Honk」上线的第二天,下载排名就达到了美国 iOS 社交类榜单的第 10 位。


    Honk丨图片来源:Sensor Tower
    Z 世代是伴随着互联网和社交媒体长大的一代,相较于其他群体而言,他们对于技术和互联网社交的需求显得更为原始本能——实时互动、安全及熟人社交。而 「Honk」 之所以能够颠覆传统的文本消息互动体验,背后依靠的正是实时消息技术。

    关于实时消息
    通常实时消息可以分为两种,一种是帮助用户来交流的消息,比如文字消息、点赞、送礼物、发弹幕等。另一种则是信令消息,比如聊天室中禁言踢人的权限管理、上麦请求等。与微信、Snapchat 等这类即时通讯聊天软件相比,实时消息传输的重点在于信令、消息传输的低延时和高送达率上。

    声网云信令/RTM (Real-time Messaging)SDK 是一个通用的消息系统,主要是为了解决实时场景下信令的低延迟和高并发问题。云信令/RTM (Real-time Messaging)SDK 的服务器端采用分布式架构,没有一个单点或者中心式的情况,通过多机房多路保活机制,智能优化路径,在其它节点失效时可以自动转移,选择最优节点路径传输。因此,可以有效保证传输的稳定性与可靠性,在性能方面也可以支持高吞吐量和低延时。

    我们尝试基于声网云信令/RTM(Real-time Messaging) SDK 实现了 「Honk」 中的实时文字消息聊天功能,并作为 Demo 开源。希望可以抛砖引玉,与社区的开发者们一起探索更多基于实时信令和虚拟键盘的互动实时消息的新玩儿法。

    「Typing」开源
    目前的「Typing」Demo 中,我们提供了类似 「Honk」 的实时文字聊天,以及点击对方聊天框发送震动的功能,开发者只需要简单的几步就可以实现。以 iOS 版为例:

    安装
    更改目录为 iOS 文件夹,运行以下命令安装项目依赖项,

    pod install

    输入验证,生成正确的 xcworkspace

    获取 App ID

    要构建并运行示例应用程序,需要获取一个应用 ID :
    1、在agora.io创建一个开发者帐户。完成注册过程后,会重新回到仪表板。
    2、在左侧的仪表板树中导航到项目 > 项目列表。
    3、保存仪表板上的 App ID 以备以后使用。
    4、生成一个临时访问 Token (24 小时内有效) 从仪表板页面给定的通道名称,保存以后使用。

    *注:对于安全性要求更高的场景,如果想要部署自己的RTM Token服务器,请参阅文档(https://docs.agora.io/cn/Real-time-Messaging/token_server_rtm

    接下来,打开 Typing.xcworkspace 并编辑 KeyCenter.swift 文件。在 KeyCenter 中更新 <#Your App Id#>,用仪表盘生成的 Token 更改<#Temp Access Token#>

    *注:如果建立的项目没有打开security token,可以将 token 变量保留为nil。

    1Swift
    2 struct KeyCenter {
    3 static let AppId: String = <#Your App Id#>
    4
    5 // assign token to nil if you have not enabled app certificate
    6 static var Token: String? = <#Temp Access Token#>
    7 }

    目前,该 Demo 支持 iOS 与 Android 平台。对于 Android 平台的小伙伴,可以选择下载打包好的 apk 文件,直接体验「Typing」。

    我们相信,关于声网云信令/RTM(Real-time Messaging)SDK 的应用场景和使用方式在不断涌现的新需求下,还有很大的待挖掘潜力。例如,或许你可以考虑把手机键盘变成一个简易的虚拟钢琴键盘,为对方弹奏一首简单的小乐曲?开发者可以通过「Typing」,快速了解声网云信令/RTM(Real-time Messaging) SDK的基本用法,并且继续探索除了文字实时交流之外的,基于各种类型虚拟键盘所进行的 1 对 1 实时互动。

    如果你对「Typing」感兴趣的话,可以进入我们的讨论群与社区的开发者们进行交流,也可以在 GitHub 仓库提交 Issue 留下你的问题、收藏/Fork「Typing」项目,或是通过 Pull Request 提交你的创意与成果。

    转自:https://www.agora.io/cn/community/blog/121-category/21310

    收起阅读 »

    WebRTC走向成熟

    常规网络浏览器的视频通话什么是WebRTC,它是如何开始的?近20年来,人们已经清楚地认识到“语音通话的未来”将不再仅是电话通话,而是发展出更多样化的应用和用例。之前的企业软电话、IP联络中心、音视频会议和协作工具大多很粗糙,用户体验不佳。VoIP应用或视频浏...
    继续阅读 »


    常规网络浏览器的视频通话

    什么是WebRTC,它是如何开始的?

    近20年来,人们已经清楚地认识到“语音通话的未来”将不再仅是电话通话,而是发展出更多样化的应用和用例。之前的企业软电话、IP联络中心、音视频会议和协作工具大多很粗糙,用户体验不佳。

    VoIP应用或视频浏览器扩展的表现时好时坏,使得用户对其失望,会话效率低。而那时VoIP信令、声学和图像处理技能还是不多见的专业 “黑科技”。

    十年前,也就是2011年6月,一个由W3C和IETF提出的新标准WebRTC出现了。它旨在将实时音视频通信插入网络浏览器中。该标准带来了三个关键创新点。

    • 使我们能够在互联网上运行低延迟的实时通信(RTC)通道;能自动处理深层技术问题,如选择编解码器和处理防火墙问题。该操作完全加密。

    • JavaScript API。它使这些RTC功能能够轻松插入到网络中,也就是使音视频功能变成网页原有的功能,不再需要插件。

    • 通过创建库和软件开发工具包(SDK),允许这些功能独立于浏览器使用。这意味着WebRTC音视频可以插入到手机或电脑桌面应用程序中。

    基于云的供应商开始提供这些功能服务。基于WebRTC的视频cPaaS播放器实现了植入式网络视频聊天,或视频联络中心初期形式。

    过去十年里,WebRTC经历了一段缓慢曲折的发展过程,但也已经在个体消费者和商业领域产生了一定影响。该标准实现了音视频功能的自由化。如今,创建一个新的通信应用或一次新体验,亦或是添加网络/应用通信作为辅助功能到现有系统中,都变得更加容易了。

    该标准能应用于数十亿台设备中,是因为得到了全部当代浏览器和其他平台(尤其是安卓系统)在操作系统中的直接支持。通过SDK和库,WebRTC还被 “内置”到成千上万的移动应用中。众多基础设施供应商提供了网关、工具、测试平台和许多其他功能。

    也就是说,WebRTC并不是全部通用的。市场上有很多独立的语音应用和软电话,以及独立的视频应用。特别是Zoom有自己的方法和技术。微软Teams使用WebRTC进行浏览器访问,但不会在原生客户端使用 WebRTC。

    对于大多数音视频开发者来说,WebRTC是一个很好的选择,但有些开发者有特定的需求,或者他们是技术领域的专家或IPR,他们可以自己实现基础设施和优化。


    借WebRTC网关实现视频通话的门禁对讲

    WebRTC 的用例和纵向市场

    在个体消费者和企业通信领域,WebRTC有大量用例。大致分为以下两类:

    仅WebRTC

    指那些设计成全程使用WebRTC的应用,或者说是用户场景。也就是说,连接的两端都在浏览器中使用WebRTC,或将其内置于专用应用程序中。一个新的、独立的视频会议服务,或集成到社交媒体应用中的视频聊天就属于这个类别。其中可能涉及到专业的平台供应商(cPaaS),也可能只是由应用开发者使用WebRTC “库”(软件组件)直接设计的。

    WebRTC 网关

    指在连接的一端使用WebRTC,另一端不使用,需要某种网关或边界功能。常见的例子是用户使用网络浏览器连接到企业平台,如联络中心或云通信UCaaS平台。这通常涉及将信令转换为商业电话,或视频会议系统中使用的通用SIP协议,还可能涉及不同音视频格式(编解码器)之间的转码。服务提供商可能会运行网关,并将互连功能作为一项基于云的服务来提供。但如果他们本身也在提供UCaaS或CCaaS服务,可能会将其作为一项额外功能提供。

    有些应用同时使用上述这两种模式。例如一个会议平台,它在内网用户之间使用SIP,但也需要与外部互联。这两种用例的用量在新冠疫情期间都急剧增长,下文将就其进一步讨论。

    从行业垂直市场来看,WebRTC 的一些主要用户主要有:

    商业UCaaS

    即一般的企业UCaaS用户,特别是用台式机的用户。

    联络中心

    包括许多垂直市场的联络中心,特别是那些有远程代理的联络中心(如外包客户服务和支持)。

    社交媒体

    即使用社交媒体应用的消费者,该应用使用WebRTC进行流媒体、广播或应用内视频播放。

    保健和远程医疗

    医疗保健和远程医疗,特别是网上问诊。

    金融服务

    金融服务应用,比如通过视频进行身份验证,或在手机中通过视频进行保险索赔评估。

    零售和旅游行业

    零售和旅游行业已有人将WebRTC用于点击呼叫功能,偶尔用于 “共同浏览”功能,即销售代表向客户介绍应用程序或网页上显示的选项。

    电信/服务提供商行业的WebRTC发展较为缓慢。在某些情况下,它成了新兴音视频应用的基础,或作为访客托管电话和UCaaS服务的额外入口。虽然各种网关扩展了内网电话或视频的基础功能,但在部署和采用方面,WebRTC与IMS世界之间的互动相当零散。


    通过WebRTC应用的医护人员视频会诊

    疫情期间发生了什么变化?

    在使用量和应用多样性方面,WebRTC发生了巨大变化。2020年,我们见证了用户由手机转向使用笔记本电脑和台式电脑,特别是在家工作者的交互需要增长。但疫情期间也有很多消费者喜欢大屏幕设备。

    重要的是,人们对双向视频通信的接受程度发生了巨大转变。人们更加适应众多场合下的视频通话了。他们安装好摄像头和麦克风,插上电源,随时可以使用;也更熟悉如何管理隐私、静音、使用背景滤镜,某些情况下适甚至还会给自己打光。

    此外,由于在办公室工作的人越来越少,会议室会议系统也发生了变化。语音通信也是如此,很少有员工会使用公司的桌面电话,或者对着大型联络中心的专用设置说话了。

    理论上上述设备都可以被 “云原生 “的UCaaS和CCaaS服务所取代,但现实中这种转变相当缓慢。现在的需求是重新利用和扩展现有的“传统”平台。使用WebRTC的软件客户端对此提出了一种重要的解决方案。

    换句话说,与2019年相比,现在对话的两端/所有人更依赖笔记本电脑和浏览器了。同时,智能手机/平板电脑用户也扩大了自己的通信范围,特别是在面对面社交活动受限或被禁止的国家。此种现象更为多见。

    其它使用WebRTC的方式有:

    会议协作

    Google Meet和Jitsi Meet等“纯”WebRTC会议协作系统大规模增长。

    访客和台式机用户

    大量访客和台式机/浏览器用户使用WebRTC访问,将其接入传统的UC/UCaaS平台(如WebEx),省去了插件或单独的应用程序下载这一麻烦。

    客户个例和销售市场

    客户个例和销售市场中广泛使用WebRTC,因为现在呼叫中心操作员和客户更依赖电脑。

    新型联络中心互动

    某些类型的联络中心互动可承载更复杂的呼叫,持续时间上也更长。此时视频通话更为合适,例如零售业“线上购物线下取货”的服务、社会关怀以及就业/福利讨论等。在网页内(比如产品描述或网页表格旁)能使用视频很方便,这样就不再只有单独的应用程序中才能使用视频了。

    医疗保健、远程护理和远程医疗

    保健、远程护理和远程医疗WebRTC用例大量增加,涵盖众多不同的应用场景和用户背景。从定期视频问诊,到为被忽视的弱势群体患者提供远程问诊等更专业的应用和工具。其范围非常广泛。

    机动工作人员

    市场对像自由职业者、顾问和主题专家等灵活劳动力的需求日益增长。他们可能也会纳入公司的通信系统。这对那些想快速扩大/缩减规模,或出于合规性的原因(如记录)的公司来说是非常重要的。当外部用户经由公司平台,而不是“端对端”客户通话时,记录会变得更加容易。

    线上查看和检查

    如今“线上查看”和“线上检查”应用广泛,比如买房、技术人员远程操作工具来维修飞机发动机,或安装数据中心的服务器等。

    社交、教育与培训

    基于浏览器的视频互动和聊天在社交、教育、培训和类似场景中的使用越来越多。虽然有时用户会选择如Zoom(不基于WebRTC)等商业会议应用,但还是有相当一部分用户选择在网站和移动应用中内置的音视频互动。

    流媒体类应用

    使用WebRTC的流媒体类应用越来愈多,特别是游戏领域。谷歌的Stadia平台就使用了该技术。

    物联网应用案例

    如今搭载WebRTC的物联网用例越来越多。比如远程视频控制门铃和门锁,即使主人不在家或无法开门,快递也会安全送到。

    公共场所的互动屏幕

    现在,公共场所的交互式屏幕越来越多。例如大楼或办公室里的线上“前台”,配备有线上视频服务员,甚至还可以显示/捕捉二维码以获取权限。

    消费者通信应用

    新的消费通信应用和体验层出不穷。例如家庭健身教练(Peloton很早就开始了线上动感单车课程)、群组语音聊天和“合作播客”。

    (资料来源:Google在2020年Krankygeek大会的发言。https://www.krankygeek.com )

    如今有这么一种趋势,6、7年前最初容易描述的“单一”WebRTC用例再次出现,但这些用例要么是实施很棘手,要么是不符合用户的行为和偏好。

    虽然网页中“点击呼叫”选项已经很常见,但大多数用户更喜欢文字聊天,因为他们不太习惯这种情况下的实时音/视频。现在这种情况已经发生了变化,这也使之前的设想成为现实,可以由基于云的第三方(通常与更广泛的cPaaS供应相关联)实现。


    使用线上视频服务员的虚拟“前台”

    WebRTC 的未来

    现在萌生的许多趋势会在明年及更远的未来继续发展,技术和用例方面也将继续迭代。WebRTC会从许多方面反映出通信领域更广泛的应用前景,为应用和开发人员提供更容易嵌入音/视频功能或创造新体验的方法。

    将来的注意事项包括:

    实时用户

    未来会有更多用户同时在线。例如一个视频会议或活动中,有10几个或100多人同时使用音视频服务。

    性能

    WebRTC性能的总体改善,例如功效。

    混合型活动

    创办混合活动。为个性化通信应用和cPaaS/其他基于云的应用带来新的机遇。

    从传统的PBX到云

    从传统的PBX和呼叫中心平台,转向更灵活的云计算。

    健康与社会关怀

    继续关注涉及音视频和集成传感器的健康和社会护理应用。

    对权衡的认识

    对安全、隐私、合规性和实用性之间的权衡有了更多的认识。例如,多路对话如何做到端到端加密的同时又能集中记录?

    音视频处理

    音视频处理使用量增加,特别是背景模糊方面。但也有适用于特定应用的定制版降噪(如音乐课与线上聚会)功能。未来会细化WebRTC的一些内部功能,使用新的标准,如更容易实现的WebAssembly。

    物联网用例

    未来会出现以物联网为中心的用例,因为摄像头、显示器和麦克风在智能家居设备、工业和智能建筑系统以及新形式的互动形式中更常见了。

    面向未来应用的新型视频编解码器

    未来会继续研究新的视频编解码器,如VP9和AV1,以便更好地在网络要求和处理性能之间进行权衡。该研究可能会促成如AR/VR类的新应用出现,特别是在可以使用GPU和硬件加速器的设备上。

    综上所述,WebRTC的音视频通信自主化工程已经有10年了。它促成了大量的新应用,并使现有的通信服务(尤其是基于SIP的服务)通过浏览器和智能手机应用扩展到电脑和手机中。

    WebRTC不是打造视频体验和服务的唯一方法,但现在它已经成为该行业的中流砥柱,也是消费者、企业和服务提供商创新的核心来源。

    转自:agora.io/cn/community/blog/121-category/21338

    收起阅读 »

    一个可以现成使用的Android调查问卷代码

    DWSurvey是一款方便、高效、实用的调研问卷系统,一款基于 JAVA WEB 的开源问卷表单系统。演示地址开源版服务,开放源代码,可独立部署。地址:http://www.diaowen.net企业版在线服务,功能更丰富,不需要部署,可直接发布问卷进行数据收...
    继续阅读 »

    DWSurvey是一款方便、高效、实用的调研问卷系统,一款基于 JAVA WEB 的开源问卷表单系统。

    输入图片说明

    演示地址

    开源版服务,开放源代码,可独立部署。

    地址:http://www.diaowen.net

    企业版在线服务,功能更丰富,不需要部署,可直接发布问卷进行数据收集。

    地址:https://www.surveyform.cn

    安装

    因为DWSurvey是基于JAVA WEB实现,所以安装与一般的JAVA WEB程序无异,配置好数据库地址就可以正常使用。

    安装说明

    服务器必须安装由 JAVA 1.6+、MySQL、Apache Tomcat  构成的环境

    由于引用的外部jar在你本地maven仓库中可能没有,这时只要您本地有maven环境,执行下bin目录下面的文件就可以自动导入。

    配置说明、数据初始化

    先在您mysql导入/src/main/resources/conf/sql/目录下的dwsurvey.sql数据库脚本文件

    配置文件地址

    conf/application.properties

    #database settings
    jdbc.url=jdbc:mysql://localhost:3306/dwsurvey?useUnicode=true&characterEncoding=utf8
    jdbc.username=root
    jdbc.password=123456,.

    分别修改```jdbc.url、jdbc.username、jdbc.password```

    启动访问

    配置完成后,启动服务在浏览器中输入如localhost:8080/diaowen相应的地址看到登录页面,表示已经安装成功。

    初始账号:service@diaowen.net 密码:123456

    war包下载

    如果不想自己编译可以直接使用我们已经编译好的war包安装

    最新的war包下载可以前往交流QQ 群1:635994795(满)群2:301105635(满), 群3:811287103(可加) (加群时请说明来由)

    下载最新的dwsurvey-oss-v***.zip(注意看后面的版本号),解压后得到diaowen.war,再考到tomcat wabapps下

    打包环境版本:jdk1.8, tomcat8.5.59

    外部解压命令:jar xvf diaowen.war

    特色

    全新体验、流程简单

    pic

    以一种全新的设计体验,告别繁琐的设计流程,通过简单有趣的方式,轻轻松松完成问卷设计,多种问卷样式模板选择,只为显现更精美的表单问卷.

    丰富的题型

    丰富的题型支持,通过拖拽即可完成题目选择,并可以随意拖动其位置,还可置入所需图片、企业LOGO、设置答题逻辑,一份优美的问卷就是这么简单。

    问卷表单静态化

    对于问卷表单系统,因为所有的表单字段都是后台数据库来维护,所以对于每一次答卷请求,如果都从后端数据库去取每一题及选项的话,必定会对性能造成不小影响。

    所以在发布的表单问卷时会对数据进行的页面静态化,生成一个真实的表单存档。


    代码下载:DWSurvey-master.zip

    收起阅读 »

    你的WebRTC应用该使用哪种音视频编解码器

    有关WebRTC 视频编解码器的温馨提示曾几何时WebRTC世界很简单,只有VP8、Opus和 G.711 。G.711被划掉是因为我不推荐使用它。真的没有理由这样做。后来,H.264作为必须实现的视频编解码器加入。WebRTC进展顺利。之后,谷歌决定在Chr...
    继续阅读 »


    有关WebRTC 视频编解码器的温馨提示

    曾几何时WebRTC世界很简单,只有VP8、Opus和 G.711 。G.711被划掉是因为我不推荐使用它。真的没有理由这样做。后来,H.264作为必须实现的视频编解码器加入。WebRTC进展顺利。

    之后,谷歌决定在Chrome中引入VP9,将其作为备选编解码器。Mozilla也在Firefox中加入了VP9。微软呢?他们从Edge切换到Chromium就能“免费”使用它了。苹果的话,VP9应该会出现在他们的Safari 开发预览版中,这么做主要是因为谷歌Standia使用了VP9。这听起来可能有点奇怪。

    另外,苹果公司决定把HEVC作为自己的可选编解码器添加到WebRTC中。可能是为了进一步迷惑我们。

    最后是AV1。就目前而言下一代最好的视频编解码器。一旦它被添加到Chrome中(即90版本),并且被开发者使用,大家就会发现这一点了。

    WebRTC浏览器支持的各类视频编解码器


    上图显示了目前web浏览器中对于视频编解码器的支持状况。

    总结如下:

    • VP8和H.264在浏览器中很常见,但它们都存在一些问题;

    • VP9开源多年,仍没有被广泛采用。它可能“很快”出现在Safari中;

    • HEVC是苹果公司的产品;

    • AV1上市比较迟。

    你的WebRTC应用该选择VP8还是H.264


    如今,你可能正在使用,或者应该使用VP8或H.264。

    这两者之间有什么真正的差别吗?不,并没有。在给定的比特率下,它们产出的音视频质量都差不多。

    但VP8和H.264之间还是有一些细微差别的。比如:

    • 谷歌并没有真正在WebRTC中使用H.264。所以其实这两者中还是VP8应用更广。比如之前H.264一直不支持在Chrome中联播(现在支持了);

    • VP8几乎没有硬件加速,所以它在某些情况下会占用更多的CPU;

    • H.264在苹果设备、PC、安卓上有硬件加速。但有时你在WebRTC中不会有H.264的实现,因为无法获得硬件,软件实现也不存在(由于版权费等问题)。

    • Temporal scalability仅在VP8中可用,H.264中不可用。

    我们自己进行的快速测试表明,H.264解码器比VP8解码器性能更好,无论H.264上是否有硬件加速。这个问题值得我们深入讨论。

    那么你应该使用哪一个呢?

    WebRTC中该使用VP8、H.264还是VP9


    真正要使用的话,首先要考虑一个问题——要选择VP9吗?去年我确实推荐使用VP9。但也没看到什么变化——反正我是没看到有人真正采用VP9。

    除了谷歌,没有人在用VP9。

    在我们的测试中,它的CPU占用率接近于VP8。这很令人惊讶,也可能是谷歌在谷歌会议中使用它的原因。

    VP9最棒的一点是什么呢?那就是它还支持SVC。

    那么现存问题是什么呢?那就是苹果公司还没有接受VP9格式。以后应该会接受的的,问题是什么时候接受罢了。

    什么时候在WebRTC 中应该使用HEVC ?


    这个问题的答案很简单——永远不要使用它。

    换句话说,如果只在苹果设备之间进行通话,那么HEVC可能是一个不错的选择。

    现在AV1 是否能派上用场呢?


    并不确定。

    根据我们自己的测试,AV1在性能方面比所有其他编解码器要差很多。它编解码所占用的CPU是其他音视频编解码器所需的两倍或更多。

    AV1的质量本应比其他编解码器更好的,这样你才可能真的愿意负担它额外占用的CPU。据我所知,如今使用AV1不外乎两个原因:

    • 处理特殊情况,如特低比特率,此时CPU不是问题,带宽才是;

    • 只进行解码,而编码器在云端,在此处你能控制硬件时。但你要支付其计算成本;

    • 据传闻,AV1擅长解码缩略图。

    欢迎来到多编解码器的WebRTC 世界


    WebRTC初创时选择不多,只有VP8和H.264。这就是全部了。而现在?市面上有4-5种视频编解码器可供选择。

    我们中的大多数人最终都选择使用VP8。也有些人选择H.264,主要是因为性能方面的考虑。其余的编解码器常被谈论,但几乎从未使用。

    面世较晚的视频编解码器看来确实前景无量,比如VP9、AV1甚至HEVC在WebRTC应用中都潜力无限。但它们仍有一些难题亟待解决,主要是CPU和浏览器间的可用性问题。

    为了使用这些编解码器,我们需要一种新的方法。即一个应用程序可以使用不止一个视频编解码器,有时甚至在同一会话中也这样做的方法。

    以下是几个建议供大家探讨:

    • 只在1对1通话中支持较高复杂度的编解码器。当通话超过两个参与者时,就动态切换到其他视频编解码器;

    • 在低比特率情况下,动态切换到更复杂的编解码器;

    • 在设备上启用尽可能多的编解码器并行解码,然后根据编码器的CPU能力决定其应该发送的内容;

    • 在联播中使用多种视频编解码器。例如使用比特率很低的AV1,然后在它旁边使用比特率更高的VP8或VP9。联播不支持这一点(目前),但你可以用不同的编解码器和比特率打开两个独立的端对端连接,以达到类似的效果。

    这样做是否值得?也许吧。我觉得在应用中提高视频质量还是很重要的。推进WebRTC多视频编解码器领域,8分的努力才能收获 2 分的优化。如果你完成了所有其他较为简单的优化,可以试试这个领域。
    更头疼的是,谷歌和微软正在研发Lyra和Satin,全新的AI驱动音视频编解码器。事情将变得更加有趣(和复杂)。

    转自:https://www.agora.io/cn/community/blog/121-category/21703

    收起阅读 »

    Chrome 90测试版——AV1 WebRTC编码器、新origin trials及更多更新

    若无特别说明,本文所介绍的更新功能均适用于 Android、Chrome OS、Linux、macOS 和 Windows 的最新 Chrome 测试版频道。点击文中各链接或ChromeStatus.com,了解更多更新信息。Chrome 90是指2021年3...
    继续阅读 »


    若无特别说明,本文所介绍的更新功能均适用于 Android、Chrome OS、Linux、macOS 和 Windows 的最新 Chrome 测试版频道。点击文中各链接或ChromeStatus.com,了解更多更新信息。Chrome 90是指2021年3月11日发布的测试版。

    AV1 编码器

    Chrome桌面推出了一款AV1编码器。该编码器专门针对WebRTC集成的视频会议进行了优化。AV1的优点包括:

    • 压缩效率比其他类型的视频编码更好,减少带宽消耗,提升视觉效果。

    • 可在极低带宽网络上为用户提供视频(30kbps及以下的视频)。

    • 与VP9和其他编解码器相比,显著提高了屏幕共享效率。

    最后一点对刚成为W3C和IETF的官方标准的WebRTC来说是一条重要补充。

    Origin Trials

    该版本的 Chrome 浏览器引入了接下来会讲到的Origin Trials。Origin Trials允许用户尝试新功能,并向网络标准社区提供关于可用性、实用性和有效性的反馈。如果想注册参加当前 Chrome 浏览器中支持的任何Origin Trials,包括下面会讲到的,请访问 Chrome Origin Trials dashboard。了解更多有关 Chrome 浏览器中Origin Trials的信息,请访问 Web 开发者Origin Trials指南。Microsoft Edge 有自己的Origin Trials,没有与Chrome 浏览器合作。要了解其更多信息,请访问 Microsoft Edge Origin Trials 开发者控制台

    新Origin Trials

    getCurrentBrowsingContextMedia()

    类似于getDisplayMedia(),mediaDevices.getCurrentBrowsingContextMedia()方法允许捕获带有当前标签页的视频(可能还带音频)的MediaStream。但与getDisplayMedia()不同的是,调用这个新方法最终呈现给用户的是一个简单的接受/拒绝对话框。如果用户接受,就捕获当前的标签页。然而,该操作需要一些额外的安全措施,目前仍在商榷。在这之前,或者调用时没有这些措施,用户会收到一个对话框,可以选择任何源,但会突出显示当前标签页的选项(getDisplayMedia通常会突出显示全屏选项)。

    MediaStreamTrack Insertable Streams (即 Breakout box )

    用于操作MediaStreamTracks所携带原始媒体的API,如摄像机、麦克风、屏幕捕捉的输出,或编解码器的解码部分和输入部分。它使用WebCodecs接口来代表原始媒体帧,并使用流来公开它们。该方法类似于WebRTCInsertable Streams API公开RTCPeerConnections编码数据的方式。这样做的是为了支持以下用例:

    • Funny Hats:指在编码前和解码后操作媒体以提供诸如背景移除、funny habts、音效等功能。

    • 机器学习:指实时对象识别/注释等应用。

    该Origin Trials预计运行到Chrome 92版本。

    使用 Web Bundles 加载子资源

    使用Web Bundles加载子资源提供了一种有效地加载大量资源的新方法,即使用允许捆绑多个资源的格式,例如Web Bundles。

    JavaScript捆绑程序(如webpack)的输出与浏览器交互较差。两样都是好工具,但:

    • 两者的输出不能如bundle一样,与HTTP缓存进行更细微的交互(Origin Trials没有解决这个问题)。可能导致它们与新的要求,比如动态捆绑不兼容(例如,一个tree shaking的编辑可能使一切无效)。

    • 两者迫使编译和执行等到所有字节都到达才能运行。理想情况下,加载多个子资源应该能够利用全流和并行化,但如果所有资源都被捆绑为一个javascript,就不能做到上述操作了。(该Origin Trial允许编译并行进行。基于目前的确定性执行模型,JavaScript模块执行仍然需要等待整个tree。)

    • 两者可以要求非JS资源(如样式表和图片)编码为JS字符串,这使它们会被解析两次,且会增加它们的大小。该Origin Trial允许这些资源以其原始形式加载。

    该Origin Trial还允许一个捆绑包将一个不透明原点iframe的源码包含为urn:uuid:资源。上述资源的方案在 Chrome 91 中会有所改变。

    该Origin Trial预计在 Chrome 92 中运行。

    WebAssembly 异常处理

    如今,WebAssembly支持异常处理。异常处理允许代码在抛出异常时中断控制流。异常可以是任何已知的WebAssembly模块异常,也可以是由调用的导入函数抛出的未知异常。该Origin Trial预计在Chrome 94中运行。

    已完成的Origin Trials

    以下为之前用于Chrome Origin Trial的功能,现在已默认启用:

    WebXR AR 光照估算

    光照估算允许网站查询WebXR会话中的环境光照条件。这既显示代表环境光照的球谐函数,也显示代表“反射”的立方图纹理。添加照明估算可以使用户的模型看起来更自然,与用户的使用环境更契合。

    此版本中的其他功能

    CSS

    宽高比插值

    宽高比属性允许在任何元素上只指定宽度或高度中的一个来自动计算另一个维度。这个属性最初推出时设定为animate不可插值(也就是说它会捕捉到目标值),提供了从一个长宽比到另一个长宽比的平滑插值。

    自定义状态伪类

    自定义元素现在通过状态CSS伪类显示元素状态。内建元素的状态可以根据用户交互和其他因素随时间而改变,这些状态通过伪类显示给web开发者。例如,一些表单控件有“无效”状态,会通过:invalid伪类显示出来。既然自定义元素也有状态,那么以类似于内置元素的方式显示它们的状态就是可行的。

    对appearance 和-webkit-appearance 实现’auto’ 值

    以下表单控件的CSS属性appearance和-webkit-appearance 被改为了“auto”。

    <input type=color> and <select>

    Android only: <input type=date>, <input type=datetime-local>, <input type=month>, <input type=time>, and <input type=week>

    注意:这些控件的默认渲染方式不会改变。

    overflow: clip 属性

    overflow的clip值会导致盒子里的内容被剪切到盒子overflow clip处。此外,因为没有提供滚动界面,用户或程序也不能自行滚动内容。而且盒子不被视为是一个滚动容器,也不会启动一个新的格式化上下文。因此,该值比overflow: hidden性能更好。

    overflow-clip-margin 属性

    overflow-clip-margin 属性可以指定元素在剪切之前在边界外画的距离。它还允许开发者扩展clip边界,这对缓解有油墨溢出,影响可视性的情况非常有用。

    权限-Policy 报头

    Permissions-Policy HTTP头取代了现有的Feature-Policy头,用于控制权限和强大功能的授权。该头允许网站更严格地限制哪些来源可以被授予访问功能。

    最近,Chrome 74中引入的Feature Policy API更名为“Permissions Policy”,HTTP头也随之更名。同时,社区已经确定了一种基于HTTP的结构化字段值的新句法。

    通过 Cross-Origin-Read-Blocking 来保护应用程序/x-protobuffer。

    保护application/x-protobuffer免受投机性执行攻击,将其添加到Cross-Origin-Read-Blocking. application/x-protobuf所使用的永不嗅探的MIME类型列表中。application/x-protobuuf已经作为永不嗅探的MIME类型受到保护,它是另一种常用的MIME类型,被protobuf库定义为 “ALT_CONTENT_TYPE”。

    在the File System Access API中查找传递文件末尾的方法

    在数据传递给FileSystemWritableFileStream.write()后,会同时传递文件末尾,文件就可以通过写入0x00(NUL)来延伸。这有助于创建稀疏文件,并极大地简化了在没有按顺序接受要写入数据时,保存内容到文件的过程。

    如果没有这个功能,那些不按顺序接收文件内容的应用程序(比如BiTtorrent downloads)就不得不提前,或在写入过程中有需要时,手动调整文件大小。

    StaticRange 构造函数

    目前,Range是web开发者唯一可用的构建范围类型。然而,Range对象是 “实时的”,维护它们的成本很高。每一次树有变化,所有受影响的Range对象都需要更新。而新的StaticRange对象不是实时的,它代表了一种轻量级的范围类型,不需要像Range那样的高维护成本。可构造的StaticRange使web开发者能将其用在不需要每次DOM树变化时都更新的范围。

    支持在<source> 元素上为<picture> 指定宽度和高度

    如今,在<picture>元素中使用<source>元素时,<source>支持其宽度和高度属性。这使得 Chrome 能够计算<picture>元素的长宽比。与<img>、<canvas>和<video>元素的类似行为相匹配。

    WebAudio——OscillatorOptions. periodicalWave不可为 Null

    如今,在创建新的 OscillatorNode 对象时,不能再把 periodicWave 设置为 null了。因为这个值是在传递给OscillatorNode()构造函数的选项对象上设置的。WebAudio 规范不允许将此值设置为null。现在,Chrome浏览器和Firefox浏览器都符合该规范。

    JavaScript

    这个版本的 Chrome 浏览器采用了 9.0 版本的 V8 JavaScript 引擎。包含了以下列出的最新更新。你可以在 V8 发行说明中找到完整的最新功能列表。

    数组、字符串和TypedArrays 的相对索引方法

    Array、String和TypedArray现在支持at()方法,既支持用负数进行相对索引。比如以下的代码返回给定数组中的最后一项。

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

    arr.at(-1);

    弃用和删除

    这个版本的 Chrome 浏览器引入了以下列出的弃用和删除的相关内容。详细信息请访问 ChromeStatus.com,查看最新的弃用列表以及之前的删除列表。

    删除内容安全政策指令“plugin-types”

    “plugin-types”指令帮助开发人员进行选择,哪些类型的插件可以通过 <embed>或<object>html元素加载。通过该操作,开发者可以在他们的页面中屏蔽Flash。由于Flash支持已经停止,他们就不再需要这个策略指令了。

    删除WebRTC RTP 数据通道

    Chrome浏览器已经取消了对WebRTC中非标准RTP数据通道的支持。用户需要使用基于SCTP的标准数据通道。

    返回navigator.plugins 和navigator.mimeTypes 的空值

    如今Chrome给navigator.plugins和navigator.mimeTypes返回空值。但因为Flash被移除,以后不再需要给这些属性返回任何内容了。

    转自:https://www.agora.io/cn/community/blog/121-category/21464

    收起阅读 »

    开源十年,WebRTC 的现状与未来

    本文首发于 InfoQ,由声网 Agora 开发者社区 与 InfoQ 联合策划,并由 InfoQ 审校。WebRTC 在今年 1 月被 W3C 和 IETF 发布为正式标准。从开源至今,十年的时间,倾注了众多开发者的贡献。本文由 Google WebRTC ...
    继续阅读 »

    本文首发于 InfoQ,由声网 Agora 开发者社区 与 InfoQ 联合策划,并由 InfoQ 审校。

    WebRTC 在今年 1 月被 W3C 和 IETF 发布为正式标准。从开源至今,十年的时间,倾注了众多开发者的贡献。本文由 Google WebRTC 产品经理 Huib Kleinhout 基于在由声网举办的 RTE 大会上的分享汇总整理,并增加了其近期对于 WebRTC 前景的看法。

    2020 年,WebRTC 发生了很多变化。WebRTC 其实就是一个客户端库。大家都知道它是开源的。尽管 Google 大力地在支持 WebRTC,但社区的力量同样功不可没。

    WebRTC 对于桌面平台、浏览器端实现音视频交互来讲十分重要。因为在你可以再浏览器上运行任何一种服务,并进行安全检查,无需安装任何应用。这是此前开发者使用该开源库的主要方式。

    但 2020 年,浏览器的发展方向变了。首先讲讲 Microsoft,它将自研的浏览器引擎替换为基于 Chromium 的引擎,同时它们也成为了 WebRTC 的积极贡献者。Microsoft 贡献之一是 perfect negotiation,它使得两端以更稳妥的形式协商。而且,它们还改良了屏幕捕获,使其效率更高。

    另一方面,还有 Safari。苹果的 Safari 还在继续改进他们 WebRTC API。激动人心的是,最新一版的 Safari Tech Preview 中已支持了 VP9,而且还支持硬件加速,大家可以在 Safari 的“开发者设置”中启用它。

    火狐浏览器增加了重传以及 transport-cc,这有助于更好地估计可用带宽,从而改善媒体质量。

    另一方面,Project Zero——Google 负责产品安全性的团队,通过寻找漏洞,帮助提高 WebRTC 的安全性。这意味着如果你的库不基于浏览器,及时更新 WebRTC 库、遵守说明就更加重要了。

    另一件激动人心的事情就是,2020 年,云游戏已经上线了。它的实现有赖于 WebRTC。 Stadia(Google 的云游戏平台)已于 2019 年底推出,但 2020 年初才正式在浏览器得以支持。其云游戏搭载 VP9,提供 4k、HDR 图像和环绕声体验。这些都会通过 WebRTC 进行传输。

    数月前,几个月前,NVIDIA 也发布了适用于 Chromebook 的 GeForce Now,同样使用了 WebRTC。最近,Microsoft 和亚马逊也宣布支持基于浏览器的云游戏开发。 这确实促使 WebRTC 从数百毫秒延迟降低到了数十毫秒延迟,同时开启了全新的应用场景。 但最重要的是, 2020 年,实时通讯(RTC)对于每个人来说都是必不可少的一部分。 因此,许多网络服务的使用率暴涨,涨幅从十倍到几百倍不等。 大家打语音电话的次数更多了,时间更久了,群组数量和成员人数也增加了, 线上交流越来越多。 所以我们需要更丰富的互动方式。

    从 Google 的角度来看, 在疫情爆发的头 2、3 个月内,我们的最大需求容量增长了 30 倍。所以即使是 Google,要确保后端和所有系统功能都可以应对这么大的增长,我们也付出了很多努力。

    在变化面前, WebRTC 和实时通信使用量激增。 大众的日常习惯也在变化。 现在不只在公司能工作, 自己的卧室、厨房里都是工作场所了。由于“社交距离”,面对面交流变得不再现实,我们需要其它与他人社交的方法。我们只能通过视频,依据别人的表情猜测他的意图,此时高清的视频质量就显得更加重要了。

    每个人协作的方式不同,可能是因为我们用的设备不一样。 如果你在公司, 你用的可能是台式机,那你可能会用它在会议室里开会。 而下班之后,你可能会带你的笔记本电脑回家。 但现在人们都在用笔记本处理各种事宜,比如同时运行应用、视频会议和文字聊天。 这种场景下,电脑的使用率非常高。我们看到学校里的孩子们也在用笔记本电脑,比如 Chromebook, 但他们电脑的性能相对差一点。社交、学习线上化之后,电脑的任务处理量突然增大, 所以开展该 WebRTC 项目的意义在于我们需要帮助扩展 WebRTC,确保其运行良好。

    其次,我们需要为 Web 开发者和实时通讯开发者提供更大的灵活度,让他们可以在当下开发出新的互动体验。当疫情爆发时,它阻碍我们了在 Chrome 中开展的所有实验,于是我们所做的一件事情就是专注于服务的扩展、维护。 但这远远不够,特别是在提高性能方面,我们需要做得更好。

    大家可以猜一猜,如果你要在任何使用 WebRTC 的浏览器中开展实时服务, 最耗性能的部分会是什么呢?是视频编码?音频编码?网络适配?(因为你会考虑到可能会有丢包和网络变化)又或者是渲染?

    当你想在屏幕显示摄像头采集的画面时,我们可以来看看浏览器中发生了什么。 我们假设你有一个通过 USB 驱动程序输入的摄像头, 驱动运行,开始处理,摄像头可能还会进行人脸检测、亮度调整等操作。 这要经过浏览器内的系统,Chrome 和其它浏览器都是多进程的。多进程有助于浏览器的稳定性和安全性,比如一个组件或一个页面崩溃,或存在安全漏洞,那么它就会与其他沙盒中的组件隔离。 但这也意味着进程间有大量的通信。 所以如果你有一帧视频数据从摄像头被采集,它可能是 MJPEG 格式。 当它开始渲染你定义媒体流的页面时, 格式可能为 I420。 当从渲染进程转到 GPU 进程(需要实际在屏幕上绘制)时,需要提供最高质量的数据,此时数据可能是 RGB 格式。 当它再次进入操作系统,在屏幕上进行合成时, 可能需要一个 alpha 层, 格式又要变。 这中间涉及到大量转换和复制步骤。 由此可见, 无论内容来自摄像头还是某一终端,仅仅把它放到屏幕上的视频帧中就要花费大量的处理时间。 所以这就是 WebRTC 服务中最复杂的部分——渲染。


    这也是我们做了很多优化的地方。 渲染变得更加高效了,可以确保我们不会在每次更新视频帧时都重新绘制。 如果同时有多个视频,我们会把他们同步,再做其他操作。Chrome 团队优化了内存分配,确保每个视频帧都以最有效的方式得到分配。我们还改进了 Chrome OS 上的操作系统调度,以确保视频服务即使负载过重也能保证交互和响应。接下来的几个月里,我们将致力于从摄像头采集到视频帧渲染到屏幕这个过程的“零拷贝”。 我们希望不会出现一次拷贝或转换,但所有信息都会以最高效的方式保存在图片内存里的。

    同时,我们也致力于使刷新率适应视频帧率。所以在没有任何变化的情况下,我们不需要 60Hz 的屏幕刷新率,但要适应视频的帧速率,例如 25 秒一次。 以下是我们觉得有用的建议:

    1、避免耗时耗力的扩展操作,在 incongnito 模式下进行测试。

    避免耗时耗力的扩展操作很难,它可以干扰你的服务进程,减缓你的服务速度。

    2、避免安全程序干扰浏览器运行

    杀毒软件若要做深度数据包检查或阻止数据包,会占用大量 CPU。

    3、通过 Intel Power Gadgets 来测试

    我们建议你用 Intel Power Gadgets 看看你的服务用了多少能耗。 它会比只看 CPU 百分比直观的多。

    4、花哨的视频效果会占用更多性能

    如果你用一些花哨的动画, 比如会动的圆点来装饰你的视频帧,就会占用更多性能。 尽管看起来不错,但它可能会导致视频帧卡顿一番才能渲染在屏幕上。

    5、摄像头分辨率设置与传输分辨率一致

    如果你使用摄像头采集,请确保打开摄像头时将其分辨率的设置,与你调用 getUserMedia 时的设置保持一致。 如果你打开摄像头,设置了高清画质,格式为 VGA,那么势必需要转换很多字节的信息都会被扔掉。

    6、要留意 WebAudio 的使用

    WebAudio 可能比预期需要更多 CPU 来处理。

    关于视频编解码

    视频编解码器可用于构建更高性能服务器。 因为不仅 CPU 资源很重要, 若你构建网络相关的服务,视频编解码器就显得重要起来了。 如果你要把业务拓展一百倍, Google 提供一款免费的编解码器,VP8、VP9、AV1,并且他在所有浏览器中都可用。


    VP8 是目前为止浏览器内部使用最多的版本,所有浏览器都支持它。VP9 同样在市场中流通很多年了,也一直在改进。它具备 30%-50%的节约率,以及如支持 HDR 和 4K 的高质量功能。同时,它广泛应用于谷歌内部,支持 Stadia 及其他内部服务。 因为它有 VP8 中没有的功能,即能帮助你更好地适应高低带宽连接的转换。然后是 AV1。AV1 也即将在 WebRTC、一些开源实现和浏览器中使用。大多数浏览器已经可以使用它进行流式传输。 希望明年能正式启用它。 实际上,微软刚刚宣布他们的操作系统将支持硬件加速 AV1。 性能的提升给予了开发者更大空间。

    WebRTC NV(Next Version)

    发布 WebRTC 1.0 之后,我们就和社区一起研发下一个版本, 该版本叫“NV”。 该版本意在支持当前 WebRTC API 不可能或很难实现的新用例,比如虚拟现实。对于虚拟现实特效,就像前面提到过的笔记本电脑和机器学习的例子一样, 为了能够使用 WebRTC API 运行,我们需要更好地掌握媒体处理的技能, 比如更好控制传输和拥塞,使用编解码器进行更多自定义操作等等。

    在以上这些目标上,WebRTC NV 的思路是不定义全新 API。 目前已经有两个 API 和 WebRTC,PeerConnetion 和 getUserMedia 了。 我们不会重新定义它们,从头开始研发。相反,我们正在做的是:允许我们使用称为“HTML 流”的接口访问端对 peer connection 内部,以及允许访问浏览器中的编解码器的接口。再加上诸如 Web Assembly 和 workers threads 的新技术,你可以在浏览器,以及集成的端对端连接中使用 Javascript 进行实时处理。

    如果看一下现在的 WebRTC 的内部,你会发现媒体流就像是从网络传入时一样被拆包(depacketized)。这里会有一些丢失或延迟的适配。因此,我们对此进行了重构。

    另一方面, 摄像头输入或麦克风输入已经经过编解码器如 Opus 或 VP8,去除了回声。比特率已经根据网络情况进行了适配,然后将其打包为 RTP 数据包并通过网络发送。我们想做到在 WebRTC NV 中拦截该管道,所以要从媒体框架开始。因此,我们希望能够在媒体帧从网络到达显示器,以及从摄像机麦克风到网络回到媒体帧时对其进行监听。我们希望能够更好地管理这些流。目前我们提出两个流方案,也正是我致力研究的两个 API。


    第一个是可插入媒体流(Insertable Media Stream)。当前的 Chrome 浏览器 86 中已提供此功能。Google 服务和其他外部服务已使用了此功能。你可以使用它来实现端到端加密,或者可以使用它向框架中添加自定义元数据(meta-data)。你要做的是在 PeerConnection 中定义此编码的可插入媒体流,并且你也可以创建流。之后,当你从摄像头获取视频帧时,它首先被编码,比如 VP8 格式,之后你可以访问它并进行流式处理。你还可以对其进行加密或标记其中一些元数据。

    另一个是原始媒体流 API(Raw Media Stream)。这是标准委员会正在讨论的标准工作。目前已经有一些确切的建议了。从 Google 的角度来说,我们正在尝试这种实现。该 API 允许我们访问原始帧。它意味着,当原始帧从摄像头采集后,在还未进行视频编码前,你就可以访问这些数据了。然后你可以对其进行处理,比如实现 AR 效果。你还可以运行多个滤镜来删除背景,然后应用一些效果。比如我想把我现在的视频背景设成一个热带岛屿。这还可以应用到自定义的编解码器中,比如你此前使用的一些编解码器与现在的浏览器不兼容,那么你可以利用这个接口将数据直接传给编解码器来处理。原始媒体流 API 可以提供一种非常有效的方式来访问此原始媒体。

    总结一下。虽然 WebRTC 作为 W3C 正式标准已经发布,但仍在继续改进。新的视频编解码器 AV1 可节省多达 50%的带宽,正在 WebRTC 和网络浏览器中使用。开源代码的持续改进有望进一步减少延迟,提高视频流的质量。WebRTC NV 收集了创建补充 API 的倡议,以实现新的用例。这些 API 包括对现有 API 的扩展,以提供更多对现有功能的控制,例如可扩展视频编码,以及提供对 low-level 组件的访问的 API。后者通过集成高性能的定制 WebAssembly 组件,为网络开发者提供了更多的创新灵活性。随着新兴的 5G 网络和对更多交互式服务的需求,我们预计在未来一年内,持续增强在 WebRTC 的服务端建设。

    转自:https://www.agora.io/cn/community/blog/121-category/21486

    收起阅读 »

    【开源项目】使用环信SDK搭建在线教学场景(含三端源码下载)

    2021年在线教育行业如火如荼,所谓人人为我,我为人人,为了方便教育行业的小伙伴们更好地使用环信SDK,我搭建了一个在线教学开源项目“环环教育”,一期覆盖1对1互动教学、在线互动小班课两种核心教学场景,实现了iOS、Android和Web三端应用。此开源项目演...
    继续阅读 »

    2021年在线教育行业如火如荼,所谓人人为我,我为人人,为了方便教育行业的小伙伴们更好地使用环信SDK,我搭建了一个在线教学开源项目“环环教育”,一期覆盖1对1互动教学、在线互动小班课两种核心教学场景,实现了iOS、Android和Web三端应用。此开源项目演示了环信IM SDK的部分API使用示例,以帮助开发者更好地理解和运用环信IM SDK。


     

    开源项目简介:


    环环教育demo,覆盖1对1互动教学、在线互动小班课两种核心教学场景,具备即时消息互动、实时音视频互动、互动白板、屏幕共享等丰富功能。Demo生动展示了如何用IM、音视频、互动白板SDK共同搭建在线互动教学场景。同时demo覆盖了教师端和学生端,并开发实现了iOS、Android、Web三端应用。现在正式开源给小伙伴们,详细介绍请往下看。

     

    核心界面展示

    1对1互动教学主界面


     移动端

     


    Web端

     

     

    在线互动小班课主界面


    移动端

     

     



    教师web端

     

     

    核心功能

    • 1对1互动教学
    • 在线互动小班课(1名老师+多位学生互动)
    • 即时消息互动(聊天室)
    • 实时音视频互动(音视频)
    • 互动白板
    • 白板权限控制
    • 屏幕共享
    • 学生列表(小班课老师端特有)

     

    资源下载

    Github源码下载:(含iOS、Android、Web以及服务器端

    https://github.com/easemob/learn-easemob

     

    Demo下载体验:

    Web端:https://cgame.bjictc.com/#/

    iOS端:


    识别二维码下载

     

     

    Android端:

    下载链接:https://download-sdk.oss-cn-beijing.aliyuncs.com/downloads/IMDemo/easemob_education_demo.apk


    识别二维码下载

     

    tips:同一房间名称+同一身份退出后不要重复多次进入,建议体验时退出后使用不同房间名。

    收起阅读 »

    在 WebRTC 应用中增加录制功能前,该优先考虑的难点

    在实时音视频应用中增加录制功能应该越早越好。当开发一个 WebRTC 音视频应用之前,我们都需要考虑一个问题“是否需要录制功能?”。如果回答为“是”,那么就要考虑“用来做什么?”、“需要录制多少内容?”,这些问题都需要优先考虑。不论你是通过实时音视频 ...
    继续阅读 »

    在实时音视频应用中增加录制功能应该越早越好。

    当开发一个 WebRTC 音视频应用之前,我们都需要考虑一个问题“是否需要录制功能?”。如果回答为“是”,那么就要考虑“用来做什么?”、“需要录制多少内容?”,这些问题都需要优先考虑。不论你是通过实时音视频 PaaS 服务,还是基于开源自研的方式来开发应用,以上这些问题的答案都将很大程度上影响你的架构设计。同时,你还要决定是进行单流录制还是合流录制。本文我们会给出更多 Tips。

    添加录制功能到个人WebRTC 的用例

    • 记录会议

    • 为满足培训、保存记录等目的的客服或质量保证

    • 开展网络研讨会或其他活动

    添加录制功能到个人WebRTC 要考虑的因素

    录制成本

    实时音视频PaaS需要额外收费。若用该服务大规模地录制的话,会增加一定的成本。

    存储/CPU 成本

    即使你使用的是开源架构,也要考虑成本问题。因为该架构可能会给你的媒体服务器增添一些处理负担,从而影响服务器在重载下扩缩的灵活度。(如有需要)录制后的处理也会给服务器带来额外负担。大多数人都选择把录制内容存储在其他地方(比如Amazon S3)。因此,用户也要把长期存储文件的成本考虑在内。

    录制架构设计

    基于选择的架构类型,你在录制服务架构方面没有多少自由。

    而且录制中的架构选项可能和应用程序本身的不一样。比如线上会议有多名参与者,还会开启屏幕共享等操作,会引发很多问题。

    录制质量

    你是否需要全高清质量的录制文件?你的录制文件要保存多长时间?这些都会影响到你的存储成本。

    录制的安全性

    大多数情况下,比如公司会议,内容较为私密。你要确保录制内容不会公开或外传。那么你打算把这些录制文件存储在哪?你打算给录制服务设置怎样的安全防护?

    不建议使用E2E

    如果你真的要在任意参与者之间都设置音视频的端对端加密(E2EE),可能就没办法录制实时音视频了。录制通常是在媒体服务器上进行的,这会破坏加密链,因为它位于客户端交流双方的中间位置。任何录音都必须在客户终端设备上完成,然后再上传到某个地方。费时费力,效率不高。

    说到WebRTC中真正的E2EE,最终会联系到可插入流(insertable streams)。这是一个相对较新的概念,请记住,可插入流的工作方式是在WebRTC连接之外,在客户端设备上对视频流进行加密/解密。因此,一个应用程序的媒体服务器无法完成E2EE呼叫的录制。这就是为什么E2EE和录制不能兼得。

    在WebRTC 应用程序中录音的方法

    有两种方法:通过合流录音,或通过单流录音。下面我们会基于上文提到的条件来讨论这两种方法。

    选项1:合流


    合流录制场景示例

    在这种情况下,录制在WebRTC媒体服务器上完成,输出一个单一媒体文件,所有不同的流都在这个文件中。

    合流很好、很简单,工作量小。你只需考虑录制的存储位置,以及它的安全性。

    但也有缺点(总是有缺点的,不是吗?),其一是你对录制布局的控制权不大。合成的录制内容可能和你在会议中听到的不太一样。比如你的录制内容只在网格上可用。如果出现屏幕共享,问题就来了。你需要明确如何告诉媒体服务器,一个流应该以显示为主。另外如果谈话过程中布局发生变化,那么录音转化可能会失败。

    第二个缺点是:录制通常是在处理所有进程流的那同一个媒体服务器上完成的。这会造成额外性能负担。也就是说,如果该媒体服务器的某个实例也在记录所有对话,那它可扩展的对话数量就少了。

    选项2:单流


    替代方案: 单流

    另一个选择是作为单独的流来录制。媒体服务器会把每个发言人的单流写入文件。它甚至可以写入视频单流和音频单流。

    单独的数据流给录制提供了很大的灵活性。整个通话过程中你都可以改变结构,可以屏幕共享,并确保是全屏。你也可以对文件进行后期处理(可能要根据媒体服务器创建的输出文件类型进行后期处理)等其他操作。

    另一个好处是,媒体服务器的工作量更小了。因为服务器不用实时组合这些文件了,只需写入即可。你的媒体服务器现在可以同时处理更多对话。然而在自己的架构中,你可能需要其他媒体服务器来处理这些文件,然后安全地存储它们。

    缺点是:你要花费更多精力来使用,或回放这些录制。那你要怎么把这些媒体文件播放给用户呢?比如有四个不同的文件,你如何确保它们的时间同步呢?如果会议的某个发言人并非从头到尾都在场,导致几个文件长度不同,该怎么办呢?

    本文用例来说,能够以不同方式处理数据流的高度自由是有益的,甚至是必不可少的。在这种情况下,额外的工作必不可少。还有一点需要注意:在录制可用之前,这种后期处理工作会有一点延迟。

    总结

    如上所述,实时音视频是否需要添加录制功能很重要,需要提前考虑。

    首先,是否要录音可能会改变你的架构方案的选择。比如是否采用开源,甚至根据在显示录音方面的灵活性,选择不同种类的CPaaS。

    其次,你想多久记录一次,记录的目的,以及之后录音的用途。这都有助于你决定采用合流还是单流。

    当然,这并不意味着你不能在事后添加录音! 只不过根据你的结构,这样的操作难易程度不同而已。

    文章地址:https://webrtc.ventures/2021/03/adding-recording-to-your-webrtc-application/

    原文作者:ArinSime

    收起阅读 »

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

    优雅的处理 iOS 中复杂的 Table Views

    Table views 是 iOS 开发中最重要的布局组件之一。通常我们的一些最重要的页面都是 table views:feed 流,设置页,条目列表等。每个开发复杂的 table view 的 iOS 开发者都知道这样的 table view 会使代码很快就...
    继续阅读 »

    Table views 是 iOS 开发中最重要的布局组件之一。通常我们的一些最重要的页面都是 table views:feed 流,设置页,条目列表等。

    每个开发复杂的 table view 的 iOS 开发者都知道这样的 table view 会使代码很快就变的很粗糙。这样会产生包含大量 UITableViewDataSource 方法和大量 if 和 switch 语句的巨大的 view controller。加上数组索引计算和偶尔的越界错误,你会在这些代码中遭受很多挫折。

    我会给出一些我认为有益(至少在现在是有益)的原则,它们帮助我解决了很多问题。这些建议并不仅仅针对复杂的 table view,对你所有的 table view 来说它们都能适用。

    我们来看一下一个复杂的 UITableView 的例子。


    这是 PokeBall,一个为 Pokémon 定制的社交网络。像其它社交网络一样,它需要一个 feed 流来显示跟用户相关的不同事件。这些事件包括新的照片和状态信息,按天进行分组。所以,现在我们有两个需要担心的问题:一是 table view 有不同的状态,二是多个 cell 和 section。

    1. 让 cell 处理一些逻辑

    我见过很多开发者将 cell 的配置逻辑放到 cellForRowAt: 方法中。仔细思考一下,这个方法的目的是创建一个 cell。UITableViewDataSource 的目的是提供数据。数据源的作用不是用来设置按钮字体的

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell

    let status = statuses[indexPath.row]
    cell.statusLabel.text = status.text
    cell.usernameLabel.text = status.user.name

    cell.statusLabel.font = .boldSystemFont(ofSize: 16)
    return cell
    }

    你应该把配置和设置 cell 样式的代码放到 cell 中。如果是一些在 cell 的整个生命周期都存在的东西,例如一个 label 的字体,就应该把它放在 awakeFromNib 方法中。

    class StatusTableViewCell: UITableViewCell {

    @IBOutlet weak var statusLabel: UILabel!
    @IBOutlet weak var usernameLabel: UILabel!

    override func awakeFromNib() {
    super.awakeFromNib()

    statusLabel.font = .boldSystemFont(ofSize: 16)
    }
    }

    另外你也可以给属性添加观察者来设置 cell 的数据。

    var status: Status! {
    didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name
    }
    }

    那样的话你的 cellForRow 方法就变得简洁易读了。

    func tableView(_ tableView: UITableView, 
    cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
    cell.status = statuses[indexPath.row]
    return cell
    }

    此外,cell 的设置逻辑现在被放置在一个单独的地方,而不是散落在 cell 和 view controller 中。

    2. 让 model 处理一些逻辑

    通常,你会用从某个后台服务中获取的一组 model 对象来填充一个 table view。然后 cell 需要根据 model 来显示不同的内容。

    var status: Status! {
    didSet {
    statusLabel.text = status.text
    usernameLabel.text = status.user.name

    if status.comments.isEmpty {
    commentIconImageView.image = UIImage(named: "no-comment")
    } else {
    commentIconImageView.image = UIImage(named: "comment-icon")
    }

    if status.isFavorite {
    favoriteButton.setTitle("Unfavorite", for: .normal)
    } else {
    favoriteButton.setTitle("Favorite", for: .normal)
    }
    }
    }

    你可以创建一个适配 cell 的对象,传入上文提到的 model 对象来初始化它,在其中计算 cell 中需要的标题,图片以及其它属性。

    class StatusCellModel {

    let commentIcon: UIImage
    let favoriteButtonTitle: String
    let statusText: String
    let usernameText: String

    init(_ status: Status) {
    statusText = status.text
    usernameText = status.user.name

    if status.comments.isEmpty {
    commentIcon = UIImage(named: "no-comments-icon")!
    } else {
    commentIcon = UIImage(named: "comments-icon")!
    }

    favoriteButtonTitle = status.isFavorite ? "Unfavorite" : "Favorite"
    }
    }

    现在你可以将大量的展示 cell 的逻辑移到 model 中。你可以独立地实例化并单元测试你的 model 了,不需要在单元测试中做复杂的数据模拟和 cell 获取了。这也意味着你的 cell 会变得非常简单易读。

    var model: StatusCellModel! {
    didSet {
    statusLabel.text = model.statusText
    usernameLabel.text = model.usernameText
    commentIconImageView.image = model.commentIcon
    favoriteButton.setTitle(model.favoriteButtonTitle, for: .normal)
    }
    }

    这是一种类似于 MVVM 的模式,只是应用在一个单独的 table view 的 cell 中。

    3. 使用矩阵(但是把它弄得漂亮点)

    分组的 table view 经常乱成一团。你见过下面这种情况吗?

    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    switch section {
    case 0: return "Today"
    case 1: return "Yesterday"
    default: return nil
    }
    }

    这一大团代码中,使用了大量的硬编码的索引,而这些索引本应该是简单并且易于改变和转换的。对这个问题有一个简单的解决方案:矩阵。

    记得矩阵么?搞机器学习的人以及一年级的计算机科学专业的学生会经常用到它,但是应用开发者通常不会用到。如果你考虑一个分组的 table view,其实你是在展示分组的列表。每个分组是一个 cell 的列表。听起来像是一个数组的数组,或者说矩阵。


    矩阵才是你组织分组 table view 的正确姿势。用数组的数组来替代一维的数组。 UITableViewDataSource 的方法也是这样组织的:你被要求返回第 m 组的第 n 个 cell,而不是 table view 的第 n 个 cell。

    var cells: [[Status]] = [[]]

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(
    withIdentifier: identifier,
    for: indexPath) as! StatusTableViewCell
    cell.status = statuses[indexPath.section][indexPath.row]
    return cell
    }

    我们可以通过定义一个分组容器类型来扩展这个思路。这个类型不仅持有一个特定分组的 cell,也持有像分组标题之类的信息。

    struct Section {
    let title: String
    let cells: [Status]
    }
    var sections: [Section] = []

    现在我们可以避免之前 switch 中使用的硬编码索引了,我们定义一个分组的数组并直接返回它们的标题。

    func tableView(_ tableView: UITableView, 
    titleForHeaderInSection section: Int) -> String? {
    return sections[section].title
    }

    这样在我们的数据源方法中代码更少了,相应地也减少了越界错误的风险。代码的表达力和可读性也变得更好。

    4. 枚举是你的朋友

    处理多种 cell 的类型有时候会很棘手。例如在某种 feed 流中,你不得不展示不同类型的 cell,像是图片和状态信息。为了保持代码优雅以及避免奇怪的数组索引计算,你应该将各种类型的数据存储到同一个数组中。

    然而数组是同质的,意味着你不能在同一个数组中存储不同的类型。面对这个问题首先想到的解决方案是协议。毕竟 Swift 是面向协议的。

    你可以定义一个 FeedItem 协议,并且让我们的 cell 的 model 对象都遵守这个协议。

    protocol FeedItem {}
    struct Status: FeedItem { ... }
    struct Photo: FeedItem { ... }

    然后定义一个持有 FeedItem 类型对象的数组。

    var cells: [FeedItem] = []

    但是,用这个方案实现 cellForRowAt: 方法时,会有一个小问题。

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cellModel = cells[indexPath.row]

    if let model = cellModel as? Status {
    let cell = ...
    return cell
    } else if let model = cellModel as? Photo {
    let cell = ...
    return cell
    } else {
    fatalError()
    }
    }

    在让 model 对象遵守协议的同时,你丢失了大量你实际上需要的信息。你对 cell 进行了抽象,但是实际上你需要的是具体的实例。所以,你最终必须检查是否可以将 model 对象转换成某个类型,然后才能据此显示 cell。

    这样也能达到目的,但是还不够好。向下转换对象类型内在就是不安全的,而且会产生可选类型。你也无法得知是否覆盖了所有的情况,因为有无限的类型可以遵守你的协议。所以你还需要调用 fatalError 方法来处理意外的类型。

    当你试图把一个协议类型的实例转化成具体的类型时,代码的味道就不对了。使用协议是在你不需要具体的信息时,只要有原始数据的一个子集就能完成任务。

    更好的实现是使用枚举。那样你可以用 switch 来处理它,而当你没有处理全部情况时代码就无法编译通过。

    enum FeedItem {
    case status(Status)
    case photo(Photo)
    }

    枚举也可以具有关联的值,所以也可以在实际的值中放入需要的数据。

    数组依然是那样定义,但你的 cellForRowAt: 方法会变的清爽很多:

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cellModel = cells[indexPath.row]

    switch cellModel {
    case .status(let status):
    let cell = ...
    return cell
    case .photo(let photo):
    let cell = ...
    return cell
    }
    }

    这样你就没有类型转换,没有可选类型,没有未处理的情况,所以也不会有 bug。

    5. 让状态变得明确


    空白的页面可能会使用户困惑,所以我们一般在 table view 为空时在页面上显示一些消息。我们也会在加载数据时显示一个加载标记。但是如果页面出了问题,我们最好告诉用户发生了什么,以便他们知道如何解决问题。

    我们的 table view 通常拥有所有的这些状态,有时候还会更多。管理这些状态就有些痛苦了。

    我们假设你有两种可能的状态:显示数据,或者一个提示用户没有数据的视图。初级开发者可能会简单的通过隐藏 table view,显示无数据视图来表明“无数据”的状态。

    noDataView.isHidden = false
    tableView.isHidden = true

    在这种情况下改变状态意味着你要修改两个布尔值属性。在 view controller 的另一部分中,你可能想修改这个状态,你必须牢记你要同时修改这两个属性。

    实际上,这两个布尔值总是同步变化的。不能显示着无数据视图的时候,又在列表里显示一些数据。

    我们有必要思考一下实际中状态的数值和应用中可能出现的状态数值有何不同。两个布尔值有四种可能的组合。这表示你有两种无效的状态,在某些情况下你可能会变成这些无效的状态值,你必须处理这种意外情况。

    你可以通过定义一个 State 枚举来解决这个问题,枚举中只列举你的页面可能出现的状态。

    enum State {
    case noData
    case loaded
    }
    var state: State = .noData

    你也可以定义一个单独的 state 属性,来作为修改页面状态的唯一入口。每当该属性变化时,你就更新页面到相应的状态。

    var state: State = .noData {
    didSet {
    switch state {
    case .noData:
    noDataView.isHidden = false
    tableView.isHidden = true
    case .loaded:
    noDataView.isHidden = false
    tableView.isHidden = true
    }
    }
    }

    如果你只通过这个属性来修改状态,就能保证不会忘记修改某个布尔值属性,也就不会使页面处于无效的状态中。现在改变页面状态就变得简单了。

    self.state = .noData

    可能的状态数量越多,这种模式就越有用。
    你甚至可以通过关联值将错误信息和列表数据都放置在枚举中。

    enum State {
    case noData
    case loaded([Cell])
    case error(String)
    }
    var state: State = .noData {
    didSet {
    switch state {
    case .noData:
    noDataView.isHidden = false
    tableView.isHidden = true
    errorView.isHidden = true
    case .loaded(let cells):
    self.cells = cells
    noDataView.isHidden = true
    tableView.isHidden = false
    errorView.isHidden = true
    case .error(let error):
    errorView.errorLabel.text = error
    noDataView.isHidden = true
    tableView.isHidden = true
    errorView.isHidden = false
    }
    }
    }

    至此你定义了一个单独的数据结构,它完全满足了整个 table view controller 的数据需求。它 易于测试(因为它是一个纯 Swift 值),为 table view 提供了一个唯一更新入口和唯一数据源。欢迎来到易于调试的新世界!

    几点建议

    还有几点不值得单独写一节的小建议,但是它们依然很有用:

    响应式!

    确保你的 table view 总是展示数据源的当前状态。使用一个属性观察者来刷新 table view,不要试图手动控制刷新。

    var cells: [Cell] = [] {
    didSet {
    tableView.reloadData()
    }
    }

    Delegate != View Controller

    任何对象和结构都可以实现某个协议!你下次写一个复杂的 table view 的数据源或者代理时一定要记住这一点。有效而且更优的做法是定义一个类型专门用作 table view 的数据源。这样会使你的 view controller 保持整洁,把逻辑和责任分离到各自的对象中。

    不要操作具体的索引值!

    如果你发现自己在处理某个特定的索引值,在分组中使用 switch 语句以区别索引值,或者其它类似的逻辑,那么你很有可能做了错误的设计。如果你在特定的位置需要特定的 cell,你应该在源数据的数组中体现出来。不要在代码中手动地隐藏这些 cell。

    牢记迪米特法则

    简而言之,迪米特法则(或者最少知识原则)指出,在程序设计中,实例应该只和它的朋友交谈,而不能和朋友的朋友交谈。等等,这是说的啥?

    换句话说,一个对象只应访问它自身的属性。不应该访问其属性的属性。因此, UITableViewDataSource 不应该设置 cell 的 label 的 text 属性。如果你看见一个表达式中有两个点(cell.label.text = ...),通常说明你的对象访问的太深入了。

    如果你不遵循迪米特法则,当你修改 cell 的时候你也不得不同时修改数据源。将 cell 和数据源解耦使得你在修改其中一项时不会影响另一项。

    小心错误的抽象

    有时候,多个相近的 UITableViewCell 类 会比一个包含大量 if 语句的 cell 类要好得多。你不知道未来它们会如何分歧,抽象它们可能会是设计上的陷阱。YAGNI(你不会需要它)是个好的原则,但有时候你会实现成 YJMNI(你只是可能需要它)。

    链接:https://www.jianshu.com/p/9417d01d7841

    收起阅读 »

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

    前言用本篇文章理论知识和架构原则实践了一个 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 种设计模式在项目中实践,彻底理解设计模式在业务场景中的使用,欢迎持续关注。当其他的平台如后端、前端架构的搭建都是殊途同归的。但是我还是有几点建议:

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

    收起阅读 »

    前端面试常问的基础(七)

    1.IE6或更低版本最多20个cookie2.IE7和之后的版本最后可以有50个cookie。3.Firefox最多50个cookie4.chrome和Safari没有做硬性限制IE和Opera 会清理近期最少使用的cookie,Firefox会随机清理coo...
    继续阅读 »

    1.IE6或更低版本最多20个cookie

    2.IE7和之后的版本最后可以有50个cookie。

    3.Firefox最多50个cookie

    4.chrome和Safari没有做硬性限制

    IE和Opera 会清理近期最少使用的cookie,Firefox会随机清理cookie。


    优点:极高的扩展性和可用性


    1.通过良好的编程,控制保存在cookie中的session对象的大小。

    2.通过加密和安全传输技术(SSL),减少cookie被破解的可能性。

    3.只在cookie中存放不敏感数据,即使被盗也不会有重大损失。

    4.控制cookie的生命期,使之不会永远有效。偷盗者很可能拿到一个过期的cookie。


    缺点:

    1.`Cookie`数量和长度的限制。每个domain最多只能有20条cookie,每个cookie长度不能超过4KB,否则会被截掉。


    2.安全性问题。如果cookie被人拦截了,那人就可以取得所有的session信息。即使加密也与事无补,因为拦截者并不需要知道cookie的意义,他只要原样转发cookie就可以达到目的了。


    3.有些状态不可能保存在客户端。例如,为了防止重复提交表单,我们需要在服务器端保存一个计数器。如果我们把这个计数器保存在客户端,那么它起不到任何作用。


    在较高版本的浏览器中,js提供了sessionStorage和globalStorage。在HTML5中提供了localStorage来取代globalStorage。


    html5中的Web Storage包括了两种存储方式:sessionStorage和localStorage。


    sessionStorage用于本地存储一个会话(session)中的数据,这些数据只有在同一个会话中的页面才能访问并且当会话结束后数据也随之销毁。因此sessionStorage不是一种持久化的本地存储,仅仅是会话级别的存储。


    而localStorage用于持久化的本地存储,除非主动删除数据,否则数据是永远不会过期的。




    收起阅读 »

    前端面试常问的基础(六)

    一、HTML5 CSS3CSS3有哪些新特性?1. CSS3实现圆角(border-radius),阴影(box-shadow),2. 对文字加特效(text-shadow、),线性渐变(gradient),旋转(transform)3.transform:r...
    继续阅读 »
    一、HTML5 CSS3
    1. CSS3有哪些新特性?
    1. CSS3实现圆角(border-radius),阴影(box-shadow),
    2. 对文字加特效(text-shadow、),线性渐变(gradient),旋转(transform)
    3.transform:rotate(9deg) scale(0.85,0.90) translate(0px,-30px) skew(-9deg,0deg);// 旋转,缩放,定位,倾斜
    4. 增加了更多的CSS选择器  多背景 rgba
    5. 在CSS3中唯一引入的伪元素是 ::selection.
    6. 媒体查询,多栏布局
    7. border-image
    1. html5有哪些新特性、移除了那些元素?如何处理HTML5新标签的浏览器兼容问题?如何区分 HTML 和 HTML5
    新特性:
    1. 拖拽释放(Drag and drop) API
    2. 语义化更好的内容标签(header,nav,footer,aside,article,section)
    3. 音频、视频API(audio,video)
    4. 画布(Canvas) API
    5. 地理(Geolocation) API
    6. 本地离线存储 localStorage 长期存储数据,浏览器关闭后数据不丢失;
    7. sessionStorage 的数据在浏览器关闭后自动删除
    8. 表单控件,calendar、date、time、email、url、search  
    9. 新的技术webworker, websocket, Geolocation
    移除的元素:
    1. 纯表现的元素:basefont,big,center,font, s,strike,tt,u;
    2. 对可用性产生负面影响的元素:frame,frameset,noframes;
    支持HTML5新标签:
    1. IE8/IE7/IE6支持通过 document.createElement 方法产生的标签,可以利用这一特性让这些浏览器支持 HTML5 新标签,浏览器支持新标签后,还需要添加标签默认的样式(当然最好的方式是直接使用成熟的框架、使用最多的是html5shim框架):
    <!--[if lt IE 9]>
    <script> src="http://html5shim.googlecode.com/svn/trunk/html5.js"</script>
    <![endif]-->
    如何区分:
    DOCTYPE声明新增的结构元素、功能元素
    1. 本地存储(Local Storage )和cookies(储存在用户本地终端上的数据)之间的区别是什么?
    Cookies:服务器和客户端都可以访问;大小只有4KB左右;有有效期,过期后将会删除;
    本地存储:只有本地浏览器端可访问数据,服务器不能访问本地存储直到故意通过POST或者GET的通道发送到服务器;每个域5MB;没有过期数据,它将保留知道用户从浏览器清除或者使用Javascript代码移除
    1. 如何实现浏览器内多个标签页之间的通信?
    调用 localstorge、cookies 等本地存储方式
    1. 你如何对网站的文件和资源进行优化?
    文件合并
    文件最小化/文件压缩
    使用CDN托管
    缓存的使用
    1. 什么是响应式设计?
    它是关于网页制作的过程中让不同的设备有不同的尺寸和不同的功能。响应式设计是让所有的人能在这些设备上让网站运行正常
    1. 新的 HTML5 文档类型和字符集是?
    答:HTML5文档类型:<!doctype html>
        HTML5使用的编码<meta charset=”UTF-8”>
    1. HTML5 Canvas 元素有什么用?
    答:Canvas 元素用于在网页上绘制图形,该元素标签强大之处在于可以直接在 HTML 上进行图形操作。
    1. HTML5 存储类型有什么区别?
    答:Media APIText Track APIApplication Cache APIUser InteractionData Transfer APICommand APIConstraint Validation APIHistory API
    1. H5+CSS3解决下导航栏最后一项掉下来的问题
    2. CSS3新增伪类有那些?
        p:first-of-type 选择属于其父元素的首个 <p> 元素的每个 <p> 元素。
        p:last-of-type  选择属于其父元素的最后 <p> 元素的每个 <p> 元素。
        p:only-of-type  选择属于其父元素唯一的 <p> 元素的每个 <p> 元素。
        p:only-child    选择属于其父元素的唯一子元素的每个 <p> 元素。
        p:nth-child(2)  选择属于其父元素的第二个子元素的每个 <p> 元素。
        :enabled、:disabled 控制表单控件的禁用状态。
    :checked,单选框或复选框被选中。               
    1. 请用CSS实现:一个矩形内容,有投影,有圆角,hover状态慢慢变透明。
    css属性的熟练程度和实践经验
    1. 描述下CSS3里实现元素动画的方法
    动画相关属性的熟悉程度
    1. html5\CSS3有哪些新特性、移除了那些元素?如何处理HTML5新标签的浏览器兼容问题?如何区分 HTML 和 HTML5
    HTML5 现在已经不是 SGML 的子集,主要是关于图像,位置,存储,地理定位等功能的增加。
    * 绘画 canvas 元素
      用于媒介回放的 video 和 audio 元素
      本地离线存储 localStorage 长期存储数据,浏览器关闭后数据不丢失;
      sessionStorage 的数据在浏览器关闭后自动删除
      语意化更好的内容元素,比如 article、footer、header、nav、section
      表单控件,calendar、date、time、email、url、search
      CSS3实现圆角,阴影,对文字加特效,增加了更多的CSS选择器  多背景 rgba
      新的技术webworker, websockt, Geolocation
    移除的元素
    纯表现的元素:basefont,big,center,font, s,strike,tt,u;
    对可用性产生负面影响的元素:frame,frameset,noframes;
    * 是IE8/IE7/IE6支持通过document.createElement方法产生的标签,
      可以利用这一特性让这些浏览器支持HTML5新标签,
      浏览器支持新标签后,还需要添加标签默认的样式:
    * 当然最好的方式是直接使用成熟的框架、使用最多的是html5shim框架
    <!--[if lt IE 9]>
    <script> src="http://html5shim.googlecode.com/svn/trunk/html5.js"</script>
    <![endif]-->
    1. 你怎么来实现页面设计图,你认为前端应该如何高质量完成工作一个满屏 品 字布局 如何设计?
    * 首先划分成头部、body、脚部;。。。。。
    * 实现效果图是最基本的工作,精确到2px;
      与设计师,产品经理的沟通和项目的参与
      做好的页面结构,页面重构和用户体验
      处理hack,兼容、写出优美的代码格式
      针对服务器的优化、拥抱 HTML5。
    1. 你能描述一下渐进增强和优雅降级之间的不同吗?
    渐进增强 progressive enhancement:针对低版本浏览器进行构建页面,保证最基本的功能,然后再针对高级浏览器进行效果、交互等改进和追加功能达到更好的用户体验。
    优雅降级 graceful degradation:一开始就构建完整的功能,然后再针对低版本浏览器进行兼容。
      区别:优雅降级是从复杂的现状开始,并试图减少用户体验的供给,而渐进增强则是从一个非常基础的,能够起作用的版本开始,并不断扩充,以适应未来环境的需要。降级(功能衰减)意味着往回看;而渐进增强则意味着朝前看,同时保证其根基处于安全地带。 
      “优雅降级”观点
      “优雅降级”观点认为应该针对那些最高级、最完善的浏览器来设计网站。而将那些被认为“过时”或有功能缺失的浏览器下的测试工作安排在开发周期的最后阶段,并把测试对象限定为主流浏览器(如 IE、Mozilla 等)的前一个版本。
      在这种设计范例下,旧版的浏览器被认为仅能提供“简陋却无妨 (poor, but passable)” 的浏览体验。你可以做一些小的调整来适应某个特定的浏览器。但由于它们并非我们所关注的焦点,因此除了修复较大的错误之外,其它的差异将被直接忽略。
      “渐进增强”观点
      “渐进增强”观点则认为应关注于内容本身。
      内容是我们建立网站的诱因。有的网站展示它,有的则收集它,有的寻求,有的操作,还有的网站甚至会包含以上的种种,但相同点是它们全都涉及到内容。这使得“渐进增强”成为一种更为合理的设计范例。这也是它立即被 Yahoo! 所采纳并用以构建其“分级式浏览器支持 (Graded Browser Support)”策略的原因所在。
     
      那么问题了。现在产品经理看到IE6,7,8网页效果相对高版本现代浏览器少了很多圆角,阴影(CSS3),要求兼容(使用图片背景,放弃CSS3),你会如何说服他?
    1. 为什么利用多个域名来存储网站资源会更有效?
    CDN缓存更方便 
    突破浏览器并发限制 
    节约cookie带宽 
    节约主域名的连接数,优化页面响应速度 
    防止不必要的安全问题
    1. 请谈一下你对网页标准和标准制定机构重要性的理解。
      (无标准答案)网页标准和标准制定机构都是为了能让web发展的更‘健康’,开发者遵循统一的标准,降低开发难度,开发成本,SEO也会更好做,也不会因为滥用代码导致各种BUG、安全问题,最终提高网站易用性。
     
    1. 请描述一下cookies,sessionStorage和localStorage的区别?  
      sessionStorage用于本地存储一个会话(session)中的数据,这些数据只有在同一个会话中的页面才能访问并且当会话结束后数据也随之销毁。因此sessionStorage不是一种持久化的本地存储,仅仅是会话级别的存储。而localStorage用于持久化的本地存储,除非主动删除数据,否则数据是永远不会过期的。
    web storagecookie的区别
    Web Storage的概念和cookie相似,区别是它是为了更大容量存储设计的。Cookie的大小是受限的,并且每次你请求一个新的页面的时候Cookie都会被发送过去,这样无形中浪费了带宽,另外cookie还需要指定作用域,不可以跨域调用。
    除此之外,Web Storage拥有setItem,getItem,removeItem,clear等方法,不像cookie需要前端开发者自己封装setCookiegetCookie。但是Cookie也是不可以或缺的:Cookie的作用是与服务器进行交互,作为HTTP规范的一部分而存在 ,而Web Storage仅仅是为了在本地存储数据而生。
    1. 知道css有个content属性吗?有什么作用?有什么应用?
    知道。css的content属性专门应用在 before/after 伪元素上,用来插入生成内容。最常见的应用是利用伪类清除浮动。
    //一种常见利用伪类清除浮动的代码
    .clearfix:after {
        content:"."; //这里利用到了content属性
        display:block;
        height:0;
        visibility:hidden;
        clear:both; }
    .clearfix {
        *zoom:1;
    }
    after伪元素通过 content 在元素的后面生成了内容为一个点的块级素,再利用clear:both清除浮动。
      那么问题继续还有,知道css计数器(序列数字字符自动递增)吗?如何通过css content属性实现css计数器?
    答案:css计数器是通过设置counter-reset 、counter-increment 两个属性 、及 counter()/counters()一个方法配合after / before 伪类实现。 
    1. 如何在 HTML5 页面中嵌入音频?
    HTML 5 包含嵌入音频文件的标准方式,支持的格式包括 MP3、Wav 和 Ogg:
    <audio controls> 
      <source src="jamshed.mp3" type="audio/mpeg"> 
       Your browser does'nt support audio embedding feature. 
    </audio>
    1. 如何在 HTML5 页面中嵌入视频?
    和音频一样,HTML5 定义了嵌入视频的标准方法,支持的格式包括:MP4、WebM 和 Ogg:
    <video width="450" height="340" controls> 
      <source src="jamshed.mp4" type="video/mp4"> 
       Your browser does'nt support video embedding feature. 
    </video> 
    1. HTML5 引入什么新的表单属性?
    Datalist   datetime   output   keygen  date  month  week  time  number   range   emailurl
    1. CSS3新增伪类有那些?
     p:first-of-type 选择属于其父元素的首个 <p> 元素的每个 <p> 元素。
        p:last-of-type  选择属于其父元素的最后 <p> 元素的每个 <p> 元素。
        p:only-of-type  选择属于其父元素唯一的 <p> 元素的每个 <p> 元素。
        p:only-child    选择属于其父元素的唯一子元素的每个 <p> 元素。
        p:nth-child(2)  选择属于其父元素的第二个子元素的每个 <p> 元素。
        :enabled、:disabled 控制表单控件的禁用状态。
    :checked,单选框或复选框被选中。
    1. ()描述一段语义的html代码吧。
    HTML5中新增加的很多标签(如:<article>、<nav>、<header>和<footer>等)
    就是基于语义化设计原则)  
    < div id="header">
    < h1>标题< /h1>
    < h2>专注Web前端技术< /h2>
    < /div>
    语义 HTML 具有以下特性:
     
    文字包裹在元素中,用以反映内容。例如:
    段落包含在 <p> 元素中。
    顺序表包含在<ol>元素中。
    从其他来源引用的大型文字块包含在<blockquote>元素中。
    HTML 元素不能用作语义用途以外的其他目的。例如:
    <h1>包含标题,但并非用于放大文本。
    <blockquote>包含大段引述,但并非用于文本缩进。
    空白段落元素 ( <p></p> ) 并非用于跳行。
    文本并不直接包含任何样式信息。例如:
    不使用 <font> 或 <center> 等格式标记。
    类或 ID 中不引用颜色或位置。
    1. cookie在浏览器和服务器间来回传递。 sessionStoragelocalStorage区别
    sessionStorage和localStorage的存储空间更大;
    sessionStorage和localStorage有更多丰富易用的接口;
    sessionStorage和localStorage各自独立的存储空间;
    1. html5有哪些新特性、移除了那些元素?如何处理HTML5新标签的浏览器兼容问题?如何区分 HTML 和 HTML5
    * HTML5 现在已经不是 SGML 的子集,主要是关于图像,位置,存储,多任务等功能的增加。
    * 绘画 canvas  
      用于媒介回放的 video 和 audio 元素
      本地离线存储 localStorage 长期存储数据,浏览器关闭后数据不丢失;
      sessionStorage 的数据在浏览器关闭后自动删除
      语意化更好的内容元素,比如 article、footer、header、nav、section
      表单控件,calendar、date、time、email、url、search  
      新的技术webworker, websockt, Geolocation
    * 移除的元素
    纯表现的元素:basefont,big,center,font, s,strike,tt,u;
    对可用性产生负面影响的元素:frame,frameset,noframes;
    支持HTML5新标签:
    * IE8/IE7/IE6支持通过document.createElement方法产生的标签,
      可以利用这一特性让这些浏览器支持HTML5新标签,
      浏览器支持新标签后,还需要添加标签默认的样式:
    * 当然最好的方式是直接使用成熟的框架、使用最多的是html5shim框架
    <!--[if lt IE 9]>
    <script> src="http://html5shim.googlecode.com/svn/trunk/html5.js"</script>
    <![endif]-->
    1. 如何区分: DOCTYPE声明\新增的结构元素\功能元素
    2. 语义化的理解?
    用正确的标签做正确的事情!
    html语义化就是让页面的内容结构化,便于对浏览器、搜索引擎解析;
    在没有样式CCS情况下也以一种文档格式显示,并且是容易阅读的。
    搜索引擎的爬虫依赖于标记来确定上下文和各个关键字的权重,利于 SEO。
    使阅读源代码的人对网站更容易将网站分块,便于阅读维护理解。
    1. HTML5的离线储存?
    localStorage    长期存储数据,浏览器关闭后数据不丢失;
    sessionStorage  数据在浏览器关闭后自动删除。
    1. 写出HTML5的文档声明方式
     
     <DOCYPE html>
    1. HTML5CSS3的新标签     
     
     
    HTML5: nav, footer, header, section, hgroup, video, time, canvas, audio...
    CSS3: RGBA, opacity, text-shadow, box-shadow, border-radius, border-image, 
    border-color, transform...;
    1. 自己对标签语义化的理解
        在我看来,语义化就是比如说一个段落, 那么我们就应该用 <p>标签来修饰,标题就应该用 <h?>标签等。符合文档语义的标签。
     

    收起阅读 »

    前端面试常问的基础(五)

    如何理解CSS的盒子模型?每个HTML元素都是长方形盒子。 (1)盒子模型有两种:IE盒子模型、标准W3C盒子模型;IE的content部分包含了border和pading。 (2)标准W3C盒模型包含:内容(content)、填充(padding)、边界(m...
    继续阅读 »

    如何理解CSS的盒子模型?

    每个HTML元素都是长方形盒子。 (1)盒子模型有两种:IE盒子模型、标准W3C盒子模型;IE的content部分包含了border和pading。 (2)标准W3C盒模型包含:内容(content)、填充(padding)、边界(margin)、边框(border)。



    link属于XHTML标签,而@import是CSS提供的。 (2)页面被加载时,link会同时被加载,而@import引用的CSS会等到页面被加载完再加载。 (3)import只在IE 5以上才能识别,而link是XHTML标签,无兼容问题。 (4)link方式的样式权重高于@import的权重。 (5)使用dom控制样式时的差别。当使用javascript控制dom去改变样式的时候,只能使用link标签,因为@import不是dom可以控制的。



    id选择器(# myid) 类选择器(.myclassname) 标签选择器(div、h1、p) 相邻选择器(h1 + p) 子选择器(ul < li) 后代选择器(li a) 通配符选择器( * ) 属性选择器(a[rel = "external"]) 伪类选择器(a: hover, li: nth - child) 可继承: font-size font-family color, UL LI DL DD DT;

    不可继承 :border padding margin width height ;

    优先级就近原则,样式定义最近者为准,载入样式以最后载入的定位为准。 优先级为: !important > id > class > tag important 比 内联优先级高 CSS3新增伪类举例: p:first-of-type 选择属于其父元素的首个<p>元素的每个<p>元素。 p:last-of-type 选择属于其父元素的最后<p>元素的每个<p>元素。 p:only-of-type 选择属于其父元素唯一的<p>元素的每个<p>元素。 p:only-child 选择属于其父元素的唯一子元素的每个<p>元素。 p:nth-child(2) 选择属于其父元素的第二个子元素的每个<p>元素。 :enabled、:disabled 控制表单控件的禁用状态。 :checked 单选框或复选框被选中。




    (1)png24为的图片在IE6浏览器上出现背景,解决方案是做成PNG8。

    (2)浏览器默认的margin和padding不同,解决方案是加一个全局的*{margin:0;padding:0;}来统一。

    (3)IE6双边距bug:块属性标签float后,又有横行的margin情况下,在IE 6显示margin比设置的大。

    (4)浮动ie产生的双边距问题:块级元素就加display:inline;行内元素转块级元素display:inline后面再加display:table。 .bb{

    background-color:#f1ee18; /*所有识别*/

    .background-color:#00deff\9; /*IE6、7、8识别*/

    +background-color:#a200ff; /*IE6、7识别*/

    _background-color:#1e0bd1; /*IE6识别*/ }


    BFC,块级格式化上下文,一个创建了新的BFC的盒子是独立布局的,盒子里面的子元素的样式不会影响到外面的元素。在同一个 BFC 中的两个毗邻的块级盒在垂直方向(和布局方向有关系)的 margin 会发生折叠。


    W3C CSS 2.1 规范中的一个概念,它决定了元素如何对其内容进行布局,以及与其他元素的关系和相互作用。

    display:none  隐藏对应的元素,在文档布局中不再给它分配空间,它各边的元素会合拢,

    就当他从来不存在。


    visibility:hidden  隐藏对应的元素,但是在文档布局中仍保留原来的空间。

    Web Storage的概念和cookie相似,区别是它是为了更大容量存储设计的。Cookie的大小是受限的,并且每次你请求一个新的页面的时候Cookie都会被发送过去,这样无形中浪费了带宽,另外cookie还需要指定作用域,不可以跨域调用。


    除此之外,Web Storage拥有setItem,getItem,removeItem,clear等方法,不像cookie需要前端开发者自己封装setCookie,getCookie。


    但是Cookie也是不可以或缺的:Cookie的作用是与服务器进行交互,作为HTTP规范的一部分而存在 ,而Web Storage仅仅是为了在本地“存储”数据而生


    浏览器的支持除了IE7及以下不支持外,其他标准浏览器都完全支持(ie及FF需在web服务器里运行),值得一提的是IE总是办好事,例如IE7、IE6中的UserData其实就是javascript本地存储的解决方案。通过简单的代码封装可以统一到所有的浏览器都支持web storage。


    localStorage和sessionStorage都具有相同的操作方法,例如setItem、getItem和removeItem等






    收起阅读 »

    前端面试常问的基础(四)

    将元素定义为网格容器,并为其内容建立新的 网格格式上下文。值:grid :生成一个块级网格inline-grid :生成一个内联网格在Bootstrap中,栅格系统将容器均分为12份,再调整内外边距,结合媒体查询,造就了这一强大的...
    继续阅读 »

    将元素定义为网格容器,并为其内容建立新的 网格格式上下文。

    值:

    • grid :生成一个块级网格
    • inline-grid :生成一个内联网格



    在Bootstrap中,栅格系统将容器均分为12份,再调整内外边距,结合媒体查询,造就了这一强大的栅格系统。


    flex布局

    水平居中:

    1. 行内元素,父元素 text-align : center

    2. 块级元素有定宽,margin:0 auto;

    3. 块级元素绝对定位,transform : translate(-50%,0);

    4. 块级元素绝对定位,并且知道宽度, margin-left: - 宽度的一半            

    5. 块级元素绝对定位,left:0;  right:0; margin:0 auto


    垂直居中

    1. 若元素是单行文本, 则可设置 line-height 等于父元素高度,原理见上面;

    2. 若元素是行内块级元素, 基本思想是使用display: inline-block, vertical-align: middle和一个伪元素让内容块处于容器中央..parent::after, .son{ display:inline-block; vertical-align:middle; }

    3. 使用flex, 在父元素上面添加.parent { display: flex; align-items: center;

    4. 绝对定位的块用 transform: translate(0, -50%)

    5. 绝对定位,并且有定高, margin-top : -高度的一半          注意不要用 margin-bottom,  不会生效的

    6. 设置父元素相对定位(position:relative), 子元素如下css样式:.son{ position:absolute; height:固定; top:0; bottom:0; margin:auto 0; } 



    重绘重排

    重绘是一个元素的外观变化所引发的浏览器行为;

    重排是引起DOM树重新计算的行为;


    1、回流/重排


    渲染树的一部分必须要更新且节点的尺寸发生了变化,会触发重排操作。每个页面至少在初始化的时候会有一次重排操作。


    2、重绘


    部分节点需要更新,但没有改变其形状,会触发重绘操作。




    会触发重绘或回流/重排的操作


    1、添加、删除元素(回流+重绘)


    2、隐藏元素,display:none(回流+重绘),visibility:hidden(只重绘,不回流)


    3、移动元素,如改变top、left或移动元素到另外1个父元素中(重绘+回流)


    4、改变浏览器大小(回流+重绘)


    5、改变浏览器的字体大小(回流+重绘)


    6、改变元素的padding、border、margin(回流+重绘)


    7、改变浏览器的字体颜色(只重绘,不回流)


    8、改变元素的背景颜色(只重绘,不回流)


    深入浏览器理解CSS animations 和 transitions的性能问题


    GPU的快在于:

    1. 绘制位图到屏幕上
    2. 一遍又一遍地绘制相同的位图
    3. 将同一位图绘制到不同位置,执行旋转以及缩放处理

    GPU 的慢在于:

    1. 将位图加载到它的内存中

    在使用height,width,margin,padding作为transition的值时,会造成浏览器主线程的工作量较重,例如从margin-left:-20px渲染到margin-left:0,主线程需要计算样式margin-left:-19px,margin-left:-18px,一直到margin-left:0,而且每一次主线程计算样式后,合成进程都需要绘制到GPU然后再渲染到屏幕上,前后总共进行20次主线程渲染,20次合成线程渲染,20+20次,总计40次计算。


    在使用css3 transtion做动画效果时,优先选择transform,尽量不要使用height,width,margin和padding。

    transform为我们提供了丰富的api,例如scale,translate,rotate等等,但是在使用时需要考虑兼容性。但其实对于大多数css3来说,mobile端支持性较好,desktop端支持性需要格外注意。


    物理像素(physical pixel) 

    即:设备像素(device pixel)。 

    本质是屏幕上的点,这个是跟设备有关系

    CSS像素(css pixel) 

    指的是CSS样式代码中使用的逻辑像素(或者叫虚拟像素)。 

    软件要在设备上显示,css规定了长度单位(绝对单位和相对单位),比如:px 是一个 相对单位 ,相对的是 物理像素(physical pixel)

    设备像素比(device pixel ratio) dpr 

    公式:物理像素数(硬件) / 逻辑像素数(软件),即(物理像素/CSS像素)。 

    在css中,可以通过 -webkit-device-pixel-ratio,-webkit-min-device-pixel-ratio 和 -webkit-max-device-pixel-ratio 进行媒体查询,对不同dpr的设备,做一些样式适配。 

    如: dpr = 2 时,1个CSS像素 = 4个物理像素。因为像素点都是正方形,所以当1个CSS像素需要的物理像素增多2倍时,其实就是长和宽都增加了2倍 


    px em rem的区别

    PX实际上就是像素,用PX设置字体大小时,比较稳定和精确。但是这种方法存在一个问题,当用户在浏览器中浏览我们制作的Web页面时,如果改变了浏览器的缩放,这时会使用我们的Web页面布局被打破。这样对于那些关心自己网站可用性的用户来说,就是一个大问题了。因此,这时就提出了使用“em”来定义Web页面的字体。


    EM就是根据基准来缩放字体的大小。EM实质是一个相对值,而非具体的数值。这种技术需要一个参考点,一般都是以<body>的“font-size”为基准。如WordPress官方主题Twenntytwelve的基准就是14px=1em。

    另外,em是相对于父元素的属性而计算的,如果想计算px和em之间的换算,输入数据就可以px和em相互计算。

    Rem是相对于根元素<html>,这样就意味着,我们只需要在根元素确定一个参考值。







    收起阅读 »

    前端面试常问的基础(三)

     JS中浮点数精度误差解决如果有精度要求,可以用toFixed方法处理通用处理方案:把需要计算的数字乘以 10 的 n 次幂,换算成计算机能够精确识别的整数,然后再除以 10 的 n 次幂promises,observables,generator ...
    继续阅读 »
     

     new 运算符是用来实例化一个类,从而在内存中分配一个实例对象。

     通过new可以产生原对象的一个实例对象,而这个实例对象继承了原对象的属性和方法。因此, new存在的意义在于它实现了javascript中的继承,而不仅仅是实例化了一个对象!

    • JavaScript 中有哪些不同的函数调用模式? 详细解释。 提示:有四种模式,函数调用,方法调用,.call() 和 .apply()。
    1. 函数模式 fn()
    2. 方法模式 a.fn()
    3. 构造器模式 new
    4. 上下文模式 call apply

    /*apply()方法*/两个参数 function.apply(thisObj[, argArray]) /*call()方法*/多个参数 function.call(thisObj[, arg1[, arg2[, [,...argN]]]]);


    • 新 ECMAScript 提案

    https://www.cnblogs.com/fundebug/p/what-is-new-in-javascript-for-2019.html

    bigint

    class 增加静态方法和属性 私有属性和方法


    symbol值在序列化的过程中会被忽略或被转换成null



    Fetch API 相对于传统的 Ajax 有哪些改进?

    改进:promise 风格的api,async/await方式调用更友好,更简洁,错误处理更直观

    缺点/难点:

    • fetch 是一种底层的 api,json传值必须转换成字符串,并且设置content-Type为application/json
    • fetch 默认情况下不会发送 cookie
    • 无法获取progress,也就是说无法用fetch做出有进度条的请求
    • 不能中断,我们知道传统的xhr是可以通过调用abort方法来终止我们的请求的

    其实javasript的社区一直很活跃,相信上述问题很快会在未来的更新中解决


    收起阅读 »

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

    搜索问题、话题或人… 问题 文章 代码 视频 活动· · ·ydhjhs发起Android快来为你的照片添加个性标签吧! 前言 需求图.png PS:最近在项目执行过程中有这样一个需求,要求拍完照的图片必须达到以上的效果。需求分析: 使用用预览布局Surfa...
    继续阅读 »

    搜索问题、话题或人…
    问题
    文章
    代码
    视频
    活动
    · · ·
    ydhjhs
    发起
    Android
    快来为你的照片添加个性标签吧!


    1. 前言

    需求图.png


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


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


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



    1. 方法1 使用SurfaceView

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


    1. 获取源头视频的截图作为SurfaceView的截图


    2. 获取SurfaceView的画布canvas,将canvas保存成Bitmap


    3. 直接截取整个屏幕,然后在截图SurfaceView位置的图



    复制代码


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



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

    第一步:获取拍照权限








    复制代码


    这里使用到郭霖大佬的开源库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需要配置文件共享。

    android:name="androidx.core.content.FileProvider"

    android:authorities="${applicationId}.fileprovider"

    android:exported="false"

    android:grantUriPermissions="true">


    android:name="android.support.FILE_PROVIDER_PATHS"

    android:resource="@xml/file_paths" />



    复制代码


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


    <?xml version=”1.0” encoding=”utf-8”?>









    name="images"

    path="Pictures" />





    复制代码


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


    @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);

    }

    }


    复制代码



    1. 最终实现的效果如下:

    效果.jpg


    5.总结


    整体来说没有什么太大的问题,添加水印的原理就是通过Canvas绘制的方式将文字/图片添加到图片上。最后再将修改之后的图片呈现给用户。同时也记录下SurfaceView截图黑屏的问题。


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

    0 收藏 分享 举报 2021-05-11
    0 个评论
    ydhjhs
    写下你的评论…
    发起人
    柳天明柳天明
    推荐内容
    java设计模式:原型模式
    算法与数据结构之数组
    算法与数据结构之算法复杂度
    算法与数据结构之链表
    java设计模式:抽象工厂模式
    java 设计模式:责任链模式与Android事件传递
    java 设计模式:观察者
    java 设计模式:模版方法
    java 设计模式:策略模式
    java 设计模式:工厂方法模式
    一个开放、互助、协作、创意的社区
    关于imGeek关于专职工程师值守社区财富榜赞助商友情链接热门标签
    京ICP备14026002号-3
    收起阅读 »

    每次上线都出问题?现在他来了,上线之前测一测.

    仿文墨天机命盘界面,自定义view 宫格实现是通过drawline画直线,拿到View的width和height的1/4,按照对应宫格宽度和高度进行偏移划线;因为中间的是占了4个小宫格矩形位置组成的大宫格矩形,因此需要分部分处理,观察图形后发现按照从上到下分为...
    继续阅读 »

    仿文墨天机命盘界面,自定义view 宫格实现是通过drawline画直线,拿到View的width和height的1/4,按照对应宫格宽度和高度进行偏移划线;因为中间的是占了4个小宫格矩形位置组成的大宫格矩形,因此需要分部分处理,观察图形后发现按照从上到下分为4部分最为合适,最上边申、酉、戌、亥四个宫位划分为TopTopArea,中上部分的未、子两个宫位划分为TopCenterArea,中下部分的午、丑两个宫位分为BottomCenterArea,最下边巳、辰、卯、寅四个宫位划分为BottomBottomArea。 x方向也是屏幕宽度方向,按照宫格所占宽度划线偏移。y方向也就是屏幕高度方向,从上往下按照宫格所占高度偏移划线。 宫格中的文字部分,按照申宫调用drawText绘制出文字,并通过Paint设置文字的大小,文字的位置,以及文字的颜色,酉、戌、亥宫中的文字布局位置通过申宫中的文字位置按照宫位所处的位置偏移即可,其它宫位原理亦如此,具体查看demo中的代码实现。

    中宫需要注意的是它会有多行文字的显示,但是drawText 只能实现单行文字效果,那么要想实现多行代码效果,需要用到官方提供另外一个专门用来实现多行文字的函数StaticLayout,我们要展示的数据 val centerStrFromText ="姓名:匿名 ${info.性别} ${info.五行局}\n" + "真太阳时:${info.真太阳时}\n" + "钟表时间:${info.钟表时间}\n" + "农历:${info.年干}" + "${info.年支}年${info.农历月}月${info.农历日}日 ${info.时干}\n" + "命主:${info.命主} 身主:${info.身主} 子斗:${info.身宫}\n"] val centerStaticLayout = StaticLayout(centerStrFromText,mTextPaint,600,Layout.Alignment.ALIGN_NORMAL,1f,0f,true)

    StaticLayout(CharSequence source, TextPaint paint, int width, Layout.Alignment align, float spacingmult, float spacingadd, boolean includepad) width 是文字区域的宽度,文字到达这个宽度后就会自动换行; align 是文字的对齐方向; spacingmult 是行间距的倍数,通常情况下填 1 就好; spacingadd 是行间距的额外增加值,通常情况下填 0 就好; includepad 是指是否在文字上下添加额外的空间,来避免某些过高的字符的绘制出现越界。 多行文字偏移,需要配合canvas的translate方法实现,调用StaticLayout的draw方法进行绘制

    添加点击事件,重写onTouchEvent方法,在 MotionEvent.ACTION_UP中判断用户点击位置是不是符合所在宫位的范围内如果是改变字体颜色,调用invalidate重新绘制其它宫位显示

    实现效果1 实现效果2


    代码下载:FortuneTelling-master.zip

    收起阅读 »

    看看微信是怎么处理图片的吧!

    整体实现思路图片展示:PhotoView(大图支持双击放大)图片加载:Glide(加载网络图片、本地图片、资源文件)小图变大图时的实现:动画图片的下载:插入系统相册该控件采用自定义View的方式,通过一些基本的控件的组合,来形成一个具有大图预览的控件。上代码使...
    继续阅读 »

    整体实现思路

    图片展示:PhotoView(大图支持双击放大)
    图片加载:Glide(加载网络图片、本地图片、资源文件)
    小图变大图时的实现:动画
    图片的下载:插入系统相册

    该控件采用自定义View的方式,通过一些基本的控件的组合,来形成一个具有大图预览的控件。上代码

    使用方法

    (1)在布局文件中引用该view

    <com.demo.gallery.view.GalleryView
    android:id="@+id/photo_gallery_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:visibility="gone"
    app:animDuration="300"
    app:saveText="保存至相册"
    app:saveTextColor="#987622"/>

    (2)具体使用方法
    GalleryView galleryView = findViewById(R.id.photo_gallery_view);
    galleryView.showPhotoGallery(index, List, ImageView);

    到这里就结束了,就是这么简单!

    具体实现

    (1)先从showPhotoGallery(index, List, ImageView)这个方法讲起

    int index:我们想要展示的一个图片列表中的第几个
    List list: 我们要传入的要展示的图片类型list(支持网络图片、资源图片、本地图片(本地图片与网络图片其实都是一个字符串地址))

    public class GalleryPhotoModel {

    public Object photoSource;

    public GalleryPhotoModel(@DrawableRes int drawableRes) {
    this.photoSource = drawableRes;
    }

    public GalleryPhotoModel(String path) {
    this.photoSource = path;
    }

    }

    ImageView:即你点击想要展示的那个图片

    (2)对传入GalleryView的数据进行处理

    /**
    * @param index 想要展示的图片的索引值
    * @param photoList 图片集合(URL、Drawable、Bitmap)
    * @param clickImageView 点击的第一个图片
    */
    public void showPhotoGallery(int index, List<GalleryPhotoModel> photoList, ImageView clickImageView) {
    GalleryPhotoParameterModel photoParameter = new GalleryPhotoParameterModel();
    //图片
    photoParameter.photoObj = photoList.get(index).photoSource;
    //图片在list中的索引
    photoParameter.index = index;
    int[] locationOnScreen = new int[2];
    //图片位置参数
    clickImageView.getLocationOnScreen(locationOnScreen);
    photoParameter.locOnScreen = locationOnScreen;
    //图片的宽高
    int width = clickImageView.getDrawable().getBounds().width();
    int height = clickImageView.getDrawable().getBounds().height();
    photoParameter.imageWidth = clickImageView.getWidth();
    photoParameter.imageHeight = clickImageView.getHeight();
    photoParameter.photoHeight = height;
    photoParameter.photoWidth = width;
    //scaleType
    photoParameter.scaleType = clickImageView.getScaleType();
    //将第一个点击的图片参数连同整个图片列表传入
    this.setVisibility(View.VISIBLE);
    post(new Runnable() {
    @Override
    public void run() {
    requestFocus();
    }
    });
    setGalleryPhotoList(photoList, photoParameter);
    }

    通过传递进来的ImageView,获取被点击View参数,并拼装成参数model,再进行数据的相关处理。

    (3)GalleryView的实现机制

    该View的实现思路主要是:最外层是一个RelativeLayout,内部有一个充满父布局的ImageView和ViewPager。ImageView用来进行图片的动画缩放,ViewPager用来进行最后的图片的展示。其实该View最主要的地方就是通过点击ImageView到最后ViewPager的展示的动画。接下来主要是讲解一下这个地方。先看一下被点击ImageView的参数Model。GalleryPhotoParameterModel

    public class GalleryPhotoParameterModel {

    //索引
    public int index;
    // 图片的类型
    public Object photoObj;
    // 在屏幕上的位置
    public int[] locOnScreen = new int[]{-1, -1};
    // 图片的宽
    public int photoWidth = 0;
    // 图片的高
    public int photoHeight = 0;
    // ImageView的宽
    public int imageWidth = 0;
    // ImageView的高
    public int imageHeight = 0;
    // ImageView的缩放类型
    public ImageView.ScaleType scaleType;

    }

    3.1图片放大操作

    private void handleZoomAnimation() {
    // 屏幕的宽高
    this.mScreenRect = GalleryScreenUtil.getDisplayPixes(getContext());
    //将被缩放的图片放在一个单独的ImageView上进行单独的动画处理。
    Glide.with(getContext()).load(firstClickItemParameterModel.photoObj).into(mScaleImageView);
    //开启动画
    mScaleImageView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
    @Override
    public void onGlobalLayout() {
    //开始放大操作
    calculateScaleAndStartZoomInAnim(firstClickItemParameterModel);
    //
    mScaleImageView.getViewTreeObserver().removeGlobalOnLayoutListener(this);
    }
    });
    }
    /**
    * 计算放大比例,开启放大动画
    *
    * @param photoData
    */
    private void calculateScaleAndStartZoomInAnim(final GalleryPhotoParameterModel photoData) {
    mScaleImageView.setVisibility(View.VISIBLE);

    // 放大动画参数
    int translationX = (photoData.locOnScreen[0] + photoData.imageWidth / 2) - (int) (mScreenRect.width() / 2);
    int translationY = (photoData.locOnScreen[1] + photoData.imageHeight / 2) - (int) ((mScreenRect.height() + GalleryScreenUtil.getStatusBarHeight(getContext())) / 2);
    float scale = getImageViewScale(photoData);
    // 开启放大动画
    executeZoom(mScaleImageView, translationX, translationY, scale, true, new Animator.AnimatorListener() {
    @Override
    public void onAnimationStart(Animator animation) {}

    @Override
    public void onAnimationEnd(Animator animation) {
    showOtherViews();
    tvPhotoSize.setText(String.format("%d/%d", viewPager.getCurrentItem() + 1, photoList.size()));
    }

    @Override
    public void onAnimationCancel(Animator animation) {

    }

    @Override
    public void onAnimationRepeat(Animator animation) {

    }
    });
    }

    3.2 图片缩小操作

    /**
    * 计算缩小比例,开启缩小动画
    */
    private void calculateScaleAndStartZoomOutAnim() {
    hiedOtherViews();

    // 缩小动画参数
    int translationX = (firstClickItemParameterModel.locOnScreen[0] + firstClickItemParameterModel.imageWidth / 2) - (int) (mScreenRect.width() / 2);
    int translationY = (firstClickItemParameterModel.locOnScreen[1] + firstClickItemParameterModel.imageHeight / 2) - (int) ((mScreenRect.height() + GalleryScreenUtil.getStatusBarHeight(getContext())) / 2);
    float scale = getImageViewScale(firstClickItemParameterModel);
    // 开启缩小动画
    executeZoom(mScaleImageView, translationX, translationY, scale, false, new Animator.AnimatorListener() {
    @Override
    public void onAnimationStart(Animator animation) {}

    @Override
    public void onAnimationEnd(Animator animation) {
    mScaleImageView.setImageDrawable(null);
    mScaleImageView.setVisibility(GONE);
    setVisibility(GONE);
    }

    @Override
    public void onAnimationCancel(Animator animation) {}

    @Override
    public void onAnimationRepeat(Animator animation) {}
    });
    }

    3.3 计算图片缩放的比例

    private float getImageViewScale(GalleryPhotoParameterModel photoData) {
    float scale;
    float scaleX = photoData.imageWidth / mScreenRect.width();
    float scaleY = photoData.photoHeight * 1.0f / mScaleImageView.getHeight();

    // 横向图片
    if (photoData.photoWidth > photoData.photoHeight) {
    // 图片的宽高比
    float photoScale = photoData.photoWidth * 1.0f / photoData.photoHeight;
    // 执行动画的ImageView宽高比
    float animationImageScale = mScaleImageView.getWidth() * 1.0f / mScaleImageView.getHeight();

    if (animationImageScale > photoScale) {
    // 动画ImageView宽高比大于图片宽高比的时候,需要用图片的高度除以动画ImageView高度的比例尺
    scale = scaleY;
    }
    else {
    scale = scaleX;
    }
    }
    // 正方形图片
    else if (photoData.photoWidth == photoData.photoHeight) {
    if (mScaleImageView.getWidth() > mScaleImageView.getHeight()) {
    scale = scaleY;
    }
    else {
    scale = scaleX;
    }
    }
    // 纵向图片
    else {
    scale = scaleY;
    }
    return scale;
    }

    3.4 执行动画的缩放

     /**
    * 执行缩放动画
    * @param scaleImageView
    * @param translationX
    * @param translationY
    * @param scale
    * @param isEnlarge
    */
    private void executeZoom(final ImageView scaleImageView, int translationX, int translationY, float scale, boolean isEnlarge, Animator.AnimatorListener listener) {
    float startTranslationX, startTranslationY, endTranslationX, endTranslationY;
    float startScale, endScale, startAlpha, endAlpha;

    // 放大
    if (isEnlarge) {
    startTranslationX = translationX;
    endTranslationX = 0;
    startTranslationY = translationY;
    endTranslationY = 0;
    startScale = scale;
    endScale = 1;
    startAlpha = 0f;
    endAlpha = 0.75f;
    }
    // 缩小
    else {
    startTranslationX = 0;
    endTranslationX = translationX;
    startTranslationY = 0;
    endTranslationY = translationY;
    startScale = 1;
    endScale = scale;
    startAlpha = 0.75f;
    endAlpha = 0f;
    }

    //-------缩小动画--------
    AnimatorSet set = new AnimatorSet();
    set.play(
    ObjectAnimator.ofFloat(scaleImageView, "translationX", startTranslationX, endTranslationX))
    .with(ObjectAnimator.ofFloat(scaleImageView, "translationY", startTranslationY, endTranslationY))
    .with(ObjectAnimator.ofFloat(scaleImageView, "scaleX", startScale, endScale))
    .with(ObjectAnimator.ofFloat(scaleImageView, "scaleY", startScale, endScale))
    // ---Alpha动画---
    // mMaskView伴随着一个Alpha减小动画
    .with(ObjectAnimator.ofFloat(maskView, "alpha", startAlpha, endAlpha));
    set.setDuration(animDuration);
    if (listener != null) {
    set.addListener(listener);
    }
    set.setInterpolator(new DecelerateInterpolator());
    set.start();
    }

    改View的主要实现如上,在图片进行缩放的时候,要考虑的情况:短边适配、图片原尺寸的宽高、展示图片的ImageView的宽高比、横竖屏时屏幕的尺寸。在此非常感谢震哥的帮助、抱拳了!老铁。如有更多想法的小伙伴。请移步我的github GalleryView地址


    代码下载:GalleryView-master.zip

    收起阅读 »

    【开源项目】使用环信SDK搭建在线教学场景(含三端源码下载)

     引言:2021年在线教育行业如火如荼,所谓人人为我,我为人人,为了方便教育行业的小伙伴们更好地使用环信SDK,IMGEEK论坛生态开发者@巍巍 发布了使用环信SDK搭建的在线教学开源项目“环环教育”,一期覆盖1对1互动教学、在线互动小班课...
    继续阅读 »

     引言:2021年在线教育行业如火如荼,所谓人人为我,我为人人,为了方便教育行业的小伙伴们更好地使用环信SDK,IMGEEK论坛生态开发者@巍巍 发布了使用环信SDK搭建的在线教学开源项目“环环教育”,一期覆盖1对1互动教学、在线互动小班课两种核心教学场景,实现了iOS、Android和Web三端应用。此开源项目演示了环信IM SDK的部分API使用示例,以帮助开发者更好地理解和运用环信IM SDK。

     


     

    开源项目简介:


    环环教育demo,覆盖1对1互动教学、在线互动小班课两种核心教学场景,具备即时消息互动、实时音视频互动、互动白板、屏幕共享等丰富功能。Demo生动展示了如何用IM、音视频、互动白板SDK共同搭建在线互动教学场景。同时demo覆盖了教师端和学生端,并开发实现了iOS、Android、Web三端应用。现在正式开源给小伙伴们,详细介绍请往下看。

     

    核心界面展示

    1对1互动教学主界面



     移动端

     


    Web端

     

     

    在线互动小班课主界面

     


    移动端

     

     



    教师web端

     

     

     

    核心功能

    • 1对1互动教学
    • 在线互动小班课(1名老师+多位学生互动)
    • 即时消息互动(聊天室)
    • 实时音视频互动(音视频)
    • 互动白板
    • 白板权限控制
    • 屏幕共享
    • 学生列表(小班课老师端特有)

     

    资源下载

    Github源码下载:(含iOS、Android、Web以及服务器端

    https://github.com/easemob/learn-easemob

     

    Demo下载体验:

    Web端:https://cgame.bjictc.com/#/

    iOS端:


    识别二维码下载

     

     

    Android端:

    下载链接:https://download-sdk.oss-cn-beijing.aliyuncs.com/downloads/IMDemo/easemob_education_demo.apk


    识别二维码下载

     

    tips:同一房间名称+同一身份退出后不要重复多次进入,建议体验时退出后使用不同房间名。 

    收起阅读 »

    这个自定义键盘能让你欲罢不能!

    KingKeyboard for Android 是一个自定义键盘。内置了满足各种场景的键盘需求:包括但不限于混合、字母、数字、电话、身份证、车牌号等可输入场景。还支持自定义。集成简单,键盘可定制化。Gif 展示引入Maven:<dependency&g...
    继续阅读 »

    KingKeyboard for Android 是一个自定义键盘。内置了满足各种场景的键盘需求:包括但不限于混合、字母、数字、电话、身份证、车牌号等可输入场景。还支持自定义。集成简单,键盘可定制化。

    Gif 展示

    Image

    引入

    Maven:

    <dependency>
    <groupId>com.king.keyboard</groupId>
    <artifactId>kingkeyboard</artifactId>
    <version>1.0.0</version>
    <type>pom</type>
    </dependency>

    Gradle:

    //AndroidX
    implementation 'com.king.keyboard:kingkeyboard:1.0.0'

    Lvy:

    <dependency org='com.king.keyboard' name='kingkeyboard' rev='1.0.0'>
    <artifact name='$AID' ext='pom'></artifact>
    </dependency>
    如果Gradle出现compile失败的情况,可以在Project的build.gradle里面添加如下:(也可以使用上面的GitPack来complie)
    allprojects {
    repositories {
    //...
    maven { url 'https://dl.bintray.com/jenly/maven' }
    }
    }

    自定义按键值

     /*
    * 在KingKeyboard的伴生对象中定义了一些核心的按键值,当您需要自定义键盘时,可能需要用到
    */

    //------------------------------ 下面是定义的一些公用功能按键值
    /**
    * Shift键 -> 一般用来切换键盘大小写字母
    */
    const val KEYCODE_SHIFT = -1
    /**
    * 模式改变 -> 切换键盘输入法
    */
    const val KEYCODE_MODE_CHANGE = -2
    /**
    * 取消键 -> 关闭输入法
    */
    const val KEYCODE_CANCEL = -3
    /**
    * 完成键 -> 长出现在右下角蓝色的完成按钮
    */
    const val KEYCODE_DONE = -4
    /**
    * 删除键 -> 删除输入框内容
    */
    const val KEYCODE_DELETE = -5
    /**
    * Alt键 -> 预留,暂时未使用
    */
    const val KEYCODE_ALT = -6
    /**
    * 空格键
    */
    const val KEYCODE_SPACE = 32

    /**
    * 无作用键 -> 一般用来占位或者禁用按键
    */
    const val KEYCODE_NONE = 0

    //------------------------------

    /**
    * 键盘按键 -> 返回(返回,适用于切换键盘后界面使用,如:NORMAL_MODE_CHANGE或CUSTOM_MODE_CHANGE键盘)
    */
    const val KEYCODE_MODE_BACK = -101

    /**
    * 键盘按键 ->返回(直接返回到最初,直接返回到NORMAL或CUSTOM键盘)
    */
    const val KEYCODE_BACK = -102

    /**
    * 键盘按键 ->更多
    */
    const val KEYCODE_MORE = -103

    //------------------------------ 下面是自定义的一些预留按键值,与共用按键功能一致,但会使用默认的背景按键

    const val KEYCODE_KING_SHIFT = -201
    const val KEYCODE_KING_MODE_CHANGE = -202
    const val KEYCODE_KING_CANCEL = -203
    const val KEYCODE_KING_DONE = -204
    const val KEYCODE_KING_DELETE = -205
    const val KEYCODE_KING_ALT = -206

    //------------------------------ 下面是自定义的一些功能按键值,与共用按键功能一致,但会使用默认背景颜色

    /**
    * 键盘按键 -> 返回(返回,适用于切换键盘后界面使用,如:NORMAL_MODE_CHANGE或CUSTOM_MODE_CHANGE键盘)
    */
    const val KEYCODE_KING_MODE_BACK = -251

    /**
    * 键盘按键 ->返回(直接返回到最初,直接返回到NORMAL或CUSTOM键盘)
    */
    const val KEYCODE_KING_BACK = -252

    /**
    * 键盘按键 ->更多
    */
    const val KEYCODE_KING_MORE = -253

    /*
    用户也可自定义按键值,primaryCode范围区间为-999 ~ -300时,表示预留可扩展按键值。
    其中-399~-300区间为功能型按键,使用Special背景色,-999~-400自定义按键为默认背景色
    */

    示例

    代码示例

        //初始化KingKeyboard
    kingKeyboard = KingKeyboard(this,keyboardParent)
    //然后将EditText注册到KingKeyboard即可
    kingKeyboard.register(editText,KingKeyboard.KeyboardType.NUMBER)

    /*
    * 如果目前所支持的键盘满足不了您的需求,您也可以自定义键盘,KingKeyboard对外提供自定义键盘类型。
    * 自定义步骤也非常简单,只需自定义键盘的xml布局,然后将EditText注册到对应的自定义键盘类型即可
    *
    * 1. 自定义键盘Custom,自定义方法setKeyboardCustom,键盘类型为{@link KeyboardType#CUSTOM}
    * 2. 自定义键盘CustomModeChange,自定义方法setKeyboardCustomModeChange,键盘类型为{@link KeyboardType#CUSTOM_MODE_CHANGE}
    * 3. 自定义键盘CustomMore,自定义方法setKeyboardCustomMore,键盘类型为{@link KeyboardType#CUSTOM_MORE}
    *
    * xmlLayoutResId 键盘布局的资源文件,其中包含键盘布局和键值码等相关信息
    */
    kingKeyboard.setKeyboardCustom(R.xml.keyboard_custom)
    // kingKeyboard.setKeyboardCustomModeChange(xmlLayoutResId)
    // kingKeyboard.setKeyboardCustomMore(xmlLayoutResId)
    kingKeyboard.register(et12,KingKeyboard.KeyboardType.CUSTOM)
     //获取键盘相关的配置信息
    var config = kingKeyboard.getKeyboardViewConfig()

    //... 修改一些键盘的配置信息

    //重新设置键盘配置信息
    kingKeyboard.setKeyboardViewConfig(config)

    //按键是否启用震动
    kingKeyboard.setVibrationEffectEnabled(isVibrationEffectEnabled)

    //... 等等,还有各种监听方法。更多详情,请直接使用。
        //在Activity或Fragment相应的生命周期中调用,如下所示

    override fun onResume() {
    super.onResume()
    kingKeyboard.onResume()
    }

    override fun onDestroy() {
    super.onDestroy()
    kingKeyboard.onDestroy()
    }

    相关说明

    • KingKeyboard主要采用Kotlin编写实现,如果您的项目使用的是Java编写,集成时语法上可能稍微有点不同,除了结尾没有分号以外,对应类伴生对象中的常量,需要通过点伴生对象才能获取。
      //Kotlin 写法
    var keyCode = KingKeyboard.KEYCODE_SHIFT
      //Java 写法
    int keyCode = KingKeyboard.Companion.KEYCODE_SHIFT;

    更多使用详情,请查看app中的源码使用示例

    代码下载:KingKeyboard-master.zip

    收起阅读 »

    多个模块如何管理?用它就对了!

    FragmentationA powerful library that manage Fragment for Android!为"单Activity + 多Fragment","多模块Activity + 多Fragment"架构而生,简化开发,轻松解决动...
    继续阅读 »

    Fragmentation

    A powerful library that manage Fragment for Android!

    为"单Activity + 多Fragment","多模块Activity + 多Fragment"架构而生,简化开发,轻松解决动画、嵌套、事务相关等问题。

    为了更好的使用和了解该库,推荐阅读下面的文章:

    Fragment全解析系列(一):那些年踩过的坑

    Fragment全解析系列(二):正确的使用姿势

    Demo演示:

    均为单Activity + 多Fragment,第一个为简单流式demo,第二个为仿微信交互的demo(全页面支持滑动退出),第三个为仿知乎交互的复杂嵌套demo

    下载APK

      

    特性

    1、悬浮球/摇一摇实时查看Fragment的栈视图,降低开发难度

    2、内部队列机制 解决Fragment多点触控、事务高频次提交异常等问题

    3、增加启动模式、startForResult等类Activity方法

    4、类Android事件分发机制的Fragment BACK键机制:onBackPressedSupport()

    5、提供onSupportVisible()、懒加载onLazyInitView()等生命周期方法,简化嵌套Fragment的开发过程

    6、提供 Fragment转场动画 系列解决方案,动态改变动画

    7、提供Activity作用域的EventBus辅助类,Fragment通信更简单、独立(需要使用EventBusActivityScope库)

    8、支持SwipeBack滑动边缘退出(需要使用Fragmentation_SwipeBack库)

          

    如何使用

    1. 项目下app的build.gradle中依赖:

    // appcompat-v7包是必须的
    compile 'me.yokeyword:fragmentation:1.3.8'

    // 如果不想继承SupportActivity/Fragment,自己定制Support,可仅依赖:
    // compile 'me.yokeyword:fragmentation-core:1.3.8'

    // 如果想使用SwipeBack 滑动边缘退出Fragment/Activity功能,完整的添加规则如下:
    compile 'me.yokeyword:fragmentation:1.3.8'
    // swipeback基于fragmentation, 如果是自定制SupportActivity/Fragment,则参照SwipeBackActivity/Fragment实现即可
    compile 'me.yokeyword:fragmentation-swipeback:1.3.8'

    // Activity作用域的EventBus,更安全,可有效避免after onSavenInstanceState()异常
    compile 'me.yokeyword:eventbus-activity-scope:1.1.0'
    // Your EventBus's version
    compile 'org.greenrobot:eventbus:{version}'

    2. Activity extends SupportActivity或者 implements ISupportActivity:(实现方式可参考MySupportActivity)

    // v1.0.0开始,不强制继承SupportActivity,可使用接口+委托形式来实现自己的SupportActivity
    public class MainActivity extends SupportActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(...);
    // 建议在Application里初始化
    Fragmentation.builder()
    // 显示悬浮球 ; 其他Mode:SHAKE: 摇一摇唤出 NONE:隐藏
    .stackViewMode(Fragmentation.BUBBLE)
    .debug(BuildConfig.DEBUG)
    ... // 更多查看wiki或demo
    .install();

    if (findFragment(HomeFragment.class) == null) {
    loadRootFragment(R.id.fl_container, HomeFragment.newInstance()); // 加载根Fragment
    }
    }

    3. Fragment extends SupportFragment或者 implements ISupportFragment:(实现方式可参考MySupportFragment):

    // v1.0.0开始,不强制继承SupportFragment,可使用接口+委托形式来实现自己的SupportFragment
    public class HomeFragment extends SupportFragment {

    private void xxx() {
    // 启动新的Fragment, 另有start(fragment,SINGTASK)、startForResult、startWithPop等启动方法
            start(DetailFragment.newInstance(HomeBean));
    // ... 其他pop, find, 设置动画等等API, 请自行查看WIKI
        }
    }


    代码下载:Fragmentation-master.zip

    收起阅读 »

    为什么别人的状态栏那么好看,而你自己却无法实现!

    这是一个为Android App 设置状态栏的工具类, 可以在4.4及其以上系统中实现 沉浸式状态栏/状态栏变色,支持设置状态栏透明度,满足你司设计师的各种要求(雾)。在此之前我写过一篇Android App 沉浸式状态栏解决方案,后来我司设计师说默认的透明度...
    继续阅读 »

    这是一个为Android App 设置状态栏的工具类, 可以在4.4及其以上系统中实现 沉浸式状态栏/状态栏变色,支持设置状态栏透明度,满足你司设计师的各种要求(雾)。

    在此之前我写过一篇Android App 沉浸式状态栏解决方案,后来我司设计师说默认的透明度太深了,让我改浅一点,然后在想了一些办法之后给解决了。本着不重复造轮子的原则,索性整理成一个工具类,方便需要的开发者。

    项目 GitHub 地址

    Sample 下载

    下载 StatusBarUtil-Demo

    特性

    1. 设置状态栏颜色

      StatusBarUtil.setColor(Activity activity, int color)

    2. 设置状态栏半透明

      StatusBarUtil.setTranslucent(Activity activity, int statusBarAlpha)

    3. 设置状态栏全透明

      StatusBarUtil.setTransparent(Activity activity)

    4. 为包含 DrawerLayout 的界面设置状态栏颜色(也可以设置半透明和全透明)

      StatusBarUtil.setColorForDrawerLayout(Activity activity, DrawerLayout drawerLayout, int color)

    5. 为使用 ImageView 作为头部的界面设置状态栏透明

      StatusBarUtil.setTranslucentForImageView(Activity activity, int statusBarAlpha, View needOffsetView)

    6. 在 Fragment 中使用

    7. 为滑动返回界面设置状态栏颜色

      推荐配合 bingoogolapple/BGASwipeBackLayout-Android: Android Activity 滑动返回 这个库一起使用。

      StatusBarUtil.setColorForSwipeBack(Activity activity, @ColorInt int color, int statusBarAlpha)

    8. 通过传入 statusBarAlpha 参数,可以改变状态栏的透明度值,默认值是112。

    使用

    1. 在 build.gradle 文件中添加依赖, StatusBarUtil 已经发布在 JCenter:

      compile 'com.jaeger.statusbarutil:library:1.4.0'
    2. 在 setContentView() 之后调用你需要的方法,例如:

      setContentView(R.layout.main_activity);
      ...
      StatusBarUtil.setColor(MainActivity.this, mColor);
    3. 如果你在一个包含 DrawerLayout 的界面中使用, 你需要在布局文件中为 DrawerLayout 添加 android:fitsSystemWindows="true" 属性:

      <android.support.v4.widget.DrawerLayout
      xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto"
      android:id="@+id/drawer_layout"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:fitsSystemWindows="true">

      ...

      </android.support.v4.widget.DrawerLayout>
    4. 滑动返回界面设置状态栏颜色:

      建议配合 bingoogolapple/BGASwipeBackLayout-Android: Android Activity 滑动返回 库一起使用。

      StatusBarUtil.setColorForSwipeBack(Activity activity, @ColorInt int color, int statusBarAlpha)
    5. 当你设置了 statusBarAlpha 值时,该值需要在 0 ~ 255 之间

    6. 在 Fragment 中的使用可以参照 UseInFragmentActivity.java 来实现

    收起阅读 »

    还在自己写Adapter?再不看你就out了

    base-adapterAndroid 万能的Adapter for ListView,RecyclerView,GridView等,支持多种Item类型的情况。引入ForRecyclerViewcompile 'com.zhy:base-rvadapter:...
    继续阅读 »

    base-adapter

    Android 万能的Adapter for ListView,RecyclerView,GridView等,支持多种Item类型的情况。

    引入

    ForRecyclerView

    compile 'com.zhy:base-rvadapter:3.0.3'

    ForListView

    compile 'com.zhy:base-adapter:3.0.3'

    使用

    ##(1)简单的数据绑定(ListView与其使用方式一致)

    首先看我们最常用的单种Item的书写方式:

    mRecyclerView.setAdapter(new CommonAdapter<String>(this, R.layout.item_list, mDatas)
    {
    @Override
    public void convert(ViewHolder holder, String s)
    {
    holder.setText(R.id.id_item_list_title, s);
    }
    });

    是不是相当方便,在convert方法中完成数据、事件绑定即可。

    只需要简单的将Adapter继承CommonAdapter,复写convert方法即可。省去了自己编写ViewHolder等大量的重复的代码。

    • 可以通过holder.getView(id)拿到任何控件。
    • ViewHolder中封装了大量的常用的方法,比如holder.setText(id,text),holder.setOnClickListener(id,listener)等,可以支持使用。

    效果图:

    ##(2)多种ItemViewType(ListView与其使用方式一致)

    对于多中itemviewtype的处理参考:https://github.com/sockeqwe/AdapterDelegates ,具有极高的扩展性。

    MultiItemTypeAdapter adapter = new MultiItemTypeAdapter(this,mDatas);
    adapter.addItemViewDelegate(new MsgSendItemDelagate());
    adapter.addItemViewDelegate(new MsgComingItemDelagate());

    每种Item类型对应一个ItemViewDelegete,例如:

    public class MsgComingItemDelagate implements ItemViewDelegate<ChatMessage>
    {

    @Override
    public int getItemViewLayoutId()
    {
    return R.layout.main_chat_from_msg;
    }

    @Override
    public boolean isForViewType(ChatMessage item, int position)
    {
    return item.isComMeg();
    }

    @Override
    public void convert(ViewHolder holder, ChatMessage chatMessage, int position)
    {
    holder.setText(R.id.chat_from_content, chatMessage.getContent());
    holder.setText(R.id.chat_from_name, chatMessage.getName());
    holder.setImageResource(R.id.chat_from_icon, chatMessage.getIcon());
    }
    }

    贴个效果图:

    ##(3) 添加HeaderView、FooterView

    mHeaderAndFooterWrapper = new HeaderAndFooterWrapper(mAdapter);

    TextView t1 = new TextView(this);
    t1.setText("Header 1");
    TextView t2 = new TextView(this);
    t2.setText("Header 2");
    mHeaderAndFooterWrapper.addHeaderView(t1);
    mHeaderAndFooterWrapper.addHeaderView(t2);

    mRecyclerView.setAdapter(mHeaderAndFooterWrapper);
    mHeaderAndFooterWrapper.notifyDataSetChanged();

    类似装饰者模式,直接将原本的adapter传入,初始化一个HeaderAndFooterWrapper对象,然后调用相关API添加。

    ##(4) 添加LoadMore

    mLoadMoreWrapper = new LoadMoreWrapper(mOriginAdapter);
    mLoadMoreWrapper.setLoadMoreView(R.layout.default_loading);
    mLoadMoreWrapper.setOnLoadMoreListener(new LoadMoreWrapper.OnLoadMoreListener()
    {
    @Override
    public void onLoadMoreRequested()
    {
    }
    });

    mRecyclerView.setAdapter(mLoadMoreWrapper);

    直接将原本的adapter传入,初始化一个LoadMoreWrapper对象,然后调用相关API即可。

    ##(5)添加EmptyView

    mEmptyWrapper = new EmptyWrapper(mAdapter);
    mEmptyWrapper.setEmptyView(R.layout.empty_view);

    mRecyclerView.setAdapter(mEmptyWrapper );

    直接将原本的adapter传入,初始化一个EmptyWrapper对象,然后调用相关API即可。

    支持链式添加多种功能,示例代码:

    mAdapter = new EmptyViewWrapper(
    new LoadMoreWrapper(
    new HeaderAndFooterWrapper(mOriginAdapter)));

    一些回调

    onViewHolderCreated

    mListView.setAdapter(new CommonAdapter<String>(this, R.layout.item_list, mDatas)
    {
    @Override
    public void convert(ViewHolder holder, String o, int pos)
    {
    holder.setText(R.id.id_item_list_title, o);
    }

    @Override
    public void onViewHolderCreated(ViewHolder holder, View itemView)
    {
    super.onViewHolderCreated(holder, itemView);
    //AutoUtil.autoSize(itemView)
    }
    });

    代码下载:
    baseAdapter-master.zip
    收起阅读 »

    为什么别人都在摸鱼就你在加班?用对工具让你事半功倍!

    主要包括:缓存(图片缓存、预取缓存、网络缓存)、公共View(下拉及底部加载更多ListView、底部加载更多ScrollView、滑动一页Gallery)及Android常用工具类(网络、下载、Android资源操作、shell、文件、Json、随机数、Co...
    继续阅读 »

    主要包括缓存(图片缓存、预取缓存、网络缓存)、公共View(下拉及底部加载更多ListView、底部加载更多ScrollView、滑动一页Gallery)及Android常用工具类(网络、下载、Android资源操作、shell、文件、Json、随机数、Collection等等)。
    示例源码:TrineaAndroidDemo
    使        用:拉取代码导入IDE,右击你的工程->properties->Android,在library中选择TrineaAndroidCommon。
    Api Guide:TrineaAndroidCommon API Guide

    Dev Tools App

    The Dev Tools App is a powerful android development tool that can help you improve efficiency greatly, It can be used to view the latest open source projects, view activity history, view manifest, decompile, color picker, extract apk or so, view app info, open or close the options in the developer options quickly, and more.

    You can download it from DevTools@Google Play.

    一. 缓存类

    主要特性:(1).使用简单 (2).轻松获取及预取取新图片 (3).包含二级缓存 (4).可选择多种缓存算法(FIFO、LIFO、LRU、MRU、LFU、MFU等13种)或自定义缓存算法 (5).可方便的保存及初始化恢复数据 (6).省流量性能佳(有且仅有一个线程获取图片) (7).支持http请求header设置及不同类型网络处理(8).可根据系统配置初始化缓存 (9).扩展性强 (10).支持等待队列 (11)包含map的大多数接口。

    1. 图片缓存

    使用见:图片缓存的使用
    适用:获取图片较多且图片使用频繁的应用,包含二级缓存,如新浪微博、twitter、微信头像、美丽说、蘑菇街、花瓣、淘宝等等。效果图如下:
    ImageCahe

    2. 图片SD卡缓存

    使用见:图片SD卡缓存的使用
    适用:应用中获取图片较多且图片较大的情况。需要二级缓存及ListView或GridView图片加载推荐使用上面的ImageCache。效果图如下:
    ImageSDCardCache

    3. 网络缓存

    使用见:Android网络缓存
    适用:网络获取内容不大的应用,尤其是api接口数据,如新浪微博、twitter的timeline、微信公众账号发送的内容等等。效果图如下:
    HttpCache

    4. 预取数据缓存

    使用见:预取数据缓存
    缓存类关系图如下:其中HttpCache为后续计划的http缓存 Image Cache

    二. 公用的view

    1. 下拉刷新及滚动到底部加载更多的Listview

    使用: 下拉刷新及滚动到底部加载更多listview的使用
    实现原理: http://trinea.iteye.com/blog/1562281。效果图如下:
    DropDownListView

    2. 滑动一页(一个Item)的Gallery

    使用及实现原理:滑动一页(一个Item)的Gallery的使用。效果图如下:
    ViewPager1 ViewPager2

    3. 滑动到底部或顶部响应的ScrollView

    使用及实现原理: 滚动到底部或顶部响应的ScrollView使用。效果图如下:
    ScrollView

    三. 工具类

    具体介绍可见:Android常用工具类
    目前包括HttpUtils、DownloadManagerProShellUtilsPackageUtils、PreferencesUtils、JSONUtils、FileUtils、ResourceUtils、StringUtils、ParcelUtils、RandomUtils、ArrayUtils、ImageUtils、ListUtils、MapUtils、ObjectUtils、SerializeUtils、SystemUtils、TimeUtils。

    1. Android系统下载管理DownloadManager使用

    使用示例:Android系统下载管理DownloadManager功能介绍及使用示例
    功能扩展:Android下载管理DownloadManager功能扩展和bug修改 效果图如下:
    downloadManagerDemo

    2. Android APK root权限静默安装

    使用示例:Android APK root权限静默安装

    3. Android root权限

    直接调用ShellUtils.execCommand方法

    4. 图片工具类

    (1)Drawable、Bitmap、byte数组相互转换; (2)根据url获得InputStream、Drawable、Bitmap
    更多工具类介绍见Android常用工具类

    Proguard

    -keep class cn.trinea.android.** { *; }
    -keepclassmembers class cn.trinea.android.** { *; }
    -dontwarn cn.trinea.android.**

    Download

    Gradle:

    compile 'cn.trinea.android.common:trinea-android-common:4.2.15'



    代码下载:android-common-master.zip

    收起阅读 »