注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

View事件分发

所谓View的事件分发,其实就是对MotionEvent事件的分发过程,即当一个MotionEvent产生了以后,系统需要把这个事件传递给一个具体的View,而这个传递的过程就是分发过程。分发过程由三个重要的方法共同完成:dispatchTouchEvent、...
继续阅读 »

所谓View的事件分发,其实就是对MotionEvent事件的分发过程,即当一个MotionEvent产生了以后,系统需要把这个事件传递给一个具体的View,而这个传递的过程就是分发过程。

分发过程由三个重要的方法共同完成:dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent,下面先简单介绍一下这些方法:

1、public boolean dispatchTouchEvent(MotionEvent ev) 用来进行事件的分发。如果事件能够传递给当前View,那么此方法一定会被调用,返回结果受当前View的onTouchEvent和下级View的dispatchTouchEvent方法的影响,表示是否消耗当前事件。

2、public boolean onInterceptTouchEvent(MotionEvent event) 在上述方法内部调用,用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列当中,此方法不会被再次调用,返回结果表示是否拦截当前事件。

3、public boolean onTouchEvent(MotionEvent event) 在dispatchTouchEvent方法中调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前View无法再次接收到事件。
当一个点击事件产生后,事件总是先传递给Activity,由Activity的dispatchTouchEvent()来进行事件分发,源码如下,路径android.app.Activity#dispatchTouchEvent:


   public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
根据Activity中的dispatchTouchEvent方法可以看到,里边会调用到getWindow()的superDispatchTouchEvent(ev),这里的getWindow()返回的是一个Window对象,而PhoneWindow又是抽象类Window在Android中唯一的实现,因此可以直接看PhoneWindow的superDispatchTouchEvent(ev),源码如下,路径com.android.internal.policy.PhoneWindow#superDispatchTouchEvent:

    @Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
里边又调用到mDecor的superDispatchTouchEvent(event),源码如下,路径com.android.internal.policy.DecorView#superDispatchTouchEvent:

    public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}


mDecor是一个DecorView对象,而DecorView继承自FrameLayout,FrameLayout继承自ViewGroup,FrameLayout本身没有重写dispatchTouchEvent方法,因此super.dispatchTouchEvent(event)最终就调用到了ViewGroup里,所以事件分发的核心部分是ViewGroup,我们暂时先放下这里,后边重点分析。

再回过头看下上述Activity的dispatchTouchEvent方法,最终会根据ViewGroup的dispatchTouchEvent的返回值决定后续流程,如果getWindow().superDispatchTouchEvent(ev)返回true,代表ViewGroup处理了该事件,直接返回,反之代表正在事件分发过程中没人处理这个事件,则会调用Activity的onTouchEvent方法,这就是整体的事件分发流程。

下面我们详细分析ViewGroup,刚才看到事件传递到了ViewGroup的dispatchTouchEvent (event),那我们就看下它的源码,源码如下,路径android.view.ViewGroup#dispatchTouchEvent,代码略长,以下是精简了剩下核心流程的代码:


        @Override
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean handled = false;// 方法最后的返回值,该变量值表示是否处理了该事件。
if (onFilterTouchEventForSecurity(ev)) {// 出于安全原因,会过滤点击事件,在该方法中会对event的一些标志位进行处理,返回FALSE表示丢弃事件,返回TRUE表示继续处理。
final int action = ev.getAction();
// 在安卓源码中有大量的位操作,通过进行位操作限定标志位范围,再对其做判断,此处位操作的作用是保留action的末尾8bit位,其余置0,作为actionMasked。
final int actionMasked = action & MotionEvent.ACTION_MASK;

// Handle an initial down.
// 因为事件流是以按下事件开始的,为此当按下事件到来时,会做一些初始化工作
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
cancelAndClearTouchTargets(ev);// 清空mFirstTouchTarget
resetTouchState();// 5、重置状态,如disallowIntercept
}

// Check for interception.
final boolean intercepted;// 该标志位表示是否拦截事件
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {// 1若该事件是ACTION_DOWN事件,或者事件已经被某个组件(mFirstTouchTarget)处理过
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;// 3允许拦截disallowIntercept标志位
if (!disallowIntercept) {// 4没设置不允许拦截disallowIntercept标志位
intercepted = onInterceptTouchEvent(ev);// 这里决定拦截intercepted为true,后续就会执行注释6处
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;//就是为了不拦截后续的非ACTION_DOWN事件
}
} else {// 2没有mFirstTouchTarget处理过,并且也不是初始的ACTION_DOWN事件,则拦截。
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}

if (!canceled && !intercepted) {// 7没拦截,由子View进行处理
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {//注意这里不执行ACTION_MOVE和ACTION_UP事件
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {// 8遍历ViewGroup所有子View
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {// 9调用dispatchTransformedTouchEvent,内部调用子View的dispatchTouchEvent()
newTouchTarget = addTouchTarget(child, idBitsToAssign);// 10给mFirstTouchTarget赋值
break;// 11跳出遍历子View
}
}
}
}
}
// Dispatch to touch targets.
if (mFirstTouchTarget == null) {// 6可能没有子View或者所有的子View都不处理该事件或者是ViewGroup拦截了事件

// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {//有子View处理了ACTION_DOWN,ACTION_MOVE和ACTION_UP会执行这里
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {// alreadyDispatchedToNewTouchTarget为true就说明了mFirstTouchTarget被赋值了,所以事件已经交给子View处理了,这里就返回true
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {//设置了FLAG_DISALLOW_INTERCEPT,后续ACTION_MOVE和ACTION_UP会执行子View的dispatchTouchEvent()
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
}
return handled;
}


首先看下注释1处的mFirstTouchTarget,那么mFirstTouchTarget != null是什么意思呢?这个从后面的代码逻辑可以看出来,当事件由ViewGroup的子元素成功处理时,mFirstTouchTarget会被赋值并指向子元素。

也就是说,当ViewGroup不拦截事件并将事件交由子元素处理时mFirstTouchTarget才不为null。反之,一旦事件由当前ViewGroup拦截时,mFirstTouchTarget就是null,这样当ACTION_MOVE和ACTION_UP事件到来时,由于mFirstTouchTarget是null,将导致执行注释2处代码,ViewGroup的onInterceptTouchEvent不会再被调用,并且同一序列中的其他事件都会默认交给它处理。

当然,这里有一种特殊情况,就是上述注释3处的FLAG_DISALLOW_INTERCEPT标记位,这个标记位是通过ViewGroup的requestDisallowInterceptTouchEvent方法来设置的,一般用于子View中。FLAG_DISALLOW_INTERCEPT一旦设置后,ViewGroup将无法拦截除了ACTION_DOWN以外的其他点击事件。为什么说是除了ACTION_DOWN以外的其他事件呢?这是因为ViewGroup在分发事件时,如果是ACTION_DOWN就会重置FLAG_DISALLOW_INTERCEPT这个标记位,具体代码是执行注释5处代码,将导致子View中设置的这个标记位无效。因此,当面对ACTION_DOWN事件时,ViewGroup总是会调用自己的onInterceptTouchEvent方法来询问自己是否要拦截事件,也就是一定会执行注释4,。因此子View调用requestDisallowInterceptTouchEvent方法并不能影响ViewGroup对ACTION_DOWN事件的处理,所以FLAG_DISALLOW_INTERCEPT就是为了不拦截后续的非ACTION_DOWN事件设计的。

如果ViewGroup决定拦截ACTION_DOWN事件,就会执行注释6处的dispatchTransformedTouchEvent方法,暂时先不管dispatchTransformedTouchEvent内部,下面统一分析,mFirstTouchTarget就是null,后续的非ACTION_DOWN事件将会默认交给它处理并且不再调用它的onInterceptTouchEvent方法

如果ViewGroup不拦截事件的时候,事件会向下分发交由它的子View进行处理,具体执行注释7处,然后在注释8处,遍历ViewGroup的所有子元素,然后判断子元素是否能够接收到点击事件。是否能够接收点击事件主要由两点来衡量:子元素是否在播动画和点击事件的坐标是否落在子元素的区域内。如果某个子元素满足这两个条件,那么事件就会传递给它来处理,然后也会调用到dispatchTransformedTouchEvent方法,暂时先不管dispatchTransformedTouchEvent内部,下面统一分析,如果dispatchTransformedTouchEvent返回了true,就会执行注释10处,调用addTouchTarget()方法,内部会完成mFirstTouchTarget的赋值,并且在注释11处终止对子元素的遍历,那我们就看下addTouchTarget()的源码,源码如下,路径android.view.ViewGroup#addTouchTarget


    private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;// 单链表结构
mFirstTouchTarget = target;// 这里就会完成mFirstTouchTarget的赋值
return target;
}

mFirstTouchTarget是否被赋值,将直接影响到ViewGroup对事件的拦截策略,如果mFirstTouchTarget为null,那么ViewGroup就默认拦截接下来同一序列中所有的点击事件,这一点在前面已经做了分析。

如果遍历所有的子元素后事件都没有被合适地处理,这包含两种情况:第一种是ViewGroup没有子元素;第二种是子元素处理了点击事件,但是在dispatchTouchEvent中返回了false,这一般是因为子元素在onTouchEvent中返回了false。在这两种情况下,ViewGroup会自己处理点击事件,同样也会调用到注释6处代码执行dispatchTransformedTouchEvent方法,就如同ViewGroup自己拦截了事件,自己来处理。

上面提到ViewGroup自身拦截或子View没有处理,还是需要ViewGroup自身处理,都会调用dispatchTransformedTouchEvent(),传入的参数child为null,如果是没拦截,遍历到了子View,也会调用dispatchTransformedTouchEvent(),传入的参数child就是遍历出来的子View本身,因此下面看下dispatchTransformedTouchEvent(),源码如下,路径android.view.ViewGroup#dispatchTransformedTouchEvent


    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;

// Canceling motions is a special case. We don't need to perform any transformations
// or filtering. The important part is the action, not the contents.
final int oldAction = event.getAction();
if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}

// Calculate the number of pointers to deliver.
final int oldPointerIdBits = event.getPointerIdBits();
final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;

// If for some reason we ended up in an inconsistent state where it looks like we
// might produce a motion event with no pointers in it, then drop the event.
if (newPointerIdBits == 0) {
return false;
}

// If the number of pointers is the same and we don't need to perform any fancy
// irreversible transformations, then we can reuse the motion event for this
// dispatch as long as we are careful to revert any changes we make.
// Otherwise we need to make a copy.
final MotionEvent transformedEvent;
if (newPointerIdBits == oldPointerIdBits) {
if (child == null || child.hasIdentityMatrix()) {
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
event.offsetLocation(offsetX, offsetY);

handled = child.dispatchTouchEvent(event);

event.offsetLocation(-offsetX, -offsetY);
}
return handled;
}
transformedEvent = MotionEvent.obtain(event);
} else {
transformedEvent = event.split(newPointerIdBits);
}

// Perform any necessary transformations and dispatch.
if (child == null) {
handled = super.dispatchTouchEvent(transformedEvent);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
transformedEvent.offsetLocation(offsetX, offsetY);
if (! child.hasIdentityMatrix()) {
transformedEvent.transform(child.getInverseMatrix());
}

handled = child.dispatchTouchEvent(transformedEvent);
}

// Done.
transformedEvent.recycle();
return handled;
}

提取核心代码可以看到主要执行了

        if (child == null) {
handled = super.dispatchTouchEvent(transformedEvent);
} else {
handled = child.dispatchTouchEvent(transformedEvent);
}
return handled;

也就是说如果传入的child为null,会return super.dispatchTouchEvent(),也就是会调用ViewGroup的父类View的dispatchTouchEvent(),这样点击事件开始交由View来处理;如果child不为null,就调用子view本身的dispatchTouchEvent(),如果子View不是ViewGroup,则也会交由View的dispatchTouchEvent()处理这个事件,如果子View是ViewGroup,那就开启新一轮的事件分发,就又回到了上面分析的ViewGroup的dispatchTouchEvent()方法

然后事件到了View的dispatchTouchEvent (MotionEvent event),直接上源码如下,路径android.view.View#dispatchTouchEvent



    public boolean dispatchTouchEvent(MotionEvent event) {
// If the event should be handled by accessibility focus first.
if (event.isTargetAccessibilityFocus()) {
// We don't have focus or no virtual descendant has it, do not handle the event.
if (!isAccessibilityFocusedViewOrHost()) {
return false;
}
// We have focus and got the event, then use normal event dispatch.
event.setTargetAccessibilityFocus(false);
}

boolean result = false;

if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(event, 0);
}

final int actionMasked = event.getActionMasked();
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Defensive cleanup for new gesture
stopNestedScroll();
}

if (onFilterTouchEventForSecurity(event)) {
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {// 1判断有mOnTouchListener并且mOnTouchListener返回true
result = true;
}

if (!result && onTouchEvent(event)) {// 2如果上述result就不会执行onTouchEvent(event)
result = true;
}
}

if (!result && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(event, 0);
}

// Clean up after nested scrolls if this is the end of a gesture;
// also cancel it if we tried an ACTION_DOWN but we didn't want the rest
// of the gesture.
if (actionMasked == MotionEvent.ACTION_UP ||
actionMasked == MotionEvent.ACTION_CANCEL ||
(actionMasked == MotionEvent.ACTION_DOWN && !result)) {
stopNestedScroll();
}

return result;
}


从代码可以看出View对点击事件的处理过程就比较简单了,因为View(这里不包含ViewGroup)是一个单独的元素,它没有子元素因此无法向下传递事件,所以它只能自己处理事件。注释1处,可以看出View对点击事件的处理过程,首先会判断有没有设置OnTouchListener,如果OnTouchListener中的onTouch方法返回true,那么onTouchEvent就不会被调用,可见OnTouchListener的优先级高于onTouchEvent,这样做的好处是方便在外界处理点击事件。如果没设置OnTouchListener,就会执行View的onTouchEvent(MotionEvent event)方法,代码精简如下,源码路径android.view.View#onTouchEvent

onTouchEvent首先在注释1处判断View是否是clickable状态,包括点击和长按,然后注释2处,View处于不可用状态下点击事件的处理过程,可以看到,不可用状态下的View照样会消耗点击事件,尽管它看起来不可用。

然后在注释3处,判断是clickable的,也就是说只要View的CLICKABLE和LONG_CLICKABLE有一个为true,那么它就会消耗这个事件,即onTouchEvent方法返回true,也就是注释5,结合上述描述,不管View是不是DISABLE状态,都会消耗这个事件。

然后就是当ACTION_UP事件发生时,会触发performClick方法,这就是给如果View设置了OnClickListener,会执行注释4处代码,那么performClick方法内部会调用它的onClick方法。



作者:冬日毛毛雨
链接:https://www.jianshu.com/p/e7b5c56d5c9d

收起阅读 »

iOS开发UIView的setNeedsLayout, layoutIfNeeded 和 layoutSubviews

iOS layout机制相关方法(CGSize)sizeThatFits:(CGSize)size(void)sizeToFit(void)layoutSubviews(void)layoutIfNeeded(void)setNeedsLayout(void)...
继续阅读 »

iOS layout机制相关方法

  • (CGSize)sizeThatFits:(CGSize)size
  • (void)sizeToFit
  • (void)layoutSubviews
  • (void)layoutIfNeeded
  • (void)setNeedsLayout
  • (void)setNeedsDisplay
  • (void)drawRect
    layoutSubviews在以下情况下会被调用:

1、init初始化不会触发layoutSubviews

但是是用initWithFrame 进行初始化时,当rect的值不为CGRectZero时,也会触发

2、addSubview会触发layoutSubviews

3、设置view的Frame会触发layoutSubviews,当然前提是frame的值设置前后发生了变化

4、滚动一个UIScrollView会触发layoutSubviews

5、旋转Screen会触发父UIView上的layoutSubviews事件

6、改变一个UIView大小的时候也会触发父UIView上的layoutSubviews事件

在苹果的官方文档中强调:

You should override this method only if the autoresizing behaviors
of the subviews do not offer the behavior you want.
layoutSubviews, 当我们在某个类的内部调整子视图位置时,需要调用。

反过来的意思就是说:如果你想要在外部设置subviews的位置,就不要重写。

刷新子对象布局

-layoutSubviews方法:这个方法,默认没有做任何事情,需要子类进行重写
-setNeedsLayout方法: 标记为需要重新布局,异步调用layoutIfNeeded刷新布局,不立即刷新,但layoutSubviews一定会被调用
-layoutIfNeeded方法:如果,有需要刷新的标记,立即调用layoutSubviews进行布局(如果没有标记,不会调用layoutSubviews)

如果要立即刷新,要先调用[view setNeedsLayout],把标记设为需要布局,然后马上调用[view layoutIfNeeded],实现布局

在视图第一次显示之前,标记总是“需要刷新”的,可以直接调用[view layoutIfNeeded]

重绘

-drawRect:(CGRect)rect方法:重写此方法,执行重绘任务
-setNeedsDisplay方法:标记为需要重绘,异步调用drawRect
-setNeedsDisplayInRect:(CGRect)invalidRect方法:标记为需要局部重绘

sizeToFit会自动调用sizeThatFits方法;

sizeToFit不应该在子类中被重写,应该重写sizeThatFits

sizeThatFits传入的参数是receiver当前的size,返回一个适合的size

sizeToFit可以被手动直接调用

sizeToFit和sizeThatFits方法都没有递归,对subviews也不负责,只负责自己

layoutSubviews对subviews重新布局

layoutSubviews方法调用先于drawRect

setNeedsLayout在receiver标上一个需要被重新布局的标记,在系统runloop的下一个周期自动调用layoutSubviews

layoutIfNeeded方法如其名,UIKit会判断该receiver是否需要layout.根据Apple官方文档,layoutIfNeeded方法应该是这样的

layoutIfNeeded遍历的不是superview链,应该是subviews链

drawRect是对receiver的重绘,能获得context

setNeedDisplay在receiver标上一个需要被重新绘图的标记,在下一个draw周期自动重绘,iphone device的刷新频率是60hz,也就是1/60秒后重绘



作者:道哥_d5a0
链接:https://www.jianshu.com/p/f714642d6340

收起阅读 »

JetpackMVVM七宗罪(之一)拿Fragment当LifecycleOwner

首先承认这个系列有点标题党,Jetpack 的 MVVM 本身没有错,错在开发者的某些使用不当。本系列将分享那些 AAC 中常见的错误用法,指导大家打造更健康的应用架构 Fragment 作为 LifecycleOwner 的问题 MVVM 的核心是数...
继续阅读 »

首先承认这个系列有点标题党,Jetpack 的 MVVM 本身没有错,错在开发者的某些使用不当。本系列将分享那些 AAC 中常见的错误用法,指导大家打造更健康的应用架构



Fragment 作为 LifecycleOwner 的问题


MVVM 的核心是数据驱动UI,在 Jetpack 中,这一思想体现在以下场景:Fragment 通过订阅 ViewModel 中的 LiveData 以驱动自身 UI 的更新


关于订阅的时机,一般会选择放到 onViewCreated 中进行,如下:


override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

viewModel.liveData.observe(this) { // Warning : Use fragment as the LifecycleOwner
updateUI(it)
}

}

我们知道订阅 LiveData 时需要传入 LifecycleOwner 以防止泄露,此时一个容易犯的错误是使用 Fragment 作为这个 LifecycleOwner,某些场景下会造成重复订阅的Bug。


做个实验如下:


val handler = Handler(Looper.getMainLooper())

class MyFragment1 : Fragment() {
val data = MutableLiveData<Int>()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

tv.setOnClickListener {
parentFragmentManager.beginTransaction()
.replace(R.id.container, MyFragment2())
.addToBackStack(null)
.commit()

handler.post{ data.value = 1 }
}

data.observe(this, Observer {
Log.e("fragment", "count: ${data.value}")
})

}

当跳转到 MyFragment2 然后再返回 MyFragment1 中时,会打出输出两条log


E/fragment: count: 1
E/fragment: count: 1

原因分析


LiveData 之所以能够防止泄露,是当 LifecycleOwner 生命周期走到 DESTROYED 的时候会 remove 调其关联的 Observer


//LiveData.java

@Override
public void onStateChanged(LifecycleOwner source, Lifecycle.Event event) {
if (mOwner.getLifecycle().getCurrentState() == DESTROYED) {
removeObserver(mObserver);
return;
}
activeStateChanged(shouldBeActive());

}

前面例子中,基于 FragmentManager#replace 的页面跳转,使得 MyFragment1 发生了从 BackStack 的出栈/入栈,由于 Framgent 实例被复用并没有发生 onDestroy, 但是 Fragment的 View 的重建导致重新 onCreateView, 这使得 Observer 被 add 了两次,但是没有对应的 remove。


所以归其原因, 是由于 Fragment 的 Lifecycle 与 Fragment#mView 的 Lifecycle 不一致导致我们订阅 LiveData 的时机和所使用的 LivecycleOwner 不匹配,所以在任何基于 replace 进行页面切换的场景中,例如 ViewPager、Navigation 等会发生上述bug



解决方法


明白了问题原因,解决思路也就清楚了:必须要保证订阅的时机和所使用的LifecycleOwner相匹配,即要么调整订阅时机,要么修改LifecycleOwner


在 onCreate 中订阅


思路一是修改订阅时机,讲订阅提前到 onCreate, 可以保证与 onDestory 的成对出现,但不幸的是这会带来另一个问题。


当 Fragment 出入栈造成 View 重建时,我们需要重建后的 View 也能显示最新状态。但是由于 onCreate 中的订阅的 Observer 已经获取过 LiveData 的最新的 Value,如果 Value 没有新的变化是无法再次通知 Obsever 的



在 LiveData 源码中体现在通知 Obsever 之前对 mLastVersion 的判断:


//LiveData.java

private void considerNotify(ObserverWrapper observer) {
if (!observer.mActive) {
return;
}

if (!observer.shouldBeActive()) {
observer.activeStateChanged(false);
return;
}
if (observer.mLastVersion >= mVersion) {// Value已经处于最新的version
return;
}

observer.mLastVersion = mVersion;
//noinspection unchecked
observer.mObserver.onChanged((T) mData);
}

正是为了保证重建后的 View 也能刷新最新的数据, 我们才在 onViewCreated 中完成订阅。因此只能考虑另一个思路,替换 LifecycleOwner


使用 ViewLifecycleOwner


Support-28 或 AndroidX-1.0.0 起,Fragment 新增了 getViewLifecycleOwner 方法。顾名思义,它返回一个与 Fragment#mView 向匹配的 LifecycleOwner,可以在 onDestroyView 的时候走到 DESTROYED ,删除 onCreateView 中注册的 Observer, 保证了 add/remove 的成对出现。



看一下源码,原理非常简单


//Fragment.java
void performCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState)
{
//...

mViewLifecycleOwner = new LifecycleOwner() {
@Override
public Lifecycle getLifecycle() {
if (mViewLifecycleRegistry == null) {
mViewLifecycleRegistry = new LifecycleRegistry(mViewLifecycleOwner);
}
return mViewLifecycleRegistry;
}
};
mViewLifecycleRegistry = null;
mView = onCreateView(inflater, container, savedInstanceState);
if (mView != null) {
// Initialize the LifecycleRegistry if needed
mViewLifecycleOwner.getLifecycle();
// Then inform any Observers of the new LifecycleOwner
mViewLifecycleOwnerLiveData.setValue(mViewLifecycleOwner); //mViewLifecycleOwnerLiveData在后文介绍
} else {
//...
}
}

基于 mViewLifecycleRegistry 创建 mViewLifecycleOwner,


     @CallSuper
public void onViewStateRestored(@Nullable Bundle savedInstanceState) {// called when onCreateView
if (mView != null) {
mViewLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE);
}
}


@CallSuper
public void onDestroyView() {
if (mView != null) {
mViewLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY);
}
}

然后在 onCreateViewonDestroyView 时,推进到合适的生命周期。


getViewLifecycleOwnerLiveData


顺道提一下,与 getViewLifecycleOwner 同时新增的还有 getViewLifecycleOwnerLiveData。 从前面贴的源码中对 mViewLifecycleOwnerLiveData 的使用,应该可以猜出它的作用: 它是前文讨论的思路1的实现方案,即使在 onCreate 中订阅,由于在 onCreateView 中对 LiveData 进行了重新设置,所以重建后的 View 也可以更新数据。


  // Then inform any Observers of the new LifecycleOwner
mViewLifecycleOwnerLiveData.setValue(mViewLifecycleOwner);

需要特别注意的是,根据 MVVM 最佳实践,我们希望由 ViewModel 而不是 Fragment 持有 LiveData,所以不再推荐使用 getViewLifecycleOwnerLiveData


最后: StateFlow 与 lifecycleScope


前面都是以 LiveData 为例介绍对 ViewLifecycleOwner 的使用, 如今大家也越来越多的开始使用协程的 StateFlow , 同样要注意不要错用 LifecycleOwner


订阅 StateFlow 需要 CoroutineScope, AndroidX 提供了基于 LifecycleOwner 的扩展方法


val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
get() = lifecycle.coroutineScope

当我们在 Fragment 中获取 lifecycleScope 时,切记要使用 ViewLifecycleOwner


class MyFragment : Fragment() {

val viewModel: MyViewModel by viewModel()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

//使用 viewLifecycleOwner 的 lifecycleScope
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.someDataFlow.collect {
updateUI(it)
}
}
}
}
}

注意此处出现了一个 repeatOnLifecycle(...), 这跟本文无关,但是将涉及到第二宗罪的剧情,敬请期待。

收起阅读 »

Android Studio Arctic Fox 大版本更新,快来了解下吧

Android Studio 的新大版本又来了,大家快来躺坑。原本链接: developer.android.com/studio/rele… Android Studio Arctic Fox 是属于大版本更新,其中包含各种新功能和改进,其中主要有:版...
继续阅读 »

Android Studio 的新大版本又来了,大家快来躺坑。原本链接: developer.android.com/studio/rele…



Android Studio Arctic Fox 是属于大版本更新,其中包含各种新功能和改进,其中主要有:版本号规则变更,支持新的测试模式,更高级的调试跟踪,更方便的导出数据库,支持 Compose 等等


新版本号


更新了 Android Studio 的版本号


本次更改了 Android Studio 的版本号规则,与 IntelliJ IDEA(Android Studio 所基于的 IDE)更加一致。


在之前的版本号中,版本的编号规则为 Android Studio 4.3 或版本 4.3.0.1 ,而有了新的版本编号规则后,以后会是 Android Studio - Arctic Fox | 2020.3.12020.3.1 版本。



以下是未来确定 Android Studio 版本号的方式:


<Year of IntelliJ Version>.<IntelliJ major version>.<Studio major version>.<Studio minor/patch version>



  • 前两个数字组代表特定 Android Studio 所基于的 IntellIj 平台的版本,此次的版本为 2020.3

  • 第三个数字组代表 Studio 的主要版本,从 1 开始, 每个主要版本递增 1。

  • 第四组数字代表 Studio 次要/补丁版本,从 1 开始,每个次要版本递增 1。

  • 此次还为每个主要版本提供了一个版本名称,根据动物名称从 A 到 Z 递增,此版本名为 Arctic Fox


更新了 Android Gradle 插件的版本编号


此次更改 Android Gradle 插件 (AGP) 的版本号,以更紧密地匹配底层 Gradle 构建工具,因此 AGP 4.2 之后的下一个版本是 AGP 7.0 。



有关更多详细信息,请参阅 AGP 中的 版本控制更改



Android Gradle 插件 7.0.0


单元测试现在使用 Gradle 测试运行器


为了提高测试执行的整体一致性,Android Studio 现在默认使用 Gradle 运行所有单元测试,当然在一般情况下,此更改不会影响在 IDE 中的测试工作流。


例如,当单击上下文菜单中的Run命令(在右键单击某个测试类时可见)或其对应的 gutter action 时,Android Studio 将默认使用 Gradle 运行配置来运行单元测试。



但是 Android Studio 不再识别现有的 Android JUnit 运行配置,因此需要将项目文件的 Android JUnit 运行配置迁移到 Gradle 运行配置。


要创建 Gradle 测试配置,请按照创建新的运行/调试配置中的说明选择 Gradle 模板,创建新配置后它将出现在 Gradle 部分的 Edit Configurations 对话框中:



如果要检查不再被识别的 Android JUnit 配置,有以下两种选择:



  • 在文本编辑器中打开手动保存的配置,这些文件的位置由用户指定,但文件通常出现在 <my-app>/.idea/runConfigurations/

  • <my-app>/.idea/workspace.xml临时配置和在 <component name="RunManager" ...> 节点中查找, 例如:


<component name="RunManager" selected="Gradle.PlantTest">

<configuration name="PlantTest" type="AndroidJUnit" factoryName="Android JUnit" nameIsGenerated="true">
      <module name="Sunflower.app" />
      <useClassPathOnly />
      <extension name="coverage">
        <pattern>
          <option name="PATTERN" value="com.google.samples.apps.sunflower.data.*" />
          <option name="ENABLED" value="true" />
        </pattern>
      </extension>
      <option name="PACKAGE_NAME" value="com.google.samples.apps.sunflower.data" />
      <option name="MAIN_CLASS_NAME" value="com.google.samples.apps.sunflower.data.PlantTest" />
      <option name="METHOD_NAME" value="" />
      <option name="TEST_OBJECT" value="class" />
      <option name="PARAMETERS" value="" />
      <option name="WORKING_DIRECTORY" value="$MODULE_DIR$" />
      <method v="2">
        <option name="Android.Gradle.BeforeRunTask" enabled="true" />
      </method>
    </configuration>

新的后台任务检查器


可以使用新的 后台任务检查器 来可视化、监控和调试应用程序的后台工作人员


首先将应用程序部署到运行 WorkManager Library 2.5.0 或更高版本的设备,然后从菜单栏中选择View > Tool Windows > App Inspection



你可以通过单击 worker 查看更多详细信息,例如可以看到 worker 的描述,它是如何执行的,它的 worker 链的细节,以及执行的结果。



你还可以通过从表中选择一个 worker 并单击工具栏中的 Show Graph View 来 查看 worker 链的可视化,然后可以选择链中的任何工作程序以查看其详细信息,或者如果它当前正在排队或正在运行,你也可以选择停止它。


如果要返回表格,请单击 Show List View



为了帮助调查执行失败的工作线程问题,开发者可以通过从表中选择并单击工具栏中的 Cancel Selected Worker 线程来停止当前正在运行或排队的工作线程,还可以使用 All tags 下拉菜单,通过标签过滤来选择表中的 workers。


从数据库检查器导出数据


现在开发者可以轻松地从 Database Inspector 导出数据库、表和查询结果,以在本地保存、共享或重新创建。


当你在 Android Studio 中打开一个应用程序项目并在 Database Inspector 中检查 该项目的应用程序时,你可以通过以下方式之一开始导出数据:



  • Databases 面板中选择一个数据库或表,然后单击面板顶部附近的 Export to file

  • 右键单击 Databases 面板中的数据库或表,然后从上下文菜单中选择 Export to file

  • 在选项卡中检查表或查询结果时,单击表或查询结果上方的 Export to file


选择导出操作后,可以使用 Export 对话框来帮助完成最后的步骤,如下所示,你可以选择以下列一种或多种格式导出数据:DB、SQL 或 CSV。



Updated UI for recording in Memory Profiler


我们为不同的记录活动整合了 Memory Profiler 用户界面 (UI),例如捕获堆转储和记录 Java、Kotlin 和本机内存分配。



Memory Profiler 提供了以下选项:



  • Capture heap dump:查看应用程序中在特定时间点使用内存的对象。

  • Record native allocations:查看每个 C/C++ 对象在一段时间内是如何分配的。

  • Record Java/Kotlin allocations:查看每个 Java/Kotlin 对象在一段时间内是如何分配的。


以下是如何使用这三个选项:



  • 要捕获堆转储,请选择 Capture heap dump,然后选择 Record ,在分析器完成对堆转储的捕获后,内存分析器 UI 将转换到显示堆转储的单独页面。




  • 要在运行 Android 10 及更高版本的设备上使用 Record native allocations,请选择 Record native allocations ,然后选择 Record ,而后记录将保持到单击 Stop 为止,之后 Memory Profiler UI 将转换为显示 native 记录的单独页面。



在 Android 9 及更低版本上,Record native allocations 选项不可用。




  • 要记录 Java 和 Kotlin 分配,请选择 Record Java / Kotlin allocations,然后选择 Record。 如果设备运行的是 Android 8 或更高版本,Memory Profiler UI 将转换为显示正在进行的记录的单独页面,开发者可以与纪律上方的迷你时间线进行交互(例如,更改选择范围),而如果要完成录制,可以选择 Stop




在 Android 7.1 及更低版本上,内存分析器使用传统分配记录,它会在时间线上显示记录,直到单击 Stop



更新链接的 C++ 项目


新版本已将与配置无关的 .cxx/ 文件从文件夹移动到 build/ 文件夹中。


CMake C++ 构建需要一个在配置阶段用于执行编译和链接步骤的 Ninja 项目,通过 CMake 生成的项目成本比较高,所以有望在 gradle clean 中不被清理。


因此,它们存储在文件夹.cxx/ 旁边的一个名为的 build/ 文件夹中,通常 Android Gradle 插件会注意到配置更改并自动重新生成 Ninja 项目。但是并非所有情况都可以检测到,发生这种情况时,可以使用 “Refresh Linked C++ Project” 选项手动重新生成 Ninja 项目。


用于多设备测试的新测试矩阵


Instrumentation tests 现在可以在多个设备上并行运行,并且可以使用专门的 Instrumentation tests 结果面板进行调查。使用此面板可以确定测试是否由于 API 级别或硬件属性而失败。



在各种 API 级别和形式因素上测试应用程序,是确保所有用户在使用您的应用程序时获得出色体验的最佳方法之一。


要利用此功能:



  • 1、在 IDE 顶部中心的目标设备下拉菜单中选择 Select Multiple Devices




  • 2、选择目标设备并单击OK




  • 3、运行测试。


要在 Run 面板中查看测试结果,请转到 View > Tool Windows > Run


新的测试结果面板允许按状态、设备和 API 级别过滤测试结果。此外可以通过单击标题对每列进行排序,通过单击单个测试单独查看每个设备的日志和设备信息。


StateFlow 支持数据绑定


对于使用协程的 Kotlin 应用程序,现在可以使用 StateFlow 对象作为数据绑定源来自动通知 UI 数据的变化。数据绑定将具有生命周期感知能力,并且只会在 UI 在屏幕上可见时触发。


要在 StateFlow 绑定类中使用对象,需要指定生命周期所有者来定义 StateFlow 对象的范围,并在布局中 ViewModel 使用绑定表达式将组件的属性和方法分配给相应的视图,如下所示例子:


class ViewModel() {
   val username: StateFlow<String>
}

<TextView
    android:id="@+id/name"
    android:text="@{viewmodel.username}" />


如果在使用 AndroidX 的 Kotlin 应用程序中 StateFlow,数据绑定的功能中会自动包含支持,包括协程依赖项。


要了解更多信息,请参阅使用可观察数据对象


改进了建议的导入


改进了建议导入功能支持的库数量,并更频繁地更新索引。


建议导入可帮助开发者快速轻松地将某些 Google Maven 工件导入类和 Gradle 项目,当 Android Studio 从某些 Google 库中检测到未解析的符号时,IDE 会建议将库导入到类和项目中。


支持构建分析器中的配置缓存


Build Analyzer现在可识别项目何时未启用配置缓存,并将其作为优化提供。Build Analyzer 运行兼容性评估,以在启用之前通知项目中的配置缓存是否存在任何问题。



改进的 AGP 升级助手


Android Gradle 插件升级助手 现在有一个持久的工具窗口,其中包含将要完成的步骤列表。


附加信息也显示在工具窗口的右侧,如果需要还可以选择不同版本的 AGP 进行升级,单击Refresh 按钮更新相应的更新步骤。



非传递性 R 类的重构


可以将非传递性 R 类与 Android Gradle 插件结合使用,为具有多个模块的应用程序实现更快的构建。


这样做有助于防止资源重复,确保每个模块的 R 类只包含对其自身资源的引用,而不从其依赖项中提取引用。这会带来更多最新的构建以及避免编译的相应好处。


可以通过转到 Refactor > Migrate to Non-transitive R Classes 来访问此功能。


支持 Jetpack Compose 工具


我们现在为预览和测试使用Jetpack Compose 的 应用程序提供额外支持。


为了获得使用 Jetpack Compose 开发的最佳体验,应该使用最新版本的 Android Studio Arctic Fox 以开发者可以体验 smart editor features,例如新项目模板和立即预览 Compose UI 的能力。


Compose preview


@Preview 方法 的以下参数现在可用:



  • showBackground:打开和关闭预览的背景。

  • backgroundColor:设置仅在预览表面中使用的颜色。

  • uiMode:这个新参数可以采用任何 Configuration.UI_* 常量,并允许您更改预览的行为,例如将其设置为夜间模式以查看主题的反应。



Interactive preview


可以使用此功能与你的 UI 组件交互,单击它们,然后查看状态如何更改,这是获取有关 UI 反应和预览动画的反馈的快速方法。启用它可单击 Interactive 图标预览将切换模式。



要停止时单击顶部工具栏中的 Stop Interactive Preview


image.png


Deploy to device


可以使用此功能将 UI 片段部署到设备,这有助于测试设备中代码的一小部分而无需启动整个应用程序。


单击 @Preview 注释旁边或预览顶部的 Deploy to Device 图标 ,Android Studio 会部署到连接的设备或模拟器。



Live Edit of literals


我们添加了文字的实时编辑预览,以帮助使用 Compose 的开发人员快速编辑其代码中的文字(字符串、数字、布尔值)并立即查看结果而无需等待编译。


此功能的目标是通过在预览、模拟器或物理设备中近乎即时地显示代码更改来帮助提高开发者的工作效率。



Compose support in the Layout Inspector


Layout Inspector 可以让开发这看到连接设备应用程序布局的丰富细节,应用程序交互并查看工具中的实时更新,以快速调试可能出现的问题。


开发者可以检查使用新的 Android 声明式 UI 框架 Jetpack Compose 编写的布局,无论应用程序使用完全由 Compose 编写的布局,还是使用 Compose 和 Views 的混合布局,布局检查器都 可以帮助开发者了解布局在运行设备上的呈现方式。


开始


首先,将应用程序部署到连接的设备,然后通过选择 View > Tool Windows > Layout Inspector 打开 Layout Inspector 窗口。


如果 Layout Inspector 没有自动连接到应用程序进程,请从进程下拉列表中选择所需的应用程序进程,应该很快就会在工具窗口中看到应用程序布局。


要开始检查 Compose 布局,请选择渲染中可见的布局组件 Component Tree 中 选择它。



Attributes 窗口将显示目前所选择的组合功能的详细信息。在此窗口中可以检查函数的参数及其值,包括修饰符和 lambda 表达式。


对于 lambda 表达式,检查器提供了一个快捷方式来帮助导航到源代码中的表达式。


Layout Inspector 显示调用堆栈的所有功能,组件到应用的布局。在许多情况下,这包括 Compose 库在内部调用的 Compose 函数。如果只想查看应用程序直接调用的 Component Tre中的 Compose 函数,可单击过滤器操作,这可能有助于将树中显示的节点数量减少到可能想要检查的数量。



改进部署下拉菜单


设备下拉列表现在可以区分选择的设备配置中的不同类型的错误。


图标和样式更改现在区分 错误(导致配置损坏的设备选择)和 警告(可能导致意外行为但仍可运行的设备选择)。


此外如果尝试将项目启动到出现错误或相关警告的设备,Android Studio 现在会发出警告。


新的 Wear OS 配对助手


新的 Wear OS 配对助手可指导开发人员直接在 Android Studio 中将 Wear OS 模拟器与物理或虚拟手机配对。


该助手可以帮助在手机上安装正确的 Wear OS Companion 应用,并在两台设备之间建立连接,你可以通过转到设备下拉菜单 > Wear OS Emulator Pairing Assistant



响应式布局模板


Android Studio Arctic Fox 现在包含一个新的布局模板,可适应各种显示尺寸和应用调整大小,例如手机、可折叠设备、平板电脑和分屏模式。


创建新项目或模块时,选择响应式活动模板以创建具有动态调整大小的组件的布局。



通过 File > New,选择 New Project 或 New Module,然后选择 Responsive Activity 模板。



补丁不适用于 Windows for v3.6-v4.1


Windows 平台上 v3.6-v4.1 到 Android Studio Arctic Fox 稳定版的补丁可能不起作用。

收起阅读 »

Flutter 2 Router 从入门到放弃 - 基本使用、区别&优势

前言 Flutter 2 主要带来的新特性有 Null 安全性趋于稳定,桌面和 Web 支持也正式宣布进入 stable 渠道,最受大家关注的就是 Add-to-App 相关的更新,从而改善 Flutter2 之前的版本混合开发体验不好缺点。所谓的 Add-...
继续阅读 »

前言


Flutter 2 主要带来的新特性有 Null 安全性趋于稳定,桌面和 Web 支持也正式宣布进入 stable 渠道,最受大家关注的就是 Add-to-App 相关的更新,从而改善 Flutter2 之前的版本混合开发体验不好缺点。所谓的 Add-to-App 就是将 Flutter 添加到现有的 iOS 和 Android 应用程序中来利用 Flutter,在两个移动平台上复用 Flutter 代码同时仍保留现有本机代码库的绝佳方法。在此方案出现之前,类似的第三方支持有 flutter_boost 、flutter_thrio 等,但是这些方案都要面对的问题是:非官方的支持必然存在每个版本需要适配的问题,而按照 Flutter 目前更新的速度,很可能每个季度的版本都存在较大的变动,所以如果开发者不维护或者维护不及时,那么侵入性极强的这类框架很容易就成为项目的瓶颈。


Flutter2 多引擎混合开发基本用法


1、先创建一个 Android 原生工程,( Android 原生项目工程创建过程略过)


2、Android 项目创建引入 Flutter Module,使用 File -> New- > New Module … -> 选择 Flutter Module ,然后指定一个 module name,填写相关息,最后点击确定,等待 Gradle sync 完成。


3、Android 项目集成 Flutter Module


1)、创建 FlutterEngineGroup 对象,FlutterEngineGroup 可以用来管理多个 FlutterEngine 对象,多个 FlutterEngine 之间是可以共享资源的,这样多个 FlutterEngine 占用的资源相对会少一些,FlutterEngineGroup 需要在 Application onCreate 方法中创建。


package com.zalex.hybird;

import android.app.Application;
import io.flutter.embedding.engine.FlutterEngineGroup;

public class WYApplication extends Application {
public FlutterEngineGroup engineGroup;
@Override
public void onCreate() {
super.onCreate();
// 创建 FlutterEngineGroup 对象
engineGroup = new FlutterEngineGroup(this);
}
}

2)、创建 WYFlutterEngineManager 缓存管理类,通过 FlutterEngineCache 缓存类,先从中获取缓存的 FlutterEngine,如果没有取到,通过 findAppBundlePath 和 entrypoint 创建出 DartEntrypoint 对象,这里的 findAppBundlePath 主要就是默认的 flutter_assets 目录;而 entrypoint 其实就是 dart 代码里启动方法的名称;也就绑定了在 dart 中 runApp 的方法,再通过 createAndRunEngine 方法创建一个 FlutterEngine,然后缓存起来。


public class WYFlutterEngineManager {
public static FlutterEngine flutterEngine(Context context, String engineId, String entryPoint) {
// 1. 从缓存中获取 FlutterEngine
FlutterEngine engine = FlutterEngineCache.getInstance().get(engineId);
if (engine == null) {
// 如果缓存中没有 FlutterEngine
// 1. 新建 FlutterEngine,执行的入口函数是 entryPoint
WYApplication app = (WYApplication) context.getApplicationContext();
DartExecutor.DartEntrypoint dartEntrypoint = new DartExecutor.DartEntrypoint(FlutterInjector.instance().flutterLoader().findAppBundlePath(), entryPoint);
engine = app.engineGroup.createAndRunEngine(context, dartEntrypoint);
// 2. 存入缓存
FlutterEngineCache.getInstance().put(engineId, engine);
}
return engine;
}
}

Activity 绑定 flutter 引擎入口


public class WYFlutterActivity extends FlutterActivity implements EngineBindingsDelegate {
private WYFlutterBindings flutterBindings;

@Override
protected void onCreate(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
flutterBindings = new WYFlutterBindings(this,SingleFlutterActivity.class.getSimpleName(),"main",this);
flutterBindings.attach();
}
@Override
protected void onDestroy() {
super.onDestroy();
flutterBindings.detach();
}

@Override
public FlutterEngine provideFlutterEngine(@NonNull @NotNull Context context) {
return flutterBindings.getEngine();
}

@Override
public void onNext() {
Intent flutterIntent = new Intent(this, MainActivity.class);
startActivity(flutterIntent);
}
}

Fragment 绑定 flutter 引擎入口


int engineId = engineId ;//自定义引擎 Id
int fragmentId = 1233444;//自定义 FragmentId

FrameLayout flutterContainer = new FrameLayout(this);
root.addView(flutterContainer);
flutterContainer.setId(containerId);
flutterContainer.setLayoutParams(new LinearLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
1.0f
));

WYFlutterBindings flutterBindings = new WYFlutterBindings(this,"WYTopFragment","fragmentMain",this);

FlutterEngine engine = bottomBindings.getEngine();

FlutterEngineCache.getInstance().put(engineId+"", engine);
Fragment flutterFragment =FlutterFragment.withCachedEngine(engineId+"").build();
fragmentManager
.beginTransaction()
.add(containerId, flutterFragment)
.commit();

3)、flutter 模块引擎入口绑定,除了 main 入口,其他引擎入口都需要加上@pragma('vm:entry-point')注解


void main() => runApp(MyApp(Colors.blue));

@pragma('vm:entry-point')
void fragmentMain() => runApp(CustomApp(Colors.green));


Flutter2 多引擎混合开发与单引擎混合开发比较


1、 Flutter 多引擎方案是 flutter api 一直都是可以支持的,可以创建多个引擎,也可以渲染多个不同的界面,也是独立的,但是每次启动一个 flutter 引擎,都会占一个独立的引擎,通过测试可以发现,一个引擎 40M,创建 10 个引擎消耗了 235M,对内存的占用很大,在开发中是不可接受的。


2、由于 Flutter 2 之前版本多引擎的缺陷,业内的做法一般是对 isolate 或 engine 进行复用来解决。影响力较大的是以 FlutterBoost 和 Thrio 为代表的单引擎浏览器方案。即把 Activity/ViewController 作为承载 Dart 页面的浏览器,在页面切换时对单引擎进行 detach/attach,同时通知 Dart 层页面切换,来实现 Engine 的复用。由于只持有了一个 Engine 单例,仅创建一份 isolate,Dart 层是通信和资源共享的,内存损耗也得以有显著的降低。但是单引擎实现依赖于修改官方的 io.flutter 包,对 flutter 框架做出比较大的结构性修改。


3、从 Flutter 2 开始,多引擎下使用 FlutterEngineGroup 来创建新的 Engine,官方宣称内存损耗仅占 180K,其本质是使 Engine 可以共享 GPU 上下文、字形和 isolate group snapshot,从而实现了更快的初始速度和更低的内存占用。


4、Flutter 2 与 Flutter 1 创建引擎的区别:


Flutter1 引擎创建


//Android
val engine = FlutterEngine(this)
engine.dartExecutor.executeDartEntrypoin(DartExecutor.DartEntrypoint.createDefault())
FlutterEngineCache.getInstance().put(1,engine)
val intent = FlutterActivity.withCacheEngine(1).build(this)

//iOS
let engine = FlutterEngine()
engine.run()
let vc = FlutterViewController(engine:engine,nibName:nil,bundle:nil)

Fluter2 引擎创建


//Android
val engineGroup = FlutterEngineGroup(context)
val engine1 = engineGroup.createAndRunDefaultEngine(context)
val engine2 = engineGroup.createAndRunEngine(context,DartExecutor.DartEntrypoint(FlutterInjector.instance().flutterLoader().findAppBundlePath(),"anotherEntrypoint"))

//iOS
let engineGroup = FlutterEngineGroup(name:"example",project:nil)
let engine1 = engineGroup.makeEngine(withEntrypoint:nil,libraryURI:nil)
let engine2 = engineGroup.makeEngine(withEntrypoint:"anotherEntrypoint",libraryURI:nil)

5、Flutter 混合开发方案比较



6、Flutter 轻量级多引擎和单引擎优缺点比较



后记


本文通过代码和表格,我们讲述了 Flutter 2 多引擎使用、多引擎混合开发与单引擎混合开发区别和优缺点比较,下一节我们将一起去学习 Flutter 2 多引擎的实现原理。



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

【Flutter组件集录】Dismissible

一、认识 Dismissible 组件 今天来看一个和滑动相关的组件:Dismissible 。如下图效果,该组件可以通过滑动来使条目移除。先来看一下它最简单的使用。 左滑 右滑 ...
继续阅读 »
一、认识 Dismissible 组件

今天来看一个和滑动相关的组件:Dismissible 。如下图效果,该组件可以通过滑动来使条目移除。先来看一下它最简单的使用。















左滑 右滑



_HomePageState 中通过 ListView 展示 60 个条目。如下 tag1 处,在构建条目时在条目外层包裹 Dismissible 组件。 构造中传入 keychild 入参。其中 key 用于标识条目,child 为条目组件。onDismissed 回调是在条目被移除时被调用。


指定注意的是:Dismissible 组件滑动移除只是 UI 的效果,实际的数据并未被移除。为了保证数据UI 的一致性,我们一般在移除后,会同时移除对应的数据,并进行重建,如下 tag2


class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);

@override
_HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
List<String> data = List.generate(60, (index) => '第$index个');

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Dismissible 测试'),),
body: ListView.builder(
itemCount: data.length,
itemBuilder: _buildItems,
),
);
}

Widget _buildItems(BuildContext context, int index) {
return Dismissible( //<---- tag1
key: ValueKey<String>(data[index]),
child: ItemBox(
info: data[index],
),
onDismissed: (direction) =>_onDismissed(direction,index),
);
}

void _onDismissed(DismissDirection direction,int index) {
setState(() {
data.removeAt(index); //<--- tag 2
});
}
}

其中 ItemBox 就是个高度为 56Container ,其中显示一下文字信息。


class ItemBox extends StatelessWidget {
final String info;

const ItemBox({Key? key, required this.info}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
height: 56,
child: Text(
info,
style: TextStyle(fontSize: 20),
),
);
}
}



二、详细了解 Dismissible 组件


上面我们已经简单认识了 Dismissible 组件的使用。如下源码中可以看出,keychild 属性是必选项,除此之外,还有很多其他的属性。下面我们来逐一认识一下:





1、 background 和 secondaryBackground

Dismissible 组件滑动时,我们可以指定背景组件。如果只设置 background ,那么左滑和右滑背景都是一样的,如下左图绿色背景。如果设置 backgroundsecondaryBackground ,则左滑背景为 background ,右滑背景为 secondaryBackground ,如下右图。















单背景 双背景

代码实现也 很简单,指定 backgroundsecondaryBackground 对于组件即可。如下 tag1tag2 处理。


Widget _buildItems(BuildContext context, int index) {
return Dismissible(
key: ValueKey(data[index]),
background: buildBackground(), // tag1
secondaryBackground: buildSecondaryBackground(), // tag2
child: ItemBox(
info: data[index],
),
onDismissed: (direction) =>_onDismissed(direction,index),
);
}

Widget buildBackground(){
return Container(
color: Colors.green,
alignment: Alignment(-0.9, 0),
child: Icon(
Icons.check,
color: Colors.white,
),
);
}

Widget buildSecondaryBackground(){
return Container(
alignment: Alignment(0.9, 0),
child: Icon(
Icons.close,
color: Colors.white,
),
color: Colors.red,
);
}



2. confirmDismiss 回调

从源码中可以看出 confirmDismiss 的类型为 ConfirmDismissCallback 。它是一个函数类型,可以回调出 DismissDirection 对象,返回 bool 值。可以看出这个回调是一个异步方法,所以我们可以处理一下异步事件。


---->[Dismissible#confirmDismiss 声明]----
final ConfirmDismissCallback? confirmDismiss;

typedef ConfirmDismissCallback = Future<bool?> Function(DismissDirection direction);

如下左图中,滑动结束后,等待两秒再执行后续逻辑。效果上来看条目会在两秒后移除。也就说明 onDismissed 是在 confirmDismiss 异步方法完成后才被调用的。


该回调有一个 Future<bool?> 类型的返回值,返回 false 则表示不移除条目。如下右图中,绿色背景下不会移除条目,红色背景下移除条目。就可以通过该返回值进行控制。















执行异步事件 返回值的功效

代码实现如下, tag1 处设置 confirmDismiss 属性。返回值是看 direction 是否不是 startToEnd,即 从左向右滑动 。也就是说, 从左向右滑动 时,会返回 false ,即不消除条目。


Widget _buildItems(BuildContext context, int index) {
return Dismissible(
key: ValueKey(data[index]),
background: buildBackground(),
secondaryBackground: buildSecondaryBackground(),
child: ItemBox(
info: data[index],
),
onDismissed: (direction) =>_onDismissed(direction,index),
confirmDismiss: _confirmDismiss, // tag1
);
}

Future<bool?> _confirmDismiss(DismissDirection direction) async{
await Future.delayed(Duration(seconds: 2));
print('_confirmDismiss:$direction');
return direction!=DismissDirection.startToEnd;
}



3. direction 滑动方向

direction 表示滑动的方向,类型是 DismissDirection 是枚举,有 7 元素。


enum DismissDirection {
vertical,
horizontal,
endToStart,
startToEnd,
up,
down,
none
}

如下左图中,设置 startToEnd ,那么从右往左就无法滑动。如下右图中,设置 vertical ,那条目就只能在竖直方向响应滑动。不过和列表同向滑动有个问题,条目响应了竖直拖拽手势,那列表的拖拽手势就会竞技失败,所以列表是滑不动的。一般来说不会让 Dismissible 和列表滑动方向相同,当列表是水平方向滑动, Dismissible 可以使用竖直方向滑动。















startToEnd vertical



4. onResize 和 resizeDuration

在竖直列表中,滑动消失时,下方条目会有一个 上移 的动画。resizeDuration 就代表动画时长,而 onResize 会在动画执行的每帧中进行回调。















默认时长 2s

源码中可以看出 resizeDuration 的默认时长为 300 ms



在深入瞄一眼,可以看出会监听动画器执行 _handleResizeProgressChanged 。而 onResize 就是在此触发的。另外这个动画的曲线是 _kResizeTimeCurve



void _handleResizeProgressChanged() {
if (_resizeController!.isCompleted) {
widget.onDismissed?.call(_dismissDirection);
} else {
widget.onResize?.call();
}
}

const Curve _kResizeTimeCurve = Interval(0.4, 1.0, curve: Curves.ease);



5.dismissThresholds 和 movementDuration

dismissThresholds 表示消失的阈值,类型为Map<DismissDirection, double> 映射,也就是说我们可以设置不同滑动方向的容忍度, 默认是 0.4 。而 movementDuration 代表滑动方向上移动的动画时长。


const double _kDismissThreshold = 0.4;

final Map<DismissDirection, double> dismissThresholds;














默认效果 本案例效果

下面代码的效果如上图右侧,当 startToEnd 的宇宙设置为 0.8 , 就会比默认的 难触发移除事件 。其中 movementDuration 设置为 3 s 可以很明显地看出,水平移动的慢速。


Widget _buildItems(BuildContext context, int index) {
return Dismissible(
key: ValueKey(data[index]),
background: buildBackground(),
secondaryBackground: buildSecondaryBackground(),
onResize: _onResize,
resizeDuration: const Duration(seconds: 2),
dismissThresholds: {
DismissDirection.startToEnd: 0.8,
DismissDirection.endToStart: 0.2,
},
movementDuration: const Duration(seconds: 3),
child: ItemBox(
info: data[index],
),
direction: DismissDirection.horizontal,
onDismissed: (direction) => _onDismissed(direction, index),
confirmDismiss: _confirmDismiss,
);
}



6. crossAxisEndOffset 交叉轴偏移

如下图,是 crossAxisEndOffset-2 的效果,在滑动过程中,原条目在交叉轴(此处为纵轴)会发生偏移,偏移量就是 crossAxisEndOffset * 组件高 。右图所示,滑动到一般时, 条目 4 已经上移了一个条目高度。















1 2

最后 dragStartBehaviorbehavior 就不说了,这种通用的属性大家应该非常清楚。




三、从 Dismissible 源码中可以学到什么


Dismissible 组件中的 confirmDismissonDismissed 两个回调打的一个组合拳,还是非常巧妙的,在实际开发中我们也可以通过异步回调来处理一些界面效果。我们来看一下源码中的实现: confirmDismiss 回调在 _confirmStartResizeAnimation 方法中进行调用,



在拖拽结束,会先等待 _confirmStartResizeAnimation 的执行,且返回 true ,才会执行 _startResizeAnimation



另外一处是在 _moveController 动画器执行完毕,如果动画完成,也会执行类似逻辑。



最后 onDismissed 回调会在 _startResizeAnimation 中触发。这也就是如何通过一个异步方法,来控制另一个回调的触发。





Dismissible 组件的使用方式到这里就完全介绍完毕,那本文到这里就结束了,谢谢观看,明天见~

收起阅读 »

Android 组件话代码中心化问题之.api化方案

一、代码中心化问题将一个大型的项目拆分成多个Module或者新开的组件化项目,想要的预期是这些module之间是平级的关系.这样一来就可以使得业务相对集中,每个人都可以专注在一件事上。同时,代码的耦合度也会随之降低,达到高度解耦状态,因为同级的module不存...
继续阅读 »

一、代码中心化问题

将一个大型的项目拆分成多个Module或者新开的组件化项目,想要的预期是这些module之间是平级的关系.这样一来就可以使得业务相对集中,每个人都可以专注在一件事上。同时,代码的耦合度也会随之降低,达到高度解耦状态,因为同级的module不存在依赖关系,在编译上就是隔离的,这会让组件间的依赖非常清楚,同时也具有更高的重用性,组件强调复用,模块强调职责划分。 他们没有非常严格的划分。

达到可复用要求的模块,那么这个模块就是组件。每个组件的可替代性、热插拔、独立编译都将可行,

1.1 代码中心化在Android组件化中的问题体现

貌似Android的组件化是非常简单且可行的,AS提供的module创建方式加gradle.properies 自定义属性可读,或者ext全局可配置的project属性亦或kotlin dsl 中kotlin的语法糖都为我们提供了application和library的切换。

然后将代码放在不同的仓库位置最好是单独git 仓库级别的管理隔离,就能达到我们想要解决的一系列问题。

然而事情并不是想象的那么简单...

一些列的问题接踵而至,于我而言影响最深的就是应用设计时使用映射型数据库,导致集成模式和组件模式中复用出现问题;最终使用注解配合Java特性生成代码,虽然不完美但是依然解决了此问题。正当我为了胜利欢呼的时刻,一片《微信Android模块化架构重构实践》文章进入我的眼帘。

随即闪现出了一个重要且紧急的问题,代码中心化的问题

这个问题是怎么出现的呢?在微信Android模块化架构重构实践中是这样描述的

"""

然而随着代码继续膨胀,一些问题开始突显出来。首先出问题的是基础工程libnetscene和libplugin。基础工程一直处于不断膨胀的状态,同时主工程也在不断变大。同时基础工程存在中心化问题,许多业务Storage类被附着在一个核心类上面,久而久之这个类已经没法看了。此外当初为了平滑切换到gradle避免结构变化太大以及太多module,我们将所有工程都对接到一个module上。缺少了编译上的隔离,模块间的代码边界出现一些劣化。虽然紧接着开发了工具来限制模块间的错误依赖,但这段时间里的影响已经产生。在上面各种问题之下,许多模块已经称不上“独立”了。所以当我们重新审视代码架构时,以前良好模块化的架构设计已经逐渐变了样。

"""

再看他们分析问题的原因:

"""

翻开基础工程的代码,我们看到除了符合设计初衷的存储、网络等支持组件外,还有相当多的业务相关代码。这些代码是膨胀的来源。但代码怎么来的,非要放这?一切不合理皆有背后的逻辑。在之前的架构中,我们大量适用Event事件总线作为模块间通信的方式,也基本是唯一的方式。使用Event作为通信的媒介,自然要有定义它的地方,好让模块之间都能知道Event结构是怎样的。这时候基础工程好像就成了存放Event的唯一选择——Event定义被放在基础工程中;接着,遇到某个模块A想使用模块B的数据结构类,怎么办?把类下沉到基础工程;遇到模块A想用模块B的某个接口返回个数据,Event好像不太适合?那就把代码下沉到基础工程吧……

就这样越来越多的代码很“自然的”被下沉到基础工程中。

我们再看看主工程,它膨胀的原因不一样。分析一下基本能确定的是,首先作为主干业务一直还有需求在开发,膨胀在所难免,缺少适当的内部重构但暂时不是问题的核心。另一部分原因,则是因为模块的生命周期设计好像已经不满足使用需要。之前的模块生命周期是从“Account初始化”到“Account已注销”,所以可以看出在这时机之外肯定还有逻辑。放在以前这不是个大问题,刚启动还不等“Account初始化”就要执行的逻辑哪有那么多。而现在不一样,再简单的逻辑堆积起来也会变复杂。此时,在模块生命周期外的逻辑基本上只能放主工程。

此外的问题,模块边界破坏、基础工程中心化,都是代码持续劣化的帮凶...

"""

看完之后就陷入了沉思,这个问题不就是我们面临的问题吗?不仅是在组件化中,在很多形成依赖关系的场景中都有此类问题。

具体是怎么体现的呢,我们来看一组图:

1.1.1 图1

解决方式为分享组件依赖user组件,能解决问题,假设,有一个组件A,需要引用分享组件,就必须依赖分享组件和user组件,这就一举打破了组件编译隔离的远景,组件化将失去香味儿。

1.1.2 图2

将user组件中的公共数据部分下沉到base组件,分享组件依赖base组件即可实现数据提供,然而当非常多的组件需要互相提供数据时,将出现中心化问题,只需要分享组件的B组件不得不依赖base组件,引入其他数据。也就造成了代码中心化下沉失去组件化的意义。

二、 怎么解决代码中心化问题

微信面对这个痛心疾首的问题时发出了“君有疾在腠理,不治将恐深” 的感慨,但也出具了非常厉害的操作-.api 化

这个操作非常高级,做法非常腾讯,但是此文档中只提到了精髓,没有具体的操作步骤,对我们来讲依然存在挑战,

2.1 什么是代码中心化问题的.api方案

先看一下具体的操作过程是什么样的,

上图3中,我们使用某种技术将user组件中需要共享数据的部分抽象成接口,利用AS对文件类型的配置将(kotlin)后拽修改为.api ,然后再创建一个同包名的module-api 组件用来让其他组件依赖,

分享组件和其他组件以及自身组件在module模式下均依赖该组件,这样就能完美的将需要共享的数据单独出去使用了,

2.1.1 SPI 方式实现

这个有点类似SPI(Service Provider Interface)机制,具体可参考:http://www.jianshu.com/p/46b42f7f5…

(来源上面的文档)

大概就是说我们可以将要共享的数据先抽象到接口中形成标准服务接口,然后在具体的实现中,然后在对应某块中实现该接口,当服务提供者提供了接口的一种具体实现后,在jar包的META-INF/services目录下创建一个以“接口全限定名”为命名的文件,内容为实现类的全限定名;

然后利用 ServiceLoader 来加载配置文件中指定的实现,此时我们在不同组件之间通过ServiceLoader加载需要的文件了

2.1.2 利用ARouter

利用ARouter 在组件间传递数据的方式+ gralde 自动生成module-api 组件,形成中心化问题的.api 化

假设我们满足上述的所有关系,并且构建正确,那我们怎么处理组件间的通信,

Arouter 阿里通信路由

@Route(path = "/test/activity")

public class YourActivity extend Activity {

...

}

跳转:

ARouter.getInstance().build("/test/activity").withLong("key1", 666L).navigation()
// 声明接口,其他组件通过接口来调用服务

public interface HelloService extends IProvider {

String sayHello(String name);

}

// 实现接口

@Route(path = "/yourservicegroupname/hello", name = "测试服务")

public class HelloServiceImpl implements HelloService {

@Override

public String sayHello(String name) {

return "hello, " + name;

}

@Override

public void init(Context context) {

}

}

//测试

public class Test {

@Autowired

HelloService helloService;

@Autowired(name = "/yourservicegroupname/hello")

HelloService helloService2;

HelloService helloService3;

HelloService helloService4;

public Test() {

ARouter.getInstance().inject(this);

}

public void testService() {

// 1. (推荐)使用依赖注入的方式发现服务,通过注解标注字段,即可使用,无需主动获取

// Autowired注解中标注name之后,将会使用byName的方式注入对应的字段,不设置name属性,会默认使用byType的方式发现服务(当同一接口有多个实现的时候,必须使用byName的方式发现服务)

helloService.sayHello("Vergil");

helloService2.sayHello("Vergil");

// 2. 使用依赖查找的方式发现服务,主动去发现服务并使用,下面两种方式分别是byName和byType

helloService3 = ARouter.getInstance().navigation(HelloService.class);

helloService4 = (HelloService) ARouter.getInstance().build("/yourservicegroupname/hello").navigation();

helloService3.sayHello("Vergil");

helloService4.sayHello("Vergil");

}

}

假如user组件的用户信息需要给支付组件使用,那我们怎么处理?

ARouter 可以通过上面的IProvider 注入服务的方式通信,或者使用EventBus这种方式

*data class* UserInfo(*val* uid: Int, *val* name: String)

*/***

*** ***@author*** *kpa*

*** ***@date*** *2021/7/21 2:15 下午*

*** ***@email*** *billkp@yeah.net*

*** ***@description*** *用户登录、获取信息等*

***/
*

*interface IAccountService* : *IProvider* {

*//获取账号信息 提供信息*

*fun* getUserEntity(): UserInfo?

}

//注入服务

@Route(path = "/user/user-service")

*class* UserServiceImpl : IAccountService {

//...

}

在支付组件中

IAccountService accountService = ARouter.getInstance().navigation(IAccountService.class);

UserInfo bean = accountService. getUserEntity();

问题就暴露在了我们眼前,支付组件中的IAccountService 和UserInfo 从哪里来?

这也就是module-api 需要解决的问题,在原理方面:

  1. 将需要共享的数据和初始化数据的类文件设计为.api文件

打开AS-> Prefernces -> File Types 找到kotlin (Java)选中 在File name patterns 里面添加".api"(注意这个后缀随意开心的话都可以设置成.kpa)

举例:

UserInfo.api

data class UserInfo(val userName: String, val uid: Int)

UserService.api

interface UserService {

fun getUserInfo(): UserInfo

}

  1. 生成包含共享的数据和初始化数据的类文件的module-api 组件

这步操作有以下实现方式,

  • 自己手动创建一个module-api 组件 显然这是不可取但是可行的
  • 使用脚本语言shell 、python 等扫描指定路径生成对应module-api
  • 利用Android 编译环境及语言groovy,编写gradle脚本,优势在于不用考虑何时编译,不打破编译环境,书写也简单

三、module-api 脚本

找到这些问题出现的原理及怎么去实现之后,从github上找到了优秀的人提供的脚本,完全符合我们的使用预期

*def* includeWithApi(String moduleName) {

*def* packageName = "com/realu/dating"

*//先正常加载这个模块*

**include(moduleName)

*//找到这个模块的路径*

**String originDir = project(moduleName).projectDir

*//这个是新的路径*

**String targetDir = "${originDir}-api"

*//原模块的名字*

**String originName = project(moduleName).name

*//新模块的名字*

*def* sdkName = "${originName}-api"

*//这个是公共模块的位置,我预先放了一个 新建的api.gradle 文件进去*

**String apiGradle = project(":apilibrary").projectDir

*// 每次编译删除之前的文件*

**deleteDir(targetDir)

*//复制.api文件到新的路径*

**copy() {

from originDir

into targetDir

exclude '**/build/'

exclude '**/res/'

include '**/*.api'

}

*//直接复制公共模块的AndroidManifest文件到新的路径,作为该模块的文件*

**copy() {

from "${apiGradle}/src/main/AndroidManifest.xml"

into "${targetDir}/src/main/"

}

*//复制 gradle文件到新的路径,作为该模块的gradle*

**copy() {

from "${apiGradle}/api.gradle"

into "${targetDir}/"

}

*//删除空文件夹*

**deleteEmptyDir(*new* File(targetDir))

*//todo 替换成自己的包名*

*//为AndroidManifest新建路径,路径就是在原来的包下面新建一个api包,作为AndroidManifest里面的包名*

**String packagePath = "${targetDir}/src/main/java/" + packageName + "${originName}/api"

*//todo 替换成自己的包名,这里是apilibrary模块拷贝的AndroidManifest,替换里面的包名*

*//修改AndroidManifest文件包路径*

**fileReader("${targetDir}/src/main/AndroidManifest.xml", "commonlibrary", "${originName}.api")

*new* File(packagePath).mkdirs()

*//重命名一下gradle*

*def* build = *new* File(targetDir + "/api.gradle")

*if* (build.exists()) {

build.renameTo(*new* File(targetDir + "/build.gradle"))

}

*// 重命名.api文件,生成正常的.java文件*

**renameApiFiles(targetDir, '.api', '.java')

*//正常加载新的模块*

**include ":$sdkName"

}

*private void* deleteEmptyDir(File dir) {

*if* (dir.isDirectory()) {

File[] fs = dir.listFiles()

*if* (fs != *null* && fs.length > 0) {

*for* (*int* i = 0; i < fs.length; i++) {

File tmpFile = fs[i]

*if* (tmpFile.isDirectory() {

deleteEmptyDir(tmpFile)

}

*if* (tmpFile.isDirectory() && tmpFile.listFiles().length <= 0) {

tmpFile.delete()

}

}

}

*if* (dir.isDirectory() && dir.listFiles().length == 0) {

dir.delete()

}

}

}

*private void* deleteDir(String targetDir) {

*FileTree* targetFiles = fileTree(targetDir)

targetFiles.exclude "*.iml"

targetFiles.each { File file ->

file.delete()

}

}

*/***

** rename api files(java, kotlin...)*

**/
*

*private def* renameApiFiles(root_dir, String suffix, String replace) {

*FileTree* files = fileTree(root_dir).include("**/*$suffix")

files.each {

File file ->

file.renameTo(*new* File(file.absolutePath.replace(suffix, replace)))

}

}

*//替换AndroidManifest里面的字段*

*def* fileReader(path, name, sdkName) {

*def* readerString = ""

*def* hasReplace = *false*

**file(path).withReader('UTF-8') { reader ->

reader.eachLine {

*if* (it.find(name)) {

it = it.replace(name, sdkName)

hasReplace = *true*

**}

readerString <<= it

readerString << '\n'

}

*if* (hasReplace) {

file(path).withWriter('UTF-8') {

within ->

within.append(readerString)

}

}

*return* readerString

}

}

使用:

includeWithApi ":user"
收起阅读 »

OC与JS交互之WKWebView

阅读目录一、WKWebView Framework二、WKWebView中的三个代理方法三、使用WKWebView重写四、后记WKWebView的14个类与3个协议:WKBackForwardList: 之前访问过的 web 页面的列表,可以通过后退和前进动作...
继续阅读 »

阅读目录

  • 一、WKWebView Framework
  • 二、WKWebView中的三个代理方法
  • 三、使用WKWebView重写
  • 四、后记

WKWebView的14个类与3个协议:

WKBackForwardList: 之前访问过的 web 页面的列表,可以通过后退和前进动作来访问到。

WKBackForwardListItem: webview 中后退列表里的某一个网页。

WKFrameInfo: 包含一个网页的布局信息。

WKNavigation: 包含一个网页的加载进度信息。

WKNavigationAction: 包含可能让网页导航变化的信息,用于判断是否做出导航变化。

WKNavigationResponse: 包含可能让网页导航变化的返回内容信息,用于判断是否做出导航变化。

WKPreferences: 概括一个 webview 的偏好设置。

WKProcessPool: 表示一个 web 内容加载池。

WKUserContentController: 提供使用 JavaScript post 信息和注射 script 的方法。

WKScriptMessage: 包含网页发出的信息。

WKUserScript: 表示可以被网页接受的用户脚本。

WKWebViewConfiguration: 初始化 webview 的设置。

WKWindowFeatures: 指定加载新网页时的窗口属性。

WKWebsiteDataStore: 包含网页数据存储和查找。



WKNavigationDelegate: 提供了追踪主窗口网页加载过程和判断主窗口和子窗口是否进行页面加载新页面的相关方法。

WKUIDelegate: 提供用原生控件显示网页的方法回调。

WKScriptMessageHandler: 提供从网页中收消息的回调方法。


二、WKWebView中的三个代理方法

  1. WKNavigationDelegate
    该代理提供的方法,可以用来追踪加载过程(页面开始加载、加载完成、加载失败)、决定是否执行跳转。
// 页面开始加载时调用
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation;
// 当内容开始返回时调用
- (void)webView:(WKWebView *)webView didCommitNavigation:(WKNavigation *)navigation;
// 页面加载完成之后调用
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation;
// 页面加载失败时调用
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation;

页面跳转的代理方法有三种,分为(收到跳转与决定是否跳转两种)

// 接收到服务器跳转请求之后调用
- (void)webView:(WKWebView *)webView didReceiveServerRedirectForProvisionalNavigation:(WKNavigation *)navigation;
// 在收到响应后,决定是否跳转
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler;
// 在发送请求之前,决定是否跳转
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;
  1. WKUIDelegate
    创建一个新的WKWebView
// 创建一个新的WebView
- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures;

剩下三个代理方法全都是与界面弹出提示框相关的,针对于web界面的三种提示框(警告框、确认框、输入框)分别对应三种代理方法。

// 界面弹出警告框
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(void (^)())completionHandler;
// 界面弹出确认框
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL result))completionHandler;
// 界面弹出输入框
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * __nullable result))completionHandler;
  1. WKScriptMessageHandler
    这个协议中包含一个必须实现的方法,这个方法是native与web端交互的关键,它可以直接将接收到的JS脚本转为OC或Swift对象。
// 从web界面中接收到一个脚本时调用
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message;


三、使用WKWebView

我这里加载的是本地的html,我先贴出html的代码

<!DOCTYPE html>
<html>
<head>

<meta charset="utf-8" http-equiv="Content-Type" content="text/html">

<title>小红帽</title>

<style>
*{
font-size: 50px;
}

.div{
align:"center";
}

.btn{
height:80px; width:80%; padding: 0px 30px 0px 30px; background-color: #0071E7; border: solid 1px #0071E7; border-radius:5px; font-size: 1em; color: white
}
</style>

<script>
function clear(){
document.getElementById('mobile').innerHTML=''
document.getElementById('name').innerHTML=''
document.getElementById('msg').innerHTML=''
}
//oc调用js的方法列表
function alertMobile(){
//这里已经调用过来了 但是搞不明白为什么alert方法没有响应
//alert('我是上面的小黄 手机号是:13300001111')
document.getElementById('mobile').innerHTML='我是上面的小黄 手机号是:13300001111'
}

function alertName(msg){
document.getElementById('name').innerHTML='你好 ' + msg + ', 我也很高兴见到你'
}

function alertSendMsg(num,msg){
document.getElementById('msg').innerHTML='这是我的手机号:' + num + ',' + msg + '!!'
}

//JS响应方法列表
function btnClick1(){
window.webkit.messageHandlers.showMobile.postMessage(null)
//window.webkit.messageHandlers.showMobile.postMessage(null)
}

function btnClick2(){
window.webkit.messageHandlers.showName.postMessage('xiao黄')
}

function btnClick3(){
window.webkit.messageHandlers.showSendMsg.postMessage(['13300001111', 'Go Climbing This Weekend !!!'])
}

</script>

</head>

<body>
<br/>
<div>
<label>自己写html</label>
</div>
<br/>
<div id="mobile"></div>
<div class="div">
<button class="btn" type="button" onclick="btnClick1()">小红帽的手机号</button>
</div>
<br/>
<div id="name"></div>
<div class="div">
<button class="btn" type="button" onclick="btnClick2()">打电话给小红帽</button>
</div>
<br/>
<div id="msg"></div>
<div class="div">
<button class="btn" type="button" onclick="btnClick3()">发短信给小红帽</button>
</div>
<br/>

</body>

</html>

关于html的内容,我在这里不多加解释,有兴趣的同学可以去学习一下关于h5,css,javascript的相关知识。

WKWebView不支持nib文件,所以这里需要使用代码初始化并加载WebView


/*设置configur对象的WKUserContentController属性的信息,也就是设置js可与webview内容交互配置
1、通过这个对象可以注入js名称,在js端通过window.webkit.messageHandlers.自定义的js名称.postMessage(如果有参数可以传递参数)方法来发送消息到native;
2、我们需要遵守WKScriptMessageHandler协议,设置代理,然后实现对应代理方法(userContentController:didReceiveScriptMessage:);
3、在上述代理方法里面就可以拿到对应的参数以及原生的方法名,我们就可以通过NSSelectorFromString包装成一个SEL,然后performSelector调用就可以了
4、以上内容是WKWebview和UIWebview针对JS调用原生的方法最大的区别(UIWebview中主要是通过是否允许加载对应url的那个代理方法,通过在js代码里面写好特殊的url,然后拦截到对应的url,进行字符串的匹配以及截取操作,最后包装成SEL,然后调用就可以了)
*/


/*
上述是理论说明,结合下面的实际代码再做一次解释,保你一看就明白
1、通过addScriptMessageHandler:name:方法,我们就可以注入js名称了,其实这个名称最好就是跟你的方法名一样,这样方便你包装使用,我这里自己写的就是openBigPicture,对应js中的代码就是window.webkit.messageHandlers.openBigPicture.postMessage()
2、因为我的方法是有参数的,参数就是图片的url,因为点击网页中的图片,要调用原生的浏览大图的方法,所以你可以通过字符串拼接的方式给"openBigPicture"拼接成"openBigPicture:",我这里没有采用这种方式,我传递的参数直接是字典,字典里面放了方法名以及图片的url,到时候直接取出来用就可以了
3、我的js代码中关于这块的代码是
window.webkit.messageHandlers.openBigPicture.postMessage({methodName:"openBigPicture:",imageSrc:imageArray[this.index].src});
4、js和原生交互这块内容离不开
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{}这个代理方法,这个方法以及参数说明请到下面方法对应处

*/



WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc]init];
//设置configur对象的preferences属性的信息
config.preferences.minimumFontSize = 18;

WKWebView *wkView = [[WKWebView alloc]initWithFrame:CGRectMake(0, 0, 414, 735/2) configuration:config];
wkView.navigationDelegate=self;
[self.view addSubview:wkView];
self.wkwebView = wkView;


NSString *filePath=[[NSBundle mainBundle] pathForResource:@"myindex" ofType:@"html"];
NSURL *baseUrl=[[NSBundle mainBundle] bundleURL];
[self.wkwebView loadHTMLString:[NSString stringWithContentsOfFile:filePath encoding:NSUTF8StringEncoding error:nil] baseURL:baseUrl];

WKUserContentController *userController=config.userContentController;

//JS调用OC 添加处理脚本
[userController addScriptMessageHandler:self name:@"showMobile"];
[userController addScriptMessageHandler:self name:@"showName"];
[userController addScriptMessageHandler:self name:@"showSendMsg"];

在代理方法中处理相关的操作,js调用oc的代码

//js调用oc,通过这个代理方法进行拦截
/*
1、js调用原生的方法就会走这个方法
2、message参数里面有2个参数我们比较有用,name和body,
2.1 :其中name就是之前已经通过addScriptMessageHandler:name:方法注入的js名称
2.2 :其中body就是我们传递的参数了,比如说我在js端传入的是一个字典,所以取出来也是字典
*/


-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
if ([message.name isEqualToString:@"showMobile"]) {
[self alertMessage:@"这是下面的小红帽 手机号 123333333"];
}
if ([message.name isEqualToString:@"showName"]) {
NSString *info=[NSString stringWithFormat:@"%@",message.body];
[self alertMessage:info];
}
if ([message.name isEqualToString:@"showSendMsg"]) {
NSArray *arr=message.body;
NSString *info=[NSString stringWithFormat:@"%@%@",arr.firstObject,arr.lastObject];
[self alertMessage:info];
}
}
-(void)alertMessage:(NSString *)msg{

UIAlertController *alertController=[UIAlertController alertControllerWithTitle:@"信息" message:msg preferredStyle:UIAlertControllerStyleAlert];

UIAlertAction *ok=[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {

}];

[alertController addAction:ok];
[self presentViewController:alertController animated:YES completion:^{

}];

}

下面的是oc调用js,分开写主要是为了让大家看清楚每部分的代码,已经相关的解释

- (IBAction)clearBtn:(id)sender {
[self.wkwebView evaluateJavaScript:@"clear()" completionHandler:nil];
}
//oc调用js,通过evaluateJavaScript:注入方法名
- (IBAction)clickBtnItem:(UIButton *)sender {
switch (sender.tag) {
case 100:
{
[self.wkwebView evaluateJavaScript:@"alertMobile()" completionHandler:nil];
}
break;

case 101:
{
[self.wkwebView evaluateJavaScript:@"alertName('小红毛')" completionHandler:nil];
}
break;

case 102:
{
[self.wkwebView evaluateJavaScript:@"alertSendMsg('18870707070','周末爬山真是件愉快的事情')" completionHandler:nil];
}
break;

default:
break;
}
}

这是我拿出html里面javaScript的代码,供大家阅读,里面我都有标注的注释。

<script>
function clear(){
document.getElementById('mobile').innerHTML=''
document.getElementById('name').innerHTML=''
document.getElementById('msg').innerHTML=''
}
//oc调用js的方法列表
function alertMobile(){
//这里已经调用过来了 但是搞不明白为什么alert方法没有响应
//alert('我是上面的小黄 手机号是:13300001111')
document.getElementById('mobile').innerHTML='我是上面的小黄 手机号是:13300001111'
}

function alertName(msg){
document.getElementById('name').innerHTML='你好 ' + msg + ', 我也很高兴见到你'
}

function alertSendMsg(num,msg){
document.getElementById('msg').innerHTML='这是我的手机号:' + num + ',' + msg + '!!'
}

//JS响应方法列表
function btnClick1(){
window.webkit.messageHandlers.showMobile.postMessage(null)
//window.webkit.messageHandlers.showMobile.postMessage(null)
}

function btnClick2(){
window.webkit.messageHandlers.showName.postMessage('xiao黄')
}

function btnClick3(){
window.webkit.messageHandlers.showSendMsg.postMessage(['13300001111', 'Go Climbing This Weekend !!!'])
}

</script>

四、学习总结

到此,关于js和原生的交互系列的示例已完成,过程中遇到很多坑,但也很有收获,关于我的描述不清,或者不妥的地方,请大家指出。每篇文章都会对知识点进行总结,在文章末尾给出相关链接和示例DEMO的地址,同样本文的示例也已放在GitHub上,需要的同学取走不谢。关于这几篇文章的DEMO,对比学习,在看的过程中有什么呢疑问,欢迎在下面留言,若发现文章中有那些地方没有阐述清,或者没有提到也可以留言告诉我,我们公司最近一直是h5和原生的app想结合,所以研究一段时间。随着H5的强大,hybrid app已经成为当前互联网的大方向,单纯的native app和web app在某些方面显得就很劣势。



作者:默默的前行
链接:https://www.jianshu.com/p/82af19c5fc6e


收起阅读 »

iOS开发要了解的UIResponder

我们的App与用户进行交互,基本上是依赖于各种各样的触发事件和运动事件。例如,用户点击界面上的按钮,我们需要触发一个按钮点击事件,并进行相应的处理,以给用户一个响应。UIView的三大职责之一就是处理触发事件和运动事件,一个视图是一个事件响应者,可以处理点击等...
继续阅读 »

我们的App与用户进行交互,基本上是依赖于各种各样的触发事件和运动事件。例如,用户点击界面上的按钮,我们需要触发一个按钮点击事件,并进行相应的处理,以给用户一个响应。UIView的三大职责之一就是处理触发事件和运动事件,一个视图是一个事件响应者,可以处理点击等触发事件,而这些触发事件和运动事件就是在UIResponder类中定义的。

一个UIResponder类为那些需要响应并处理事件的对象定义了一组接口。这些事件主要分为两类:触摸事件(touch events)和运动事件(motion events)。UIResponder类为这两类事件都定义了一组接口,这个我们将在下面详细介绍,并探讨一下。

在UIKit中,UIApplication、UIView、UIViewController这几个类都是直接继承自UIResponder类。因此UIKit中的视图、控件、视图控制器,以及我们自定义的视图及视图控制器都有响应事件的能力。这些对象通常被称为响应对象,或者是响应者(以下我们统一使用响应者)。

本文将详细介绍一个UIResponder类提供的基本功能。不过在此之前,我们先来了解一下事件响应链机制。

响应链
大多数事件的分发都是依赖响应链的。响应链是由一系列链接在一起的响应者组成的。一般情况下,一条响应链开始于第一响应者,结束于application对象。如果一个响应者不能处理事件,则会将事件沿着响应链传到下一响应者。

那这里就会有三个问题:

  • 响应链是何时构建的
    1.系统是如何确定第一响应者的
    2.确定第一响应者后,系统又是按照什么样的顺序来传递事件的
    构建响应链
    3.我们都知道在一个App中,所有视图是按一定的结构层次组织起来的,即树状层次结构。除了根视图外,每个视图都有一个父视图;而每个视图都可以有0个或多个子视图。而在这个树状结构构建的同时,也构建了一条完整的事件响应链。

确定第一响应者
当用户触发某一事件(触摸事件或运动事件)后,UIKit会创建一个事件对象(UIEvent),该对象包含一些处理事件所需要的信息。然后事件对象被放到一个事件队列中。这些事件按照先进先出的顺序来处理。当处理事件时,程序的UIApplication对象会从队列头部取出一个事件对象,将其分发出去。通常首先是将事件分发给程序的主window对象,对于触摸事件来讲,window对象会首先尝试将事件分发给触摸事件发生的那个视图上。这一视图通常被称为hit-test视图,而查找这一视图的过程就叫做hit-testing

系统使用hit-testing来找到触摸事件下的视图,它检测一个触摸事件是否发生在相应视图对象的边界之内(即视图的frame属性,这也是为什么子视图如果在父视图的frame之外时,是无法响应事件的)。如果在,则会递归检测其所有的子视图。包含触摸点的视图层次架构中最底层的视图就是hit-test视图。在检测出hit-test视图后,系统就将事件发送给这个视图来进行处理。

我们通过一个示例来演示hit-testing的过程。图1是一个视图层次结构,




假设用户点击了视图E,系统按照以下顺序来查找hit-test视图:

点击事件发生在视图A的边界内,所以检测子视图B和C;
点击事件不在视图B的边界内,但在视图C的边界范围内,所以检测子图片D和E;
点击事件不在视图D的边界内,但在视图E的边界范围内;
视图E是包含触摸点的视图层次架构中最底层的视图(倒树结构),所以它就是hit-test视图。

hit-test视图可以最先去处理触摸事件,如果hit-test视图不能处理事件,则事件会沿着响应链往上传递,直到找到能处理它的视图。

事件传递
最有机会处理事件的对象是hit-test视图或第一响应者。如果这两者都不能处理事件,UIKit就会将事件传递到响应链中的下一个响应者。每一个响应者确定其是否要处理事件或者是通过nextResponder方法将其传递给下一个响应者。这一过程一直持续到找到能处理事件的响应者对象或者最终没有找到响应者。最后到UIApplication对象,若果也不能处理,这个事件就会被抛弃。

下图演示了这样一个事件传递的流程:






当系统检测到一个事件时,将其传递给初始对象,这个对象通常是一个视图。然后,会按以下路径来处理事件(我们以上面左图为例):

1.初始视图(initial view)尝试处理事件。如果它不能处理事件,则将事件传递给其父视图。
2.初始视图的父视图(superview)尝试处理事件。如果这个父视图还不能处理事件,则继续将视图传递给上层视图。
3.上层视图(topmost view)会尝试处理事件。如果这个上层视图还是不能处理事件,则将事件传递给视图所在的视图控制器。
4.视图控制器会尝试处理事件。如果这个视图控制器不能处理事件,则将事件传递给窗口(window)对象。
5.窗口(window)对象尝试处理事件。如果不能处理,则将事件传递给单例UIApplication对象。
6.如果UIApplication对象不能处理事件,则丢弃这个事件。
从上面可以看到,视图、视图控制器、窗口对象和UIApplication对象都能处理事件。另外需要注意的是,手势也会影响到事件的传递。

以上便是响应链的一些基本知识。有了这些知识,我们便可以来看看UIResponder提供给我们的一些方法了。

管理响应链
UIResponder提供了几个方法来管理响应链,包括让响应对象成为第一响应者、放弃第一响应者、检测是否是第一响应者以及传递事件到下一响应者的方法,我们分别来介绍一下。

  • 上面提到在响应链中负责传递事件的方法属性和方法,其声明如下:

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly, nullable) UIResponder *nextResponder;
#else
//上面提到在响应链中负责传递事件的方法是nextResponder,其声明如下
- (nullable UIResponder*)nextResponder;
#endif

#if UIKIT_DEFINE_AS_PROPERTIES

@property(nonatomic, readonly) BOOL canBecomeFirstResponder; // default is NO
#else
//是否将目标对象设置为第一响应者的资格
- (BOOL)canBecomeFirstResponder; // default is NO
#endif
//成为第一响应者
- (BOOL)becomeFirstResponder;

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL canResignFirstResponder; // default is YES
#else
//是否将目标对象设置为失去第一响应者的资格
- (BOOL)canResignFirstResponder; // default is YES
#endif
- (BOOL)resignFirstResponder;

#if UIKIT_DEFINE_AS_PROPERTIES
@property(nonatomic, readonly) BOOL isFirstResponder;
#else
- (BOOL)isFirstResponder;
#endif

// Generally, all responders which do custom touch handling should override all four of these methods.
// Your responder will receive either touchesEnded:withEvent: or touchesCancelled:withEvent: for each
// touch it is handling (those touches it received in touchesBegan:withEvent:).
// *** You must handle cancelled touches to ensure correct behavior in your application. Failure to
// do so is very likely to lead to incorrect behavior or crashes.
#UIResponder内部提供了以下方法来处理事件触摸事件
// UIView是UIResponder的子类,可以覆盖下列4个方法处理不同的触摸事件
// 一根或者多根手指开始触摸view,系统会自动调用view的下面方法
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
// 一根或者多根手指在view上移动,系统会自动调用view的下面方法(随着手指的移动,会持续调用该方法)
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
// 触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程,系统会自动调用view的下面方法
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
// 触摸结束前,某个系统事件(例如电话呼入)会打断触摸过程,系统会自动调用view的下面方法
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;

- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches NS_AVAILABLE_IOS(9_1);

// Generally, all responders which do custom press handling should override all four of these methods.
// Your responder will receive either pressesEnded:withEvent or pressesCancelled:withEvent: for each
// press it is handling (those presses it received in pressesBegan:withEvent:).
// pressesChanged:withEvent: will be invoked for presses that provide an analog value
// (like thumbsticks or analog push buttons)
// *** You must handle cancelled presses to ensure correct behavior in your application. Failure to
// do so is very likely to lead to incorrect behavior or crashes.
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event NS_AVAILABLE_IOS(9_0);
//加速计事件
- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(3_0);
//远程控制事件
- (void)remoteControlReceivedWithEvent:(nullable UIEvent *)event NS_AVAILABLE_IOS(4_0);

- (BOOL)canPerformAction:(SEL)action withSender:(nullable id)sender NS_AVAILABLE_IOS(3_0);
// Allows an action to be forwarded to another target. By default checks -canPerformAction:withSender: to either return self, or go up the responder chain.
- (nullable id)targetForAction:(SEL)action withSender:(nullable id)sender NS_AVAILABLE_IOS(7_0);

@property(nullable, nonatomic,readonly) NSUndoManager *undoManager NS_AVAILABLE_IOS(3_0);
- (UIResponder *)nextResponder

UIResponder类并不自动保存或设置下一个响应者,该方法的默认实现是返回nil。子类的实现必须重写这个方法来设置下一响应者。UIView的实现是返回管理它的UIViewController对象(如果它有)或者其父视图。而UIViewController的实现是返回它的视图的父视图;UIWindow的实现是返回app对象;而UIApplication的实现是返回nil。所以,响应链是在构建视图层次结构时生成的。

一个响应对象可以成为第一响应者,也可以放弃第一响应者。为此,UIResponder提供了一系列方法,我们分别来介绍一下。

如果想判定一个响应对象是否是第一响应者,则可以使用以下方法:

- (BOOL)isFirstResponder

如果我们希望将一个响应对象作为第一响应者,则可以使用以下方法:

- (BOOL)becomeFirstResponder

如果对象成为第一响应者,则返回YES;否则返回NO。默认实现是返回YES。子类可以重写这个方法来更新状态,或者来执行一些其它的行为。

一个响应对象只有在当前响应者能放弃第一响应者状态(canResignFirstResponder)且自身能成为第一响应者(canBecomeFirstResponder)时才会成为第一响应者。

这个方法相信大家用得比较多,特别是在希望UITextField获取焦点时。另外需要注意的是只有当视图是视图层次结构的一部分时才调用这个方法。如果视图的window属性不为空时,视图才在一个视图层次结构中;如果该属性为nil,则视图不在任何层次结构中。

上面提到一个响应对象成为第一响应者的一个前提是它可以成为第一响应者的资格,我们可以使用canBecomeFirstResponder方法来检测,

- (BOOL)canBecomeFirstResponder

需要注意的是我们不能向一个不在视图层次结构中的视图发送这个消息,其结果是未定义的。

与上面两个方法相对应的是响应者放弃第一响应者的方法,其定义如下:

- (BOOL)resignFirstResponder
- (BOOL)canResignFirstResponder

resignFirstResponder默认也是返回YES。需要注意的是,如果子类要重写这个方法,则在我们的代码中必须调用super的实现。

canResignFirstResponder默认也是返回YES。不过有些情况下可能需要返回NO,如一个输入框在输入过程中可能需要让这个方法返回NO,以确保在编辑过程中能始终保证是第一响应者。

管理输入视图
所谓的输入视图,是指当对象为第一响应者时,显示另外一个视图用来处理当前对象的信息输入,如UITextView和UITextField两个对象,在其成为第一响应者是,会显示一个系统键盘,用来输入信息。这个系统键盘就是输入视图。输入视图有两种,一个是inputView,另一个是inputAccessoryView。

与inputView相关的属性有如下两个:

@property(nonatomic, readonly, retain) UIView *inputView
@property(nonatomic, readonly, retain) UIInputViewController *inputViewController

这两个属性提供一个视图(或视图控制器)用于替代为UITextField和UITextView弹出的系统键盘。我们可以在子类中将这两个属性重新定义为读写属性来设置这个属性。如果我们需要自己写一个键盘的,如为输入框定义一个用于输入身份的键盘(只包含0-9和X),则可以使用这两个属性来获取这个键盘。

与inputView类似,inputAccessoryView也有两个相关的属性:


@property(nonatomic, readonly, retain) UIView *inputAccessoryView
@property(nonatomic, readonly, retain) UIInputViewController *inputAccessoryViewController

设置方法与前面相同,都是在子类中重新定义为可读写属性,以设置这个属性。

另外,UIResponder还提供了以下方法,在对象是第一响应者时更新输入和访问视图,

- (void)reloadInputViews

调用这个方法时,视图会立即被替换,即不会有动画之类的过渡。如果当前对象不是第一响应者,则该方法是无效的。

响应触摸事件
UIResponder提供了如下四个大家都非常熟悉的方法来响应触摸事件:

// 当一个或多个手指触摸到一个视图或窗口
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
// 当与事件相关的一个或多个手指在视图或窗口上移动时
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
// 当一个或多个手指从视图或窗口上抬起时
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
// 当一个系统事件取消一个触摸事件时
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event

这四个方法默认都是什么都不做。不过,UIKit中UIResponder的子类,尤其是UIView,这几个方法的实现都会把消息传递到响应链上。因此,为了不阻断响应链,我们的子类在重写时需要调用父类的相应方法;而不要将消息直接发送给下一响应者。

默认情况下,多点触摸是被禁用的。为了接受多点触摸事件,我们需要设置响应视图的multipleTouchEnabled属性为YES。

响应移动事件
与触摸事件类似,UIResponder也提供了几个方法来响应移动事件:


// 移动事件开始
- (void)motionBegan:(UIEventSubtype)motion withEvent:(UIEvent *)event
// 移动事件结束
- (void)motionEnded:(UIEventSubtype)motion withEvent:(UIEvent *)event
// 取消移动事件
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(UIEvent *)event

与触摸事件不同的是,运动事件只有开始与结束操作;它不会报告类似于晃动这样的事件。这几个方法的默认操作也是什么都不做。不过,UIKit中UIResponder的子类,尤其是UIView,这几个方法的实现都会把消息传递到响应链上。

响应远程控制事件
远程控制事件来源于一些外部的配件,如耳机等。用户可以通过耳机来控制视频或音频的播放。接收响应者对象需要检查事件的子类型来确定命令(如播放,子类型为UIEventSubtypeRemoteControlPlay),然后进行相应处理。

为了响应远程控制事件,UIResponder提供了以下方法,

- (void)remoteControlReceivedWithEvent:(UIEvent *)event

我们可以在子类中实现该方法,来处理远程控制事件。不过,为了允许分发远程控制事件,我们必须调用UIApplication的beginReceivingRemoteControlEvents方法;而如果要关闭远程控制事件的分发,则调用endReceivingRemoteControlEvents方法。

在我们的应用中,经常会处理各种菜单命令,如文本输入框的”复制”、”粘贴”等。UIResponder为此提供了两个方法来支持此类操作。首先使用以下方法可以启动或禁用指定的命令:


- (BOOL)canPerformAction:(SEL)action withSender:(id)sender

该方法默认返回YES,我们的类可以通过某种途径处理这个命令,包括类本身或者其下一个响应者。子类可以重写这个方法来开启菜单命令。例如,如果我们希望菜单支持”Copy”而不支持”Paser”,则在我们的子类中实现该方法。需要注意的是,即使在子类中禁用某个命令,在响应链上的其它响应者也可能会处理这些命令。

另外,我们可以使用以下方法来获取可以响应某一行为的接收者:


- (id)targetForAction:(SEL)action withSender:(id)sender
在对象需要调用一个action操作时调用该方法。默认的实现是调用canPerformAction:withSender:方法来确定对象是否可以调用action操作。如果可以,则返回对象本身,否则将请求传递到响应链上。如果我们想要重写目标的选择方式,则应该重写这个方法。下面这段代码演示了一个文本输入域禁用拷贝/粘贴操作:

- (id)targetForAction:(SEL)action withSender:(id)sender{    UIMenuController *menuController = [UIMenuController sharedMenuController]; 
if (action == @selector(selectAll:) || action == @selector(paste:) ||action == @selector(copy:) || action == @selector(cut:)) {
if (menuController) {
[UIMenuController sharedMenuController].menuVisible = NO;
}
return nil;
}
return [super targetForAction:action withSender:sender];
}

结语

以我测试代码结束,应该是不错的

- (void)viewDidLoad {
[super viewDidLoad];

label=[[UILabel alloc]initWithFrame:CGRectMake(100, 100, SCREEN_WIDTH-200, 100)];
label.text=@"开始你几十次那就瞌睡虫你桑蚕丝次数次时间长是程思佳传送才加上就程思佳测试才加上产品搜救出平时";
label.numberOfLines=0;
label.userInteractionEnabled = YES;
[self.view addSubview:label];
[label addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(labelClick)]];

}
-(void)labelClick{
NSLog(@"测试");
UIMenuController *menuController = [UIMenuController sharedMenuController];
menuController.menuItems = @[ [[UIMenuItem alloc] initWithTitle:@"顶" action:@selector(ding:)], [[UIMenuItem alloc] initWithTitle:@"回复" action:@selector(reply:)], [[UIMenuItem alloc] initWithTitle:@"举报" action:@selector(warn:)] ];
// 菜单最终显示的位置
[menuController setTargetRect:label.bounds inView:label];
// 显示菜单
[menuController setMenuVisible:YES animated:YES];
}
//让Label具备成为第一响应者的资格(默认为NO),需手动开启
- (BOOL)canBecomeFirstResponder{
return YES;
}
-(void)ding:(id)send{
NSLog(@"ding");
}
-(void)reply:(id)send{
NSLog(@"reply");
}
-(void)warn:(id)send{
NSLog(@"warn");
}
////判断当前的方法,是否显示需要显示的项
//- (BOOL)canPerformAction:(SEL)action withSender:(id)sender{
// if ( (action == @selector(copy:) && label.text)
// ||(action == @selector(cut:) && label.text) || action == @selector(paste:) || action == @selector(ding:) || action == @selector(reply:) || action == @selector(warn:)){
// return YES;
// }
// return NO;
//
//}
//方法来获取可以响应某一行为的接收者:
- (id)targetForAction:(SEL)action withSender:(id)sender{ UIMenuController *menuController = [UIMenuController sharedMenuController];
if (action == @selector(selectAll:) || action == @selector(paste:) ||action == @selector(copy:) || action == @selector(cut:)) {
if (menuController) {
[UIMenuController sharedMenuController].menuVisible = NO;
}
return nil;
}
return [super targetForAction:action withSender:sender];
}


作者:默默的前行
链接:https://www.jianshu.com/p/698e3c1b0d3d

收起阅读 »

Runloop一定要记得的面试题

1.Runloop 和线程的关系?一个线程对应一个 Runloop。主线程的默认就有了 Runloop。子线程的 Runloop 以懒加载的形式创建。Runloop 存储在一个全局的可变字典里,线程是 key ,Runloop 是 value。2.RunLoo...
继续阅读 »

1.Runloop 和线程的关系?

一个线程对应一个 Runloop。
主线程的默认就有了 Runloop。
子线程的 Runloop 以懒加载的形式创建。
Runloop 存储在一个全局的可变字典里,线程是 key ,Runloop 是 value。

2.RunLoop的运行模式

RunLoop的运行模式共有5种,RunLoop只会运行在一个模式下,要切换模式,就要暂停当前模式,重写启动一个运行模式

- kCFRunLoopDefaultMode, App的默认运行模式,通常主线程是在这个运行模式下运行
- UITrackingRunLoopMode, 跟踪用户交互事件(用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他Mode影响)
- kCFRunLoopCommonModes, 伪模式,不是一种真正的运行模式
- UIInitializationRunLoopMode:在刚启动App时第进入的第一个Mode,启动完成后就不再使用
- GSEventReceiveRunLoopMode:接受系统内部事件,通常用不到

3.runloop内部逻辑?

实际上 RunLoop 就是这样一个函数,其内部是一个 do-while 循环。当你调用 CFRunLoopRun() 时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。




内部逻辑:

如果是 Timer 事件,处理 Timer 并重新启动循环,跳到第 2 步

如果输入源被触发,处理该事件(文档上是 deliver the event)

如果 RunLoop 被手动唤醒但尚未超时,重新启动循环,跳到第 2 步

事件到达基于端口的输入源(port-based input sources)(也就是 Source0)

Timer 到时间执行

外部手动唤醒

为 RunLoop 设定的时间超时

通知 Observer 已经进入了 RunLoop

通知 Observer 即将处理 Timer

通知 Observer 即将处理非基于端口的输入源(即将处理 Source0)

处理那些准备好的非基于端口的输入源(处理 Source0)

如果基于端口的输入源准备就绪并等待处理,请立刻处理该事件。转到第 9 步(处理 Source1)

通知 Observer 线程即将休眠

将线程置于休眠状态,直到发生以下事件之一

通知 Observer 线程刚被唤醒(还没处理事件)

处理待处理事件

4.autoreleasePool 在何时被释放?

App启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是 _wrapRunLoopWithAutoreleasePoolHandler()。

第一个 Observer 监视的事件是 Entry(即将进入Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。其 order 是 -2147483647,优先级最高,保证创建释放池发生在其他所有回调之前。

第二个 Observer 监视了两个事件:BeforeWaiting(准备进入休眠) 时调用_objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池;Exit(即将退出Loop) 时调用 _objc_autoreleasePoolPop() 来释放自动释放池。这个 Observer 的 order 是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。

在主线程执行的代码,通常是写在诸如事件回调、Timer回调内的。这些回调会被 RunLoop 创建好的 AutoreleasePool 环绕着,所以不会出现内存泄漏,开发者也不必显示创建 Pool 了。

5. GCD 在Runloop中的使用?

GCD由 子线程 返回到 主线程,只有在这种情况下才会触发 RunLoop。会触发 RunLoop 的 Source 1 事件。

6.AFNetworking 中如何运用 Runloop?

AFURLConnectionOperation 这个类是基于 NSURLConnection 构建的,其希望能在后台线程接收 Delegate 回调。
为此 AFNetworking 单独创建了一个线程,并在这个线程中启动了一个 RunLoop:


+ (void)networkRequestThreadEntryPoint:(id)__unused object {
@autoreleasepool {
[[NSThread currentThread] setName:@"AFNetworking"];
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode];
[runLoop run];
}
}

+ (NSThread *)networkRequestThread {
static NSThread *_networkRequestThread = nil;
static dispatch_once_t oncePredicate;
dispatch_once(&oncePredicate, ^{
_networkRequestThread = [[NSThread alloc] initWithTarget:self selector:@selector(networkRequestThreadEntryPoint:) object:nil];
[_networkRequestThread start];
});
return _networkRequestThread;
}
RunLoop 启动前内部必须要有至少一个 Timer/Observer/Source,所以 AFNetworking 在 [runLoop run] 之前先创建了一个新的 NSMachPort 添加进去了。
通常情况下,调用者需要持有这个 NSMachPort (mach_port) 并在外部线程通过这个 port 发送消息到 loop 内;但此处添加 port 只是为了让 RunLoop 不至于退出,并没有用于实际的发送消息。

- (void)start {
[self.lock lock];
if ([self isCancelled]) {
[self performSelector:@selector(cancelConnection) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
} else if ([self isReady]) {
self.state = AFOperationExecutingState;
[self performSelector:@selector(operationDidStart) onThread:[[self class] networkRequestThread] withObject:nil waitUntilDone:NO modes:[self.runLoopModes allObjects]];
}
[self.lock unlock];
}

当需要这个后台线程执行任务时,AFNetworking 通过调用 [NSObject performSelector:onThread:..] 将这个任务扔到了后台线程的 RunLoop 中。

7. PerformSelector 的实现原理?

当调用 NSObject 的 performSelecter:afterDelay: 后,实际上其内部会创建一个 Timer 并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。

当调用 performSelector:onThread: 时,实际上其会创建一个 Timer 加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。

8.PerformSelector:afterDelay:这个方法在子线程中是否起作用?

不起作用,子线程默认没有 Runloop,也就没有 Timer。可以使用 GCD的dispatch_after来实现

9.事件响应的过程?

苹果注册了一个 Source1 (基于 mach port 的) 用来接收系统事件,其回调函数为 __IOHIDEventSystemClientQueueCallback()。

当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent 事件并由 SpringBoard 接收。这个过程的详细情况可以参考这里。SpringBoard 只接收按键(锁屏/静音等),触摸,加速,接近传感器等几种 Event,随后用 mach port 转发给需要的 App 进程。随后苹果注册的那个 Source1 就会触发回调,并调用 _UIApplicationHandleEventQueue() 进行应用内部的分发。

_UIApplicationHandleEventQueue() 会把 IOHIDEvent 处理并包装成 UIEvent 进行处理或分发,其中包括识别 UIGesture/处理屏幕旋转/发送给 UIWindow 等。通常事件比如 UIButton 点击、touchesBegin/Move/End/Cancel 事件都是在这个回调中完成的。

10.手势识别的过程?

当 _UIApplicationHandleEventQueue() 识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin/Move/End 系列回调打断。随后系统将对应的 UIGestureRecognizer 标记为待处理。

苹果注册了一个 Observer 监测 BeforeWaiting (Loop即将进入休眠) 事件,这个 Observer 的回调函数是 _UIGestureRecognizerUpdateObserver(),其内部会获取所有刚被标记为待处理的 GestureRecognizer,并执行GestureRecognizer 的回调。

当有 UIGestureRecognizer 的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。

11.CADispalyLink和Timer哪个更精确?

iOS设备的屏幕刷新频率是固定的,CADisplayLink在正常情况下会在每次刷新结束都被调用,精确度相当高。

NSTimer的精确度就显得低了点,比如NSTimer的触发时间到的时候,runloop如果在阻塞状态,触发时间就会推迟到下一个runloop周期。并且 NSTimer新增了tolerance属性,让用户可以设置可以容忍的触发的时间的延迟范围。

CADisplayLink使用场合相对专一,适合做UI的不停重绘,比如自定义动画引擎或者视频播放的渲染。NSTimer的使用范围要广泛的多,各种需要单次或者循环定时处理的任务都可以使用。

在UI相关的动画或者显示内容使用 CADisplayLink比起用NSTimer的好处就是我们不需要在格外关心屏幕的刷新频率了,因为它本身就是跟屏幕刷新同步的。

补充

为什么子线程RunLoop需要手动开启

RunLoop是iOS中处理循环事件以及管理和处理消息的对象,通过在runloop中注册不同的观察者对象和回调处理来处理source0和source1事件。通过简单的的示例我们来观察一下为什么子线程RunLoop需要手动开启。


- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSLog(@"1");
[self performSelector:@selector(testPerformSelector) withObject:nil afterDelay:2];
NSLog(@"2");
});
}

- (void)testPerformSelector {
NSLog(@"3");
}

我们运行一下可看到如下打印效果 :
1 2
如果我们在子线程中获取该线程中的runloop,我们在看下打印结果

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSLog(@"1");
[self performSelector:@selector(testPerformSelector) withObject:nil afterDelay:2];
[[NSRunLoop currentRunLoop] run];
NSLog(@"2");
});
}

- (void)testPerformSelector {
NSLog(@"3");
}

输出结果如下:
1 3 2
如同我们所知道的那样,performSelector:withObject:afterDelay:是在当前runloop中注册了一个Timer事件,但是当前线程并没有处理该Timer事件,结合runloop源码我们探索一下


CFRunLoopRef CFRunLoopGetCurrent(void) {
CHECK_FOR_FORK();
CFRunLoopRef rl = (CFRunLoopRef)_CFGetTSD(__CFTSDKeyRunLoop);
if (rl) return rl;
return _CFRunLoopGet0(pthread_self());
}

_CF_EXPORT CFRunLoopRef _CFRunLoopGet0(pthread_t t) {
_CFUnlock(&loopsLock);
// __CFRunLoops是个CFMutableDictionaryRef类型的全局变量,用来保存RunLoop。这里首先判断有没有这个全局变量,如果没有就新创建一个这个全局变量,并同时创建一个主线程对应的runloop
CFMutableDictionaryRef dict = CFDictionaryCreateMutable(kCFAllocatorSystemDefault, 0, NULL, &kCFTypeDictionaryValueCallBacks);
// 创建一个主线程对应的runloop。
CFRunLoopRef mainLoop = __CFRunLoopCreate(pthread_main_thread_np());
// 保存主线程
CFDictionarySetValue(dict, pthreadPointer(pthread_main_thread_np()), mainLoop);
if (!OSAtomicCompareAndSwapPtrBarrier(NULL, dict, (void * volatile *)&__CFRunLoops)) {
CFRelease(dict);
}
CFRelease(mainLoop);
__CFLock(&loopsLock);
}
// 根据线程取其对应的runloop
CFRunLoopRef loop = (CFRunLoopRef)CFDictionaryGetValue(__CFRunLoops, pthreadPointer(t));
__CFUnlock(&loopsLock);
// 如果这个线程没有对应的runloop,就新建立一个runloop对象
if (!loop) {
//RunLoop 懒加载的方式!!!
CFRunLoopRef newLoop = __CFRunLoopCreate(t);
__CFLock(&loopsLock);
loop = (CFRunLoopRef)CFDictionaryGetValue(__CFRunLoops, pthreadPointer(t));
if (!loop) {
CFDictionarySetValue(__CFRunLoops, pthreadPointer(t), newLoop);
loop = newLoop;
}
// don't release run loops inside the loopsLock, because CFRunLoopDeallocate may end up taking it
__CFUnlock(&loopsLock);
CFRelease(newLoop);
}
if (pthread_equal(t, pthread_self())) {
_CFSetTSD(__CFTSDKeyRunLoop, (void *)loop, NULL);
if (0 == _CFGetTSD(__CFTSDKeyRunLoopCntr)) {
// 注册一个回调,当线程销毁时一同销毁对应的runloop
_CFSetTSD(__CFTSDKeyRunLoopCntr, (void *)(PTHREAD_DESTRUCTOR_ITERATIONS-1), (void (*)(void *))__CFFinalizeRunLoop);
}
}
return loop;
}


当调用 [NSRunloop currentLoop]的时候,会将当前线程的指针传入_CFRunLoopGet0函数中,会将当前线程和当前线程对应的runloop对象保存在一个全局的容器里面,如果当前线程没有runloop,会使用懒加载的方式创建一个runloop,并保存到全局的容器中,这也就解释了为什么子线程的runloop需要手动的获取,而不是默认开启的了,并且每个子线程和该线程下的runloop是一一对应的关系。


作者:Silence_xl
链接:https://www.jianshu.com/p/38eef7adda94



收起阅读 »

iOS 事件传递和视图响应链

1、事件的分类multitouch events:所谓的多点触摸事件,即用户触摸屏幕交互产生的事件类型;motion events:所谓的移动事件。是指用户在摇晃、移动和倾斜手机的时候产生的事件称为移动事件。这类事件依赖于iPhone手机里边的加速器,陀螺仪等...
继续阅读 »

1、事件的分类

multitouch events:所谓的多点触摸事件,即用户触摸屏幕交互产生的事件类型;
motion events:所谓的移动事件。是指用户在摇晃、移动和倾斜手机的时候产生的事件称为移动事件。这类事件依赖于iPhone手机里边的加速器,陀螺仪等传感器;
remote control events:所谓的远程控制事件。指的是用户在操作多媒体的时候产生的事件。比如,播放音乐,视频等。

2、触摸事件

UIEvent
iOS将触摸事件定义第一个手指开始触摸屏幕到最后一个手指离开屏幕为一个触摸事件。用类UIEvent表示。
UITouch
一个手指第一次点屏幕,会形成一个UITouch对象,知道离开销毁,表示触碰。UITouch对象能表明了当前手指触碰屏幕的位置,状态。状态分为开始触碰,移动和离开。
根据定义,UIEvent实际包括了多个UITouch对象。有几个手指触碰,就会有几个UITouch对象。代码定义如下:

typedef NS_ENUM(NSInteger, UIEventType) {
UIEventTypeTouches,
UIEventTypeMotion,
UIEventTypeRemoteControl,
UIEventTypePresses API_AVAILABLE(ios(9.0)),
};
@interface UIEvent : NSObject

@property(nonatomic,readonly) UIEventType type API_AVAILABLE(ios(3.0));
@property(nonatomic,readonly) UIEventSubtype subtype API_AVAILABLE(ios(3.0));

@property(nonatomic,readonly) NSTimeInterval timestamp;

@property(nonatomic, readonly, nullable) NSSet <UITouch *> *allTouches;

- (nullable NSSet <UITouch *> *)touchesForWindow:(UIWindow *)window;
- (nullable NSSet <UITouch *> *)touchesForView:(UIView *)view;
- (nullable NSSet <UITouch *> *)touchesForGestureRecognizer:(UIGestureRecognizer *)gesture API_AVAILABLE(ios(3.2));

@end
其中UIEventType表明了事件类型,UIEvent表示了三大事件。
allTouches是该事件所有UITouch对象的集合。

typedef NS_ENUM(NSInteger, UITouchPhase) {
UITouchPhaseBegan, // whenever a finger touches the surface.
UITouchPhaseMoved, // whenever a finger moves on the surface.
UITouchPhaseStationary, // whenever a finger is touching the surface but hasn't moved since the previous event.
UITouchPhaseEnded, // whenever a finger leaves the surface.
UITouchPhaseCancelled, // whenever a touch doesn't end but we need to stop tracking (e.g. putting device to face)
};

@interface UITouch : NSObject

@property(nonatomic,readonly) NSTimeInterval timestamp;
@property(nonatomic,readonly) UITouchPhase phase;
@property(nonatomic,readonly) NSUInteger tapCount; // touch down within a certain point within a certain amount of time
@property(nonatomic,readonly) UITouchType type API_AVAILABLE(ios(9.0));

// majorRadius and majorRadiusTolerance are in points
// The majorRadius will be accurate +/- the majorRadiusTolerance
@property(nonatomic,readonly) CGFloat majorRadius API_AVAILABLE(ios(8.0));
@property(nonatomic,readonly) CGFloat majorRadiusTolerance API_AVAILABLE(ios(8.0));

@property(nullable,nonatomic,readonly,strong) UIWindow *window;
@property(nullable,nonatomic,readonly,strong) UIView *view;
@property(nullable,nonatomic,readonly,copy) NSArray <UIGestureRecognizer *> *gestureRecognizers API_AVAILABLE(ios(3.2));

UITouch中phase表明了手指移动的状态,包括 1.开始点击;2.移动;3.保持; 4.离开;5.被取消(手指没有离开屏幕,但是系统不再跟踪它了)

综上,UIEvent就是一组UITouch。每当该组中任何一个UITouch对象的phase发生变化,系统都会产生一条TouchMessage。也就是说每次用户手指的移动和变化,UITouch都会形成状态改变,系统变回会形成Touch message进行传递和派发。

3、Responder

Responder是用来接收和处理事件的类。Responder的属性和方法

@property(nonatomic, readonly, nullable) UIResponder *nextResponder;

@property(nonatomic, readonly) BOOL canBecomeFirstResponder; // default is NO
- (BOOL)becomeFirstResponder;

@property(nonatomic, readonly) BOOL canResignFirstResponder; // default is YES
- (BOOL)resignFirstResponder;

@property(nonatomic, readonly) BOOL isFirstResponder;

// Generally, all responders which do custom touch handling should override all four of these methods.
// Your responder will receive either touchesEnded:withEvent: or touchesCancelled:withEvent: for each
// touch it is handling (those touches it received in touchesBegan:withEvent:).
// *** You must handle cancelled touches to ensure correct behavior in your application. Failure to
// do so is very likely to lead to incorrect behavior or crashes.
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(nullable UIEvent *)event;
- (void)touchesEstimatedPropertiesUpdated:(NSSet<UITouch *> *)touches API_AVAILABLE(ios(9.1));

// Generally, all responders which do custom press handling should override all four of these methods.
// Your responder will receive either pressesEnded:withEvent or pressesCancelled:withEvent: for each
// press it is handling (those presses it received in pressesBegan:withEvent:).
// pressesChanged:withEvent: will be invoked for presses that provide an analog value
// (like thumbsticks or analog push buttons)
// *** You must handle cancelled presses to ensure correct behavior in your application. Failure to
// do so is very likely to lead to incorrect behavior or crashes.
- (void)pressesBegan:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)pressesChanged:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)pressesEnded:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));
- (void)pressesCancelled:(NSSet<UIPress *> *)presses withEvent:(nullable UIPressesEvent *)event API_AVAILABLE(ios(9.0));

- (void)motionBegan:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0));
- (void)motionEnded:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0));
- (void)motionCancelled:(UIEventSubtype)motion withEvent:(nullable UIEvent *)event API_AVAILABLE(ios(3.0));

注意有个很中重要的方法,nextResponder,表明响应是一个链表结构,通过nextResponder找到下一个responder。这里是从第一个responder开始通过nextResponder传递事件,直到有responder响应了事件就停止传递;如果传递到最后一个responder都没有被响应,那么该事件就被抛弃。

注意:UIResponser包括了各种Touch message的处理,比如说开始,移动,停止等等。常见的UIResponser有UIView及子类。UIApplication、UIWindow、UIViewController、UIView都是继承UIResponder,都可以传递和响应事件。

程序启动
UIApplication会生成一个单例,并会关联一个APPDelegate。APPDelegate作为整个响应链的根建立起来,而UIApplication会将自己与这个单例链接,即UIApplication的nextResponder(下一个事件处理者)为APPDelegate
创建UIWindow
程序启动后,任何的UIWindow被创建时,UIWindow内部都会把nextResponser设置为UIApplication单例。
UIWindow初始化rootViewController, rootViewController的nextResponser会设置为UIWindow
UIViewController初始化
loadView, VC的view的nextResponser会被设置为VC.
addSubView
addSubView操作过程中,如果子subView不是VC的View,那么subView的nextResponser会被设置为superView。如果是VC的View,那就是 subView -> subView.VC ->superView

4、事件的传递

4.1 事件传递的流程

触摸事件的传递是从父控件传递到子控件
也就是从UIApplicaiton->window->寻找处理事件的最合适的view
注意:如果父控件不能接受触摸事件,那么子控件就不可能接收到触摸事件。

4.2 如何寻找最合适的控件来处理事件

①.首先判断主窗口(keyWindou)自己是否能接受触摸事件
②.判断触摸点是否在自己身上
③.子控件数组中从后往前遍历子控件,重复前面两个步骤(所谓从后往前遍历子控件,就是首先查找子控件数组中最后一个元素,然后执行1、2步骤)
④.如果没有符合条件的子控件,那么就认为自己最合适处理这个事件,也就是自己是最合适的view。

4.3 两个重要的方法
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event;
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event;

view会调用hitTest:withEvent:方法,hitTest:withEvent:方法底层会调用pointInside:withEvent:方法判断触摸点是不是在这个view的坐标上。如果在坐标上,会分发事件给这个view的子view。然后每个子view重复以上步骤,直至最底层的一个合适的view。

UIView不能接收触摸事件的三种情况:

不允许交互:userInteractionEnabled = NO
隐藏:如果把父控件隐藏,那么子控件也会隐藏,隐藏的控件不能接受事件
透明度:如果设置一个控件的透明度<0.01,会直接影响子控件的透明度。alpha:0.0~0.01为透明。
整个过程的系统实现大致如下



- (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event {
//判断是否合格
if (!self.hidden && self.alpha > 0.01 && self.isUserInteractionEnabled) {
//判断点击位置是否在自己区域内部
if ([self pointInside: point withEvent:event]) {
UIView *attachedView;
for (int i = self.subviews.count - 1; i >= 0; i--) {
UIView *view = self.subviews[i];
//对子view进行hitTest
attachedView = [view hitTest:point withEvent:event];
if (attachedView)
break;
}
if (attachedView) {
return attachedView;
} else {
return self;
}
}
}
return nil;
}


5、事件的响应

5.1 触摸事件处理的整体过程

1 用户点击屏幕后产生的一个触摸事件,经过一系列的传递过程后,会找到最合适的视图控件来处理这个事件
2 找到最合适的视图控件后,就会调用控件的touches方法来作具体的事件处理touchesBegan…touchesMoved…touchedEnded…
3 这些touches方法的默认做法是将事件顺着响应者链条向上传递(也就是touch方法默认不处理事件,只传递事件),将事件交给上一个响应者进行处理

5.2 响应者链条

在iOS程序中无论是最后面的UIWindow还是最前面的某个按钮,它们的摆放是有前后关系的,一个控件可以放到另一个控件上面或下面,那么用户点击某个控件时是触发上面的控件还是下面的控件呢,这种先后关系构成一个链条就叫响应者链。也可以说,响应者链是由多个响应者对象连接起来的链条。

事件响应会先从底层最合适的view开始,然后随着上一步找到的链一层一层响应touch事件。默认touch事件会传递给上一层。如果到了viewcontroller的view,就会传递给viewcontroller。如果viewcontroller不能处理,就会传递给UIWindow。如果UIWindow无法处理,就会传递给UIApplication。如果UIApplication无法处理,就会传递给UIApplicationDelegate。如果UIApplicationDelegate不能处理,则会丢弃该事件。



作者:Silence_xl
链接:https://www.jianshu.com/p/1436d54c8c89

收起阅读 »

UIButton的图文排列

图文结合通过 setTitle:forState: 和 setImage:forState: 这两个方法设置了 UIButton 的 标题和图片之后,可以通过以下两个属性访问代表 UIBut...
继续阅读 »

图文结合

通过 setTitle:forState: 和 setImage:forState: 这两个方法设置了 UIButton 的 标题和图片之后,可以通过以下两个属性访问代表 UIButton 中标题和图片的两个子控件:

@property(nullable, nonatomic,readonly,strong) UILabel     *titleLabel;
@property(nullable, nonatomic,readonly,strong) UIImageView *imageView;

UIControlContentVerticalAlignment

UIControlContentVerticalAlignment控制的是UIButtonimagetitle在竖直方向的对齐方式,其值有topbottomcenterfill。当指定为fill时,图片会在竖直方向被拉伸填满UIButton的高度。


// UIButton的image和title在竖直方向的对齐方式
typedef NS_ENUM(NSInteger, UIControlContentVerticalAlignment) {
UIControlContentVerticalAlignmentCenter = 0,
UIControlContentVerticalAlignmentTop = 1,
UIControlContentVerticalAlignmentBottom = 2,
UIControlContentVerticalAlignmentFill = 3,
};

UIControlContentHorizontalAlignment

UIControlContentHorizontalAlignment控制的则是水平方向的对齐方式。其值有leftrightcenterfill。当指定为fill时,图片并没有在水平方向将UIButton充满,而是在右侧留出了一定距离,这个距离应该是title的宽度,但是title实际上也没有乖乖的跑到那段空隙去,而是和image重叠了


typedef NS_ENUM(NSInteger, UIControlContentHorizontalAlignment) {
UIControlContentHorizontalAlignmentCenter = 0,
UIControlContentHorizontalAlignmentLeft = 1,
UIControlContentHorizontalAlignmentRight = 2,
UIControlContentHorizontalAlignmentFill = 3,
};

  • UIEdgeInsets

使用 insets 来添加或删除你自定义(或系统的)按钮内容周围空间,你可以单独为按钮标题(titleEdgeInsets)、图片(imageEdgeInsets)或同时为标题和图片(contentEdgeInsets)指定 insets 值。应用时,insets 影响了按钮的相应内容矩形,由Auto Layout引擎用于确定按钮的位置。


@property(nonatomic)          UIEdgeInsets contentEdgeInsets;           
@property(nonatomic) UIEdgeInsets titleEdgeInsets;
@property(nonatomic) UIEdgeInsets imageEdgeInsets;

默认按钮布局

创建一个图片+标题的按钮:

// 创建自定义类型按钮
UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
button.frame = CGRectMake(80, 180, 150, 150);
// 设置按钮的背景颜色
button.backgroundColor = [UIColor honeydewColor];
// 设置按钮的标签文字
[button setTitle:@"Tap it" forState:UIControlStateNormal];
button.titleLabel.backgroundColor = [UIColor skyBlueColor];
// 设置按钮图片
UIImage *image = [UIImage imageNamed:@"buttonImage"];
[button setImage:image forState:UIControlStateNormal];
[self.view addSubview:button];

默认情况下:图片在左边而文字在右边,而且整体水平和垂直居中

这是一个 UIButton 范畴(Category) 类,用于处理图文结合

  • UIButton+ImageTitleStyle.h 文件
#import <UIKit/UIKit.h>

@interface UIButton (ImageTitleStyle)

//上下居中,图片在上,文字在下
- (void)verticalCenterImageAndTitle:(CGFloat)spacing;
- (void)verticalCenterImageAndTitle; //默认6.0

//左右居中,文字在左,图片在右
- (void)horizontalCenterTitleAndImage:(CGFloat)spacing;
- (void)horizontalCenterTitleAndImage; //默认6.0

//左右居中,图片在左,文字在右
- (void)horizontalCenterImageAndTitle:(CGFloat)spacing;
- (void)horizontalCenterImageAndTitle; //默认6.0

//文字居中,图片在左边
- (void)horizontalCenterTitleAndImageLeft:(CGFloat)spacing;
- (void)horizontalCenterTitleAndImageLeft; //默认6.0

//文字居中,图片在右边
- (void)horizontalCenterTitleAndImageRight:(CGFloat)spacing;
- (void)horizontalCenterTitleAndImageRight; //默认6.0

@end

  • UIButton+ImageTitleStyle.m 文件
#import "UIButton+ImageTitleStyle.h"

@implementation UIButton (ImageTitleStyle)

#pragma mark - 上下居中,图片在上,文字在下
- (void)verticalCenterImageAndTitle:(CGFloat)spacing
{
// get the size of the elements here for readability
CGSize imageSize = self.imageView.frame.size;
CGSize titleSize = self.titleLabel.frame.size;

// lower the text and push it left to center it
self.titleEdgeInsets = UIEdgeInsetsMake(0.0, - imageSize.width, - (imageSize.height + spacing/2), 0.0);

// the text width might have changed (in case it was shortened before due to
// lack of space and isn't anymore now), so we get the frame size again
titleSize = self.titleLabel.frame.size;

// raise the image and push it right to center it
self.imageEdgeInsets = UIEdgeInsetsMake(- (titleSize.height + spacing/2), 0.0, 0.0, - titleSize.width);
}

- (void)verticalCenterImageAndTitle
{
const int DEFAULT_SPACING = 6.0f;
[self verticalCenterImageAndTitle:DEFAULT_SPACING];
}


#pragma mark - 左右居中,文字在左,图片在右
- (void)horizontalCenterTitleAndImage:(CGFloat)spacing
{
// get the size of the elements here for readability
CGSize imageSize = self.imageView.frame.size;
CGSize titleSize = self.titleLabel.frame.size;

// lower the text and push it left to center it
self.titleEdgeInsets = UIEdgeInsetsMake(0.0, - imageSize.width, 0.0, imageSize.width + spacing/2);

// the text width might have changed (in case it was shortened before due to
// lack of space and isn't anymore now), so we get the frame size again
titleSize = self.titleLabel.frame.size;

// raise the image and push it right to center it
self.imageEdgeInsets = UIEdgeInsetsMake(0.0, titleSize.width + spacing/2, 0.0, - titleSize.width);
}

- (void)horizontalCenterTitleAndImage
{
const int DEFAULT_SPACING = 6.0f;
[self horizontalCenterTitleAndImage:DEFAULT_SPACING];
}

#pragma mark - 左右居中,图片在左,文字在右
- (void)horizontalCenterImageAndTitle:(CGFloat)spacing;
{
// get the size of the elements here for readability
// CGSize imageSize = self.imageView.frame.size;
// CGSize titleSize = self.titleLabel.frame.size;

self.titleEdgeInsets = UIEdgeInsetsMake(0.0, 0.0, 0.0, - spacing/2);
self.imageEdgeInsets = UIEdgeInsetsMake(0.0, - spacing/2, 0.0, 0.0);
}

- (void)horizontalCenterImageAndTitle;
{
const int DEFAULT_SPACING = 6.0f;
[self horizontalCenterImageAndTitle:DEFAULT_SPACING];
}

#pragma mark - 文字居中,图片在左边
- (void)horizontalCenterTitleAndImageLeft:(CGFloat)spacing
{
// get the size of the elements here for readability
// CGSize imageSize = self.imageView.frame.size;
// CGSize titleSize = self.titleLabel.frame.size;

self.imageEdgeInsets = UIEdgeInsetsMake(0.0, - spacing, 0.0, 0.0);
}

- (void)horizontalCenterTitleAndImageLeft
{
const int DEFAULT_SPACING = 6.0f;
[self horizontalCenterTitleAndImageLeft:DEFAULT_SPACING];
}

#pragma mark - 文字居中,图片在右边
- (void)horizontalCenterTitleAndImageRight:(CGFloat)spacing
{
// get the size of the elements here for readability
CGSize imageSize = self.imageView.frame.size;
CGSize titleSize = self.titleLabel.frame.size;

// lower the text and push it left to center it
self.titleEdgeInsets = UIEdgeInsetsMake(0.0, - imageSize.width, 0.0, 0.0);

// the text width might have changed (in case it was shortened before due to
// lack of space and isn't anymore now), so we get the frame size again
titleSize = self.titleLabel.frame.size;

// raise the image and push it right to center it
self.imageEdgeInsets = UIEdgeInsetsMake(0.0, titleSize.width + imageSize.width + spacing, 0.0, - titleSize.width);
}

- (void)horizontalCenterTitleAndImageRight
{
const int DEFAULT_SPACING = 6.0f;
[self horizontalCenterTitleAndImageRight:DEFAULT_SPACING];
}

1. 上下居中,图片在上,文字在下


[button verticalCenterImageAndTitle:10.0f];

2. 左右居中,文字在左,图片在右


[button horizontalCenterTitleAndImage:10.0f];

3. 左右居中,图片在左,文字在右

[button horizontalCenterImageAndTitle:10.0f];

通过布局子视图方法:

当 UIButton 是固定大小时,使用上面的方法无法设置按钮中的图片相对于整个按钮的大小。

  • 图片在上、文字在下
- (void) layoutSubviews {
[super layoutSubviews];
// 修改button内image和label的位置
self.imageView.y = self.height * 0.15;
self.imageView.width = self.width * 0.5;
self.imageView.height = self.imageView.width;
self.imageView.centerX = self.width * 0.5;

self.titleLabel.x = 0;
self.titleLabel.y = self.imageView.bottom;
self.titleLabel.width = self.width;
self.titleLabel.height = self.height - self.imageView.bottom;
}

显示/隐藏密码按钮

- (UIButton *)showPasswordButton {
if (!_showPasswordButton) {
_showPasswordButton = [UIButton buttonWithType:UIButtonTypeCustom];
[_showPasswordButton setImage:[UIImage imageNamed:@"login_pwd_hide"]
forState:UIControlStateNormal];
[_showPasswordButton setImage:[UIImage imageNamed:@"login_pwd_show"]
forState:UIControlStateSelected];
[_showPasswordButton setSelected:NO];
[_showPasswordButton addTarget:self
action:@selector(showPasswordButtonDidClicked:)
forControlEvents:UIControlEventTouchUpInside];
}
return _showPasswordButton;
}

- (void)showPasswordButtonDidClicked:(id)sender {
self.passwordTextField.secureTextEntry = !self.passwordTextField.secureTextEntry;
[self.showPasswordButton setSelected:!self.passwordTextField.secureTextEntry];
}



收起阅读 »

UIButton

对象继承关系UIButton 类本身定义继承 UIControl ,描述了在 iOS 上所有用户界面控件的常见基本行为。反过来, UIControl 类继承 UIView ,给在屏幕显示的对象提供常用功能。UIView 继承于 UIResponder,允许它响...
继续阅读 »

对象继承关系

UIButton 类本身定义继承 UIControl ,描述了在 iOS 上所有用户界面控件的常见基本行为。反过来, UIControl 类继承 UIView ,给在屏幕显示的对象提供常用功能。UIView 继承于 UIResponder,允许它响应用户输 入、手势。最后,也是最重要的,UIResponder 继承 NSObject,图 1-3 所示。




Documentation and API Reference

UIButton 对象是一个视图,它用于执行你的自定义代码以响应用户交互。

概述

当你触摸点击按钮或选择具有焦点的按钮时,该按钮就会执行被附加上的任何操作。 你可以使用文本标签,图像或两者同时使用来传达按钮的功能。 按钮的外观是可配置的,因此你可以使用色调按钮或格式化的标题来匹配应用程序的设计。 你可以通过编程方式或使用Interface Builder向界面添加按钮。


当添加一个按钮到你的界面上时,请执行以下步骤:

  • 创建按钮时,设置按钮的类型。
  • 设置标题或图片,并根据内容适当调整按钮的大小。
  • 为按钮设置一个或多个目标-动作(Target-Action)方法。
  • 设置自动布局(Auto Layout)规则来控制界面中按钮的大小和位置。
  • 提供辅助功能信息和本地化字符串。

响应按钮触摸事件

当用户触摸点击按钮时,按钮使用 Target-Action 设计模式监听APP。并不是直接处理触摸事件,你可以向按钮分配操作方法,并指定哪些事件触发对你的方法的调用。 在运行时,该按钮可处理所有传入的触摸事件并调用您的方法作为响应。

你使用 addTarget:action:forControlEvents: 方法连接按钮到动作方法上,或者在 Interface Builder 建立连接。动作方法的签名采用清单1中列出的三种形式之一。选择提供您需要响应按钮轻敲的信息的表单。

Listing 1 Action methods for buttons:

- (IBAction)doSomething;
- (IBAction)doSomething:(id)sender;
- (IBAction)doSomething:(id)sender forEvent:(UIEvent*)event;

配置按钮外观

按钮的类型决定了它的基本外观和行为,在创建按钮时,使用buttonWithType: 方法指定按钮的类型。创建按钮之后,你无法再更改它的类型了。最常用的类型是 UIButtonTypeSystem 、UIButtonTypeCustom (默认),请在合适时使用其它类型。

注意

要配置应用程序中所有按钮的外观,请使用外观代理对象。 UIButton 类实现了 appearance 类方法,您可以使用它来获取应用程序中所有按钮的外观代理。

按钮状态

按钮有五个状态定义其外观:default(默认),highlighted(高亮),focused(聚焦),selected(选中)和disabled(禁用)。 当您将一个按钮添加到界面时,它最初处于default(默认)状态,这意味着该按钮已启用(enabled),并且用户未与其进行交互。 当用户与按钮进行交互时,其状态将更改为其他值。 例如,当用户点击带有标题的按钮时,按钮将改变到 highlighted(高亮)状态。

以编程方式或在Interface Builder中配置按钮时,可以分别为每个状态指定属性。 在Interface Builder中,使用“属性”检查器中的“状态配置”控件选择适当的状态,然后配置其他属性。 如果不为特定状态指定属性,则 UIButton类提供合理的默认行为。 例如,禁用的按钮通常变暗,并且在轻拍时不显示高亮。 该类的其他属性,如adjustImageWhenHighlighted和 adjustImageWhenDisabled属性,可以在特定情况下更改默认行为。

内容

按钮的内容由你指定的标题字符串或图像组成。你指定的内容是用于配置被按钮所拥有的 UILabel 和 UIImageView 对象。你可以使用 titleLabel 或 imageView 属性配置这些对象,或者直接对它们赋值。按钮也有背景颜色,它位于您指定的内容后面。很可能同时指定按钮的图片和标题,它会导致下图的结果。你可以使用指定的属性访问按钮的当前内容。


当设置按钮的内容时,你必须指定每个状态下的标题、图片和外观属性。如果你不为某个特定状态自定义内容,该按钮使用与默认状态相关联的值,并添加任何适当的自定义配置。例如,在按钮高亮状态下,如果没有提供自定义图像,则基于图像的按钮将在默认图像的上方绘制高亮。

色调颜色

你可以通过 tintColor 属性指定自定义按钮的色调。该属性会设置按钮图片和文本的颜色。如果你没有明确设置色调,那么按钮使用它父类的色调。

边缘插入量

使用 insets 来添加或删除你自定义(或系统的)按钮内容周围空间,你可以单独为按钮标题(titleEdgeInsets)、图片(imageEdgeInsets)或同时为标题和图片(contentEdgeInsets)指定 insets 值。应用时,insets 影响了按钮的相应内容矩形,由Auto Layout引擎用于确定按钮的位置。

你应该没有理由去调整 info,contact 或指示器按钮的边缘插入量。

Interface Builder 属性

表一列出了 Interface Builder 中按钮配置的核心属性。

属性描述
Type按钮的类型。该属性决定了许多其他按钮属性的默认设置。该类型属性无法在运行时被改变,但是你可以使用 buttonType属性访问它。
State Config状态选择器。 在此控件中选择一个值后,按钮属性的更改将适用于指定的状态。
Title按钮的标题。你可以用纯字符串或属性字符串指定按钮的标题
(Title Font and Attributes)适用于按钮标题字符串的字体和其他属性。具体的配置选项取决于你对按钮的标题使用纯字符串还是属性字符串。对于纯字符串来说,你可以设置字体、文本颜色和阴影颜色。对于属性字符串来说,你可以指定对齐方式,文本方向,缩进,连字号等许多选项。
Image按钮的前景图片。通常来说,你可以使用模板图片指定按钮的前景,但是你可以在XCode 项目中指定任何图片。
Background按钮的背景图片。按钮的背景图片是展示在它的标题和前景图片后面的。

表2列出了影响按钮外观的属性

属性描述
Shadow Offset按钮阴影的偏移和行为。阴影只会影响标题字符串。 当按钮状态更改为或高亮状态时,启用“高亮状态”选项上的“反转”选项可更改阴影的高亮。 使用编程方式配置偏移量使用按钮的titleLabel 对象的 shadowOffset 属性。配置高亮行为使用 reversesTitleShadowWhenHighlighted 属性。
Drawing按钮的绘图行为。 当高亮时显示触摸 showsTouchWhenHighlighted 属性开启时, 该按钮向用户触摸的按钮的部分添加白色光泽。 当高亮时调整图片 adjustsImageWhenHighlighted 属性开启时。高亮状态时,安妮图片变暗。 当禁用时调整图片 adjustsImageWhenDisabled 属性开启时, 当按钮被禁用时,图像变暗。
Line Break按钮文本的打断显示选项。 使用此属性来定义按钮的标题如何修改以适应可用空间。

表3列出了按钮的边缘插入量属性。使用边缘插入按钮更改按钮内容的矩形。

属性描述
Edge边缘插入配置。 您可以为按钮的整体内容,其标题及其图像指定单独的边缘插入。
Inset插入值。 正值缩小相应的边缘,使其更接近按钮的中心。 负值展开边缘,将其从按钮的中心移开。运行时使用 contentEdgeInsetstitleEdgeInsets 和imageEdgeInsets属性访问这些值。

有关按钮的继承的 Interface Builde r属性的信息,参阅 UIControl 和 UIView

国际化

要使按钮国际化,请为按钮的标题文本指定一个本地化的字符串。 (您也可以根据需要定位按钮的图像。)

使用故事板构建界面时,请使用Xcode的基本国际化功能来配置项目支持的本地化。 当您添加本地化时,Xcode将为该本地化创建一个字符串文件。 以编程方式配置接口时,请使用系统的内置支持来加载本地化字符串和资源。

辅助功能

默认情况下可以使用按钮。 按钮的默认辅助功能特征是按钮和用户交互启用。

当在设备上启用 VoiceOver 时,可访问性标签,特征和提示将会发送给用户。 按钮的标题覆盖其无障碍标签; 即使您为标签设置了自定义值,VoiceOver会说出标题的值。 当用户点击按钮一次时,VoiceOver会显示此信息。 例如,当用户点击“相机”中的“选项”按钮时,VoiceOver会说出以下内容:

  • "选项.按钮.显示其他相机选项"


UIButton 的使用

初始化按钮

+ (instancetype)buttonWithType:(UIButtonType)buttonType;

UIButtonType

这是个枚举类型,用于指定按钮风格

UIButtonType描述
UIButtonTypeCustom无按钮风格。
UIButtonTypeSystem系统风格按钮,例如导航栏和工具栏中显示的那些。
UIButtonTypeDetailDisclosure详细说明按钮。
UIButtonTypeInfoLight浅色背景的信息按钮。
UIButtonTypeInfoDark深色背景的信息按钮
UIButtonTypeContactAdd添加按钮
UIButtonTypeRoundedRect = UIButtonTypeSystem失效)圆角矩形样式按钮

示例:

// UIButtonTypeCustom
UIButton *button1 = [UIButton buttonWithType:UIButtonTypeCustom];
button1.frame = CGRectMake(150, 50, 50, 50);
button1.backgroundColor = [UIColor turquoiseColor];
[button1 setTitle:@"button1" forState:UIControlStateNormal];
[self.view addSubview:button1];

// UIButtonTypeSystem
UIButton *button2 = [UIButton buttonWithType:UIButtonTypeSystem];
button2.frame = CGRectMake(150, 120, 50, 50);
[button2 setTitle:@"button2" forState:UIControlStateNormal];
[self.view addSubview:button2];

// UIButtonTypeDetailDisclosure
UIButton *button3 = [UIButton buttonWithType:UIButtonTypeDetailDisclosure];
button3.frame = CGRectMake(150, 190, 50, 50);
[self.view addSubview:button3];

// UIButtonTypeInfoLight
UIButton *button4 = [UIButton buttonWithType:UIButtonTypeInfoLight];
button4.frame = CGRectMake(150, 260, 50, 50);
[self.view addSubview:button4];

// UIButtonTypeInfoDark
UIButton *button5 = [UIButton buttonWithType:UIButtonTypeInfoDark];
button5.frame = CGRectMake(150, 330, 50, 50);
[self.view addSubview:button5];

// UIButtonTypeContactAdd
UIButton *button6 = [UIButton buttonWithType:UIButtonTypeContactAdd];
button6.frame = CGRectMake(150, 400, 50, 50);
[self.view addSubview:button6];

// UIButtonTypeRoundedRect = UIButtonTypeSystem
UIButton *button7 = [UIButton buttonWithType:UIButtonTypeRoundedRect];
button7.frame = CGRectMake(150, 470, 50, 50);
[button7 setTitle:@"button7" forState:UIControlStateNormal];
[self.view addSubview:button7];



设置按钮内容

// 设置标题,假定为单行。
- (void)setTitle:(NSString *)title forState:(UIControlState)state;

// 设置标题颜色,默认为不透明白色。
- (void)setTitleColor:(nullable UIColor *)color forState:(UIControlState)state;

// 设置标题阴影颜色,默认 50% 黑色。
- (void)setTitleShadowColor:(nullable UIColor *)color forState:(UIControlState)state;

// 设置图片,不同状态下的图片大小应该相同
- (void)setImage:(nullable UIImage *)image forState:(UIControlState)state;

// 设置背景图片
- (void)setBackgroundImage:(nullable UIImage *)image forState:(UIControlState)state;

// 设置标题
- (void)setAttributedTitle:(nullable NSAttributedString *)title forState:(UIControlState)state;

UIControlState

该类也是一个枚举类型,用于设置按钮状态

typedef NS_OPTIONS(NSUInteger, UIControlState) {
UIControlStateNormal = 0, // 正常状态
UIControlStateHighlighted = 1 << 0, // 高亮状态
UIControlStateDisabled = 1 << 1, // 禁用状态
UIControlStateSelected = 1 << 2, // 选中状态
UIControlStateFocused NS_ENUM_AVAILABLE_IOS(9_0) = 1 << 3, // 仅适用于屏幕支持对焦时(iOS新加入 应该和3D Touch有关)
UIControlStateApplication = 0x00FF0000, // 可用于应用程序使用的附加标志
UIControlStateReserved = 0xFF000000 // 标记保留供内部框架使用
};

示例代码:

// UIControlStateHighlighted
UIButton *button2 = [UIButton buttonWithType:UIButtonTypeSystem];
button2.frame = CGRectMake(150, 120, 100, 50);
[button2 setTitle:@"高亮状态" forState:UIControlStateHighlighted];
[button2 setHighlighted:YES];
[self.view addSubview:button2];

// UIControlStateDisabled
UIButton *button3 = [UIButton buttonWithType:UIButtonTypeSystem];
button3.frame = CGRectMake(150, 190, 100, 50);
[button3 setTitle:@"禁用状态" forState:UIControlStateDisabled];
[button3 setEnabled:NO];
[self.view addSubview:button3];

// UIControlStateSelected
UIButton *button4 = [UIButton buttonWithType:UIButtonTypeSystem];
button4.frame = CGRectMake(150, 260, 100, 50);
[button4 setTitle:@"选中状态" forState:UIControlStateSelected];
[button4 setSelected:YES];
[self.view addSubview:button4];



添加、移除点击事件


- (void)addTarget:(nullable id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents;

- (void)removeTarget:(nullable id)target action:(nullable SEL)action forControlEvents:(UIControlEvents)controlEvents;

使用示例:

[button1 addTarget:self
action:@selector(butClick:)
forControlEvents:UIControlEventTouchUpInside];

// ...

// 调用的方法,用来响应按钮的点击事件
- (void)butClick:(id)button {
// do something
}

UIControlEvents

UIControlEventTouchDown
单点触摸按下事件:用户点触屏幕,或者又有新手指落下的时候。

UIControlEventTouchDownRepeat
多点触摸按下事件,点触计数大于1:用户按下第二、三、或第四根手指的时候。

UIControlEventTouchDragInside
当一次触摸在控件窗口内拖动时。

UIControlEventTouchDragOutside
当一次触摸在控件窗口之外拖动时。

UIControlEventTouchDragEnter
当一次触摸从控件窗口之外拖动到内部时。

UIControlEventTouchDragExit
当一次触摸从控件窗口内部拖动到外部时。

UIControlEventTouchUpInside
所有在控件之内触摸抬起事,一般用于按钮。

UIControlEventTouchUpOutside
所有在控件之外触摸抬起事件(点触必须开始与控件内部才会发送通知)。

UIControlEventTouchCancel
所有触摸取消事件,即一次触摸因为放上了太多手指而被取消,或者被上锁或者电话呼叫打断。

UIControlEventTouchChanged
当控件的值发生改变时,发送通知。用于滑块、分段控件、以及其他取值的控件。你可以配置滑块控件何时发送通知,在滑块被放下时发送,或者在被拖动时发送。

UIControlEventEditingDidBegin
当文本控件中开始编辑时发送通知。

UIControlEventEditingChanged
当文本控件中的文本被改变时发送通知。

UIControlEventEditingDidEnd
当文本控件中编辑结束时发送通知。

UIControlEventEditingDidOnExit
当文本控件内通过按下回车键(或等价行为)结束编辑时,发送通知。

UIControlEventAlltouchEvents
通知所有触摸事件。

UIControlEventAllEditingEvents
通知所有关于文本编辑的事件。

UIControlEventAllEvents
通知所有事件。

userInteractionEnabled 与 enabled 的区别

self.btn1.enabled = NO;
self.btn1.userInteractionEnabled = NO;



相同点

这两个方法都可以将按钮设置为禁用状态,阻止接受用户触摸事件及键盘响应。

不同点

  • enabled = NO 会使按钮的状态变为UIControlStateDisabled,而userInteractionEnabled = NO 不会。
  • enabledUIControl的属性(虽然UIButtonUICotrol的子类,但是这两个方法都能用,因为UICotrol 又是 UIView 的子类),而userInteractionEnabledUIViewUIImageViewUILabel的属性。
  • 如果使用时,同时设置了这两个方法,那么,先设置的方法奏效。

关于 tintColor

tintColor 是 iOS7.0 引入的一个 UIView 的属性,该属性会设置按钮图片和文本的颜色。

tintColor 具有 继承重写传播 的特点。

  • 继承:只要一个 UIView 的 subview 没有明确指定 tintColor,那么这个 UIView 的 tintColor就会被它的 subview 所继承!在一个 App 中,最顶层的 view 就是 window,因此,只要修改 window 的 tintColor,那么所有 view 的 tintColor 就都会跟着改变。(这种说法其实并不严谨,请耐心继续看下去_)。

  • 重写:如果明确指定了某个 view 的 tintColor, 那么这个 view 就不会继承其 superview 的 tintColor。而且自此, 这个 view 的 subview 的 tintColor 会发生改变。

  • 传播:一个 view 的 tintColor 的改变会立即向下传播, 影响其所有的 subview,直至它的一个 subview 明确指定了 tintColor 为止。

    ——UIView并没想的那么简单 - tintColor揭秘

⚠️ 如果创建的是 UIButtonTypeCustom 类型的按钮,再去设置 tintColor 属性是无效的。


// 创建一个系统类型按钮
UIButton *button2 = [UIButton buttonWithType:UIButtonTypeSystem];
button2.frame = CGRectMake(80, 180, 180, 180);
// 设置按钮的背景颜色为蓝色
button2.backgroundColor = [UIColor skyBlueColor];
// 设置按钮的色调颜色为绿色
button2.tintColor = [UIColor seafoamColor];
// 设置按钮的标签文字
[button2 setTitle:@"Tap it" forState:UIControlStateNormal];
// 设置按钮图片
UIImage *image = [UIImage imageNamed:@"picture_button"];
[button2 setImage:image forState:UIControlStateNormal];
// 绑定按钮的点击动作
[button2 addTarget:self
action:@selector(butClick:)
forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:button2];



运行结果发现按钮的图片、文字都会变成绿色。

另外:

  • 如果我们想指定整个 APP 的 tint color,则可以通过设置 window 的 tint color。这样同一个window下的所有子视图都会继承此 tint color。
  • 如果你是用 storyboard 创建界面的, 那么只要在入口控制器的 File Inspector 中修改一下 Global Tint 即可。

设置圆角


代码实现:

button.clipsToBounds = YES;     // 如果子视图的范围超出了父视图的边界,那么超出的部分就会被裁剪掉
button.layer.cornerRadius = 5; //这里的5是你想设置的圆角大小,比如是一个40*40的正方形,那个设置成20就是一个圆,以此类推






代码实现:

// 创建一个按钮
UIButton *button3 = [UIButton buttonWithType:UIButtonTypeSystem];
button3.frame = CGRectMake(80, 450, 150, 30);
// 设置按钮的背景色
button3.backgroundColor = [UIColor icebergColor];
// 设置按钮的前景色
button3.tintColor = [UIColor skyBlueColor];
// 设置按钮的标签文字
[button3 setTitle:@"Tap it" forState:UIControlStateNormal];
// 给按钮添加边框效果
[button3.layer setMasksToBounds:YES];
// 设置层的圆角半径
[button3.layer setCornerRadius:5.0];
// 设置边框的宽度
[button3.layer setBorderWidth:2.0];
// 设置边框的颜色
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGColorRef colorRef = CGColorCreate(colorSpace, (CGFloat[]){ 56/255.0, 237/255.0, 56/255.0, 1 });
[button3.layer setBorderColor:colorRef];
[self.view addSubview:button3];

按钮高亮效果

需求如下:


代码实现:

UIButton *submitButton = [UIButton buttonWithType:UIButtonTypeCustom];
// 默认标题
NSDictionary *attributes = @{
NSFontAttributeName:[UIFont systemFontOfSize:18],
NSForegroundColorAttributeName:[UIColor whiteColor] };
NSAttributedString *title =[[NSAttributedString alloc] initWithString:@"提交" attributes:attributes];
[submitButton setAttributedTitle:title forState:UIControlStateNormal];
// 设置背景颜色
// 使用 YYKit 组件实现将颜色生成图片效果
[submitButton setBackgroundImage:[UIImage imageWithColor:HexColor(@"#108EE9")]
forState:UIControlStateNormal];
[submitButton setBackgroundImage:[UIImage imageWithColor:HexColor(@"#1284D6")]
forState:UIControlStateHighlighted];
// 设置圆角
submitButton.clipsToBounds = YES;
submitButton.layer.cornerRadius = 5;
[submitButton addTarget:self
action:@selector(submitInfoAction:)
forControlEvents:UIControlEventTouchUpInside];
// 将按钮添加到视图上
[self.view addSubview:submitButton];

效果展示:





小按钮高亮镂空效果

代码实现:

UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
// 默认标题
NSDictionary *attributes1 = @{
NSFontAttributeName:[UIFont systemFontOfSize:13],
NSForegroundColorAttributeName:HexColor(@"#108EE9")
};
NSAttributedString *normalTitle =[[NSAttributedString alloc] initWithString:@"示例" attributes:attributes1];
[button setAttributedTitle:normalTitle
forState:UIControlStateNormal];
// 高亮标题
NSDictionary *attributes2 = @{
NSFontAttributeName:[UIFont systemFontOfSize:13],
NSForegroundColorAttributeName:[UIColor whiteColor]
};
NSAttributedString *highlightedTitle = [[NSAttributedString alloc] initWithString:@"示例" attributes:attributes2];
[button setAttributedTitle:highlightedTitle
forState:UIControlStateHighlighted];
// 高亮背景颜色
[button setBackgroundImage:[UIImage imageWithColor:HexColor(@"#108EE9")]
forState:UIControlStateHighlighted];
[button.layer setCornerRadius:3];
[button.layer setMasksToBounds:YES];
[button.layer setBorderWidth:1];
[button.layer setBorderColor:[HexColor(@"#108EE9") CGColor]];
// Target-Action
[button addTarget:self
action:@selector(buttonClickUpHandler:)
forControlEvents:UIControlEventTouchUpInside];
// 将按钮添加到视图上
[self.view addSubview:button];




收起阅读 »

UITableView总结

基本介绍UITableView有两种风格:UITableViewStylePlain和UITableViewStyleGrouped。UITableView中只有行的概念,每一行就是一个UITableViewCell。下图是UITableViewCell内置好...
继续阅读 »

基本介绍

UITableView有两种风格:UITableViewStylePlainUITableViewStyleGrouped


UITableView中只有行的概念,每一行就是一个UITableViewCell。下图是UITableViewCell内置好的控件,可以看见contentView控件作为其他元素的父控件、两个UILabel控件(textLabel,detailTextLabel),一个UIImage控件(imageView),分别用于容器、显示内容、详情和图片。



typedef NS_ENUM(NSInteger, UITableViewCellStyle) {
UITableViewCellStyleDefault,
UITableViewCellStyleValue1,
UITableViewCellStyleValue2,
UITableViewCellStyleSubtitle
};
风格如下:
1⃣️左侧显示textLabel,不显示detailTextLabel,imageView可选(显示在最左边)
2⃣️左侧显示textLabel,右侧显示detailTextLabel,imageView可选(显示在最左边)
3⃣️左侧依次显示textLabel和detailTextLabel,不显示imageView
4⃣️左上方显示textLabel,左下方显示detailTextLabel,imageView可选(显示在最左边)
下面依次为四种风格示例:



一般UITableViewCell的风格各种各样,需要自定义cell

代理方法、数据源方法

<UITableViewDelegate,UITableViewDataSource>

//有多少组(默认为1)
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView{
return 5;
}
//每组显示多少行cell数据
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
return 5;
}
//cell内容设置,属性设置
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
static NSString *identifily = @"cellIdentifily";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifily];
if (cell == nil) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue2 reuseIdentifier:identifily];
}
cell.textLabel.text = [NSString stringWithFormat:@"textLabel.text %ld",indexPath.row];
cell.detailTextLabel.text = [NSString stringWithFormat:@"detailTextLabel.text %ld",indexPath.row];
cell.imageView.image = [UIImage imageNamed:@"hello.jpg"];
cell.imageView.frame = CGRectMake(10, 30, 30, 30);
NSLog(@"cellForRowAtIndexPath");
return cell;
}
//每个cell将要加载时调用
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath{
NSLog(@"willDisplayCell");
}
//加载组头标题时调用
- (void)tableView:(UITableView *)tableView willDisplayHeaderView:(UIView *)view forSection:(NSInteger)sectio{
NSLog(@"willDisplayHeaderView");
}
//加载尾头标题时调用
- (void)tableView:(UITableView *)tableView willDisplayFooterView:(UIView *)view forSection:(NSInteger)section{
NSLog(@"willDisplayFooterView");
}
//滑动时,cell消失时调用
- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath*)indexPath{
NSLog(@"didEndDisplayingCell");
}
//组头标题消失时调用
- (void)tableView:(UITableView *)tableView didEndDisplayingHeaderView:(UIView *)view forSection:(NSInteger)section{
NSLog(@"didEndDisplayingHeaderView");
}
//组尾标题消失时调用
- (void)tableView:(UITableView *)tableView didEndDisplayingFooterView:(UIView *)view forSection:(NSInteger)section{
NSLog(@"didEndDisplayingFooterView");
}

// Variable height support
//cell 的高度(每组可以不一样)
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath{
return 70.f;
}
//group 风格的cell的组头部标题部分高度
- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section{
return 15.0f;
}
//group 风格的cell的尾部标题部分的高度
- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section{
return 15.0f;
}

//返回组头标题
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section{
return [NSString stringWithFormat:@"headerGroup%ld",section];
}
//返回组尾标题
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section{
return [NSString stringWithFormat:@"footerGroup%ld",section];
}
  • 点击cell时调用
 - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath;
  • 离开点击时调用
 - (void)deselectRowAtIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated;
一般用法是在didSelectRowAtIndexPath方法中加入
[tableView deselectRowAtIndexPath:indexPath animated:YES];
即点击cell时cell有背景色,如过没有选中另一个,则这个cell背景色一直在,加入这句话效果是在点击结束后cell背景色消失。
  • 离开选中状态时调用(即选中另一个cell时,第一个cell会调用它的这个方法)
 - (void)tableView:(UITableView *)tableView didDeselectRowAtIndexPath:(NSIndexPath *)indexPath</pre>

UITableViewCell里面的一些细节属性

  • cell选中时的背景颜色(默认灰色,现在好像只有无色和灰色两种类型了)
    @property (nonatomic) UITableViewCellSelectionStyle selectionStyle;

UITableViewCellSelectionStyleNone,
UITableViewCellSelectionStyleBlue,
UITableViewCellSelectionStyleGray,
UITableViewCellSelectionStyleDefault

  • cell 右侧图标类型(图示)
    @property (nonatomic) UITableViewCellAccessoryType accessoryType;

UITableViewCellAccessoryNone 默认无
UITableViewCellAccessoryDisclosureIndicator 有指示下级菜单图标
UITableViewCellAccessoryDetailDisclosureButton 有详情按钮和指示下级菜单图标
UITableViewCellAccessoryCheckmark 对号
UITableViewCellAccessoryDetailButton 详情按钮



  • cell的另一个属性
    @property (nonatomic, strong, nullable) UIView *accessoryView;

如需自定义某个右侧控件(支持任何UIView控件)如下图的第一组第一行的右侧控件(核心代码见下面)




UITableView的右侧索引

  • 核心代码

返回每组标题索引
<pre>- (NSArray *)sectionIndexTitlesForTableView:(UITableView *)tableView{
NSMutableArray *indexs = [[NSMutableArray alloc] init];
for (int i = 0; i < kHeaderTitle.count; i++) {
[indexs addObject:kHeaderTitle[i]];
}
return indexs;
}
</pre>

自定义cell(MVC模式)

类似于下图这种每个cell不太一样。




1.建立模型,模型里面是数据类型



注意:如果.h文件中有类似于 id这种关键字的变量,要重新写一个变量,在.m文件中判断如果是这个变量,则用新写的变量接收原来变量的值。

2.cell 文件继承UITableViewCell(cell 可以纯代码,可以xib,一般xib比较方便点)

2.1 cell文件中声明一个模型类的变量
@property(nonatomic,strong)GPStatus * status;
2.2 写一个初始化的方法
+(instancetype)statusCellWithTableView:(UITableView *)tableView;

.m文件中初始化方法一般写如下代码

//注册 直接使用类名作为唯一标识
NSString * Identifier = NSStringFromClass([self class]);
UINib * nib = [UINib nibWithNibName:Identifier bundle:nil];
[tableView registerNib:nib forCellReuseIdentifier:Identifier];
return [tableView dequeueReusableCellWithIdentifier:Identifier];

.m 文件中 模型类的set方法中设置数据

self.iconView.image = [UIImage imageNamed:self.status.icon];
self.pictureView.image = [UIImage imageNamed:self.status.picture];
self.textView.text = self.status.text;
self.nameView.text = self.status.name;
self.vipView.image = [UIImage imageNamed:@"vip"];
3.最后就是在控制器中使用了(给出示例核心代码),cell的初始化用自定义的cell初始化,cell的模型对应数据源的每组数据。

cell = [[GPStatusCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:ID];
cell.status = self.statuses[indexPath.row];

删除操作

一般这种Cell如果向左滑动右侧就会出现删除按钮直接删除就可以了。其实实现这个功能只要实现代理方法,只要实现了此方法向左滑动就会显示删除按钮。只要点击删除按钮这个方法就会调用。

-(void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath;
- (void)tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath{
if (editingStyle == UITableViewCellEditingStyleDelete) {
[_titleArray removeObject:_titleArray[indexPath.row]];
[tableView deleteRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationBottom];
}
}

排序

  • 进入编辑状态,实现下面这个方法就能排序
 - (void)btnClick{
[_tableView setEditing:!_tableView.isEditing animated:YES];
}
 - (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)sourceIndexPath toIndexPath:(NSIndexPath *)destinationIndexPath{
//更新数据源,保存排序后的结果
}


tableView下拉放大header上滑改变navigationBar颜色



简单介绍:
  • 1.还是创建控制器,控制器里面创建tableView,初始化其必要的代理方法使能其正常显示
  • 2.初始化tableView的时候让tableView向下偏移(偏移下来的那段放图片):
_tableView.contentInset = UIEdgeInsetsMake(backGroupHeight - 64, 0, 0, 0);
  • 3.初始化图片,注意图片的frame设置,加载在tableView上

imageBg = [[UIImageView alloc] initWithFrame:CGRectMake(0, -backGroupHeight, kDeviceWidth, backGroupHeight)];
imageBg.image = [UIImage imageNamed:@"bg_header.png"];
[_tableView addSubview:imageBg];
  • 4.根据滑动时的偏移量改变图片的frame,改变navigationBar的透明度
 - (void)scrollViewDidScroll:(UIScrollView *)scrollView{
CGFloat yOffset = scrollView.contentOffset.y;
CGFloat xOffset = (yOffset + backGroupHeight)/2;

if (yOffset < -backGroupHeight) {
CGRect rect = imageBg.frame;
rect.origin.y = yOffset;
rect.size.height = -yOffset;
rect.origin.x = xOffset;
rect.size.width = kDeviceWidth + fabs(xOffset)*2;

imageBg.frame = rect;
}
CGFloat alpha = (yOffset + backGroupHeight)/backGroupHeight;
[self.navigationController.navigationBar setBackgroundImage:[self imageWithColor:[[UIColor orangeColor] colorWithAlphaComponent:alpha]] forBarMetrics:UIBarMetricsDefault];
titleLb.textColor = [UIColor colorWithRed:255 green:255 blue:255 alpha:alpha];
}
  • 5.渲染navigationBar颜色方法
 - (UIImage *)imageWithColor:(UIColor *)color{
//描述矩形
CGRect rect = CGRectMake(0.0f, 0.0f, 1.0f, 1.0f);
//开启位图上下文
UIGraphicsBeginImageContext(rect.size);
//获取位图上下文
CGContextRef content = UIGraphicsGetCurrentContext();
//使用color演示填充上下文
CGContextSetFillColorWithColor(content, [color CGColor]);
//渲染上下文
CGContextFillRect(content, rect);
//从上下文中获取图片
UIImage *currentImage = UIGraphicsGetImageFromCurrentImageContext();
//结束上下文
UIGraphicsEndImageContext();
return currentImage;
}



作者:SPIREJ
链接:https://www.jianshu.com/p/a5f6c534695e
收起阅读 »

抖音品质建设 - iOS启动优化《原理篇》

前言启动是 App 给用户的第一印象,启动越慢用户流失的概率就越高,良好的启动速度是用户体验不可缺少的一环。启动优化涉及到的知识点非常多面也很广,一篇文章难以包含全部,所以拆分成两部分:原理和实战。本文从基础知识出发,先回顾一些核心概念,为后续章节做铺垫;接下...
继续阅读 »

前言

启动是 App 给用户的第一印象,启动越慢用户流失的概率就越高,良好的启动速度是用户体验不可缺少的一环。启动优化涉及到的知识点非常多面也很广,一篇文章难以包含全部,所以拆分成两部分:原理和实战。

本文从基础知识出发,先回顾一些核心概念,为后续章节做铺垫;接下来介绍 IPA 构建的基本流程,以及这个流程里可用于启动优化的点;最后大篇幅讲解 dyld3 的启动 pipeline,因为启动优化的重点还在运行时。

基本概念

启动的定义

启动有两种定义:

  • 广义:点击图标到首页数据加载完毕
  • 狭义:点击图标到 Launch Image 完全消失第一帧

不同产品的业务形态不一样,对于抖音来说,首页的数据加载完成就是视频的第一帧播放;对其他首页是静态的 App 来说,Launch Image 消失就是首页数据加载完成。由于标准很难对齐,所以我们一般使用狭义的启动定义:即启动终点为启动图完全消失的第一帧

以抖音为例,用户感受到的启动时间:


Tips:启动最佳时间是 400ms 以内,因为启动动画时长是 400ms。

这是从用户感知维度定义启动,那么代码上如何定义启动呢?Apple 在 MetricKit 中给出了官方计算方式:

  • 起点:进程创建的时间
  • 终点:第一个CA::Transaction::commit()

Tips:CATransaction 是 Core Animation 提供的一种事务机制,把一组 UI 上的修改打包,一起发给 Render Server 渲染。

启动的种类

根据场景的不同,启动可以分为三种:冷启动,热启动和回前台。

  • 冷启动:系统里没有任何进程的缓存信息,典型的是重启手机后直接启动 App
  • 热启动:如果把 App 进程杀了,然后立刻重新启动,这次启动就是热启动,因为进程缓存还在
  • 回前台:大多数时候不会被定义为启动,因为此时 App 仍然活着,只不过处于 suspended 状态

那么,线上用户的冷启动多还是热启动多呢?

答案是和产品形态有关系,打开频次越高,热启动比例就越高。

Mach-O

Mach-O 是 iOS 可执行文件的格式,典型的 Mach-O 是主二进制和动态库。Mach-O 可以分为三部分:

  • Header
  • Load Commands
  • Data



Header 的最开始是 Magic Number,表示这是一个 Mach-O 文件,除此之外还包含一些 Flags,这些 flags 会影响 Mach-O 的解析。

Load Commands 存储 Mach-O 的布局信息,比如 Segment command 和 Data 中的 Segment/Section 是一一对应的。除了布局信息之外,还包含了依赖的动态库等启动 App 需要的信息。

Data 部分包含了实际的代码和数据,Data 被分割成很多个 Segment,每个 Segment 又被划分成很多个 Section,分别存放不同类型的数据。

标准的三个 Segment 是 TEXT,DATA,LINKEDIT,也支持自定义:

  • TEXT,代码段,只读可执行,存储函数的二进制代码(__text),常量字符串(__cstring),Objective C 的类/方法名等信息
  • DATA,数据段,读写,存储 Objective C 的字符串(__cfstring),以及运行时的元数据:class/protocol/method…
  • LINKEDIT,启动 App 需要的信息,如 bind & rebase 的地址,代码签名,符号表…

dyld

dyld 是启动的辅助程序,是 in-process 的,即启动的时候会把 dyld 加载到进程的地址空间里,然后把后续的启动过程交给 dyld。dyld 主要有两个版本:dyld2 和 dyld3。

dyld2 是从 iOS 3.1 引入,一直持续到 iOS 12。dyld2 有个比较大的优化是 dyld shared cache[1],什么是 shared cache 呢?

  • shared cache 就是把系统库(UIKit 等)合成一个大的文件,提高加载性能的缓存文件。

iOS 13 开始 Apple 对三方 App 启用了 dyld3,dyld3 的最重要的特性就是启动闭包,闭包里包含了启动所需要的缓存信息,从而提高启动速度。

虚拟内存

内存可以分为虚拟内存和物理内存,其中物理内存是实际占用的内存,虚拟内存是在物理内存之上建立的一层逻辑地址,保证内存访问安全的同时为应用提供了连续的地址空间。

物理内存和虚拟内存以页为单位映射,但这个映射关系不是一一对应的:一页物理内存可能对应多页虚拟内存;一页虚拟内存也可能不占用物理内存。


iPhone 6s 开始,物理内存的 Page 大小是 16K,6 和之前的设备都是 4K,这是 iPhone 6 相比 6s 启动速度断崖式下降的原因之一

mmap

mmap 的全称是 memory map,是一种内存映射技术,可以把文件映射到虚拟内存的地址空间里,这样就可以像直接操作内存那样来读写文件。当读取虚拟内存,其对应的文件内容在物理内存中不存在的时候,会触发一个事件:File Backed Page In,把对应的文件内容读入物理内存

启动的时候,Mach-O 就是通过 mmap 映射到虚拟内存里的(如下图)。下图中部分页被标记为 zero fill,是因为全局变量的初始值往往都是 0,那么这些 0 就没必要存储在二进制里,增加文件大小。操作系统会识别出这些页,在 Page In 之后对其置为 0,这个行为叫做 zero fill。


Page In

启动的路径上会触发很多次 Page In,其实也比较容易理解,因为启动的会读写二进制中的很多内容。Page In 会占去启动耗时的很大一部分,我们来看看单个 Page In 的过程:



  • MMU 找到空闲的物理内存页面
  • 触发磁盘 IO,把数据读入物理内存
  • 如果是 TEXT 段的页,要进行解密
  • 对解密后的页,进行签名验证

其中解密是大头,IO 其次。为什么要解密呢?

因为 iTunes Connect 会对上传 Mach-O 的 TEXT 段进行加密,防止 IPA 下载下来就直接可以看到代码。这也就是为什么逆向里会有个概念叫做“砸壳”,砸的就是这一层 TEXT 段加密。iOS 13 对这个过程进行了优化,Page In 的时候不需要解密了

二进制重排

既然 Page In 耗时,有没有什么办法优化呢?

启动具有局部性特征,即只有少部分函数在启动的时候用到,这些函数在二进制中的分布是零散的,所以 Page In 读入的数据利用率并不高。如果我们可以把启动用到的函数排列到二进制的连续区间,那么就可以减少 Page In 的次数,从而优化启动时间:

以下图为例,方法 1 和方法 3 是启动的时候用到的,为了执行对应的代码,就需要两次 Page In。假如我们把方法 1 和 3 排列到一起,那么只需要一次 Page In,从而提升启动速度。


链接器 ld 有个参数-order_file 支持按照符号的方式排列二进制

IPA 构建

pipeline

既然要构建,那么必然会有一些地方去定义如何构建,对应 Xcode 中的两个配置项:

  • Build Phase:以 Target 为维度定义了构建的流程。可以在 Build Phase 中插入脚本,来做一些定制化的构建,比如 CocoaPod 的拷贝资源就是通过脚本的方式完成的。
  • Build Settings:配置编译和链接相关的参数。特别要提到的是 other link flags 和 other c flags,因为编译和链接的参数非常多,有些需要手动在这里配置。很多项目用的 CocoaPod 做的组件化,这时候编译选项在对应的.xcconfig 文件里。

以单 Target 为例,我们来看下构建流程:


  • 源文件(.m/.c/.swift 等)是单独编译的,输出对应的目标文件(.o)
  • 目标文件和静态库/动态库一起,链接出最后的 Mach-O
  • Mach-O 会被裁剪,去掉一些不必要的信息
  • 资源文件如 storyboard,asset 也会编译,编译后加载速度会变快
  • Mach-O 和资源文件一起,打包出最后的.app
  • 对.app 签名,防篡改

编译

编译器可以分为两大部分:前端和后端,二者以 IR(中间代码)作为媒介。这样前后端分离,使得前后端可以独立的变化,互不影响。C 语言家族的前端是 clang,swift 的前端是 swiftc,二者的后端都是 llvm。

  • 前端负责预处理,词法语法分析,生成 IR
  • 后端基于 IR 做优化,生成机器码



那么如何利用编译优化启动速度呢?

代码数量会影响启动速度,为了提升启动速度,我们可以把一些无用代码下掉。那怎么统计哪些代码没有用到呢?可以利用 LLVM 插桩来实现。LLVM 的代码优化流程是一个一个 Pass,由于 LLVM 是开源的,我们可以添加一个自定义的 Pass,在函数的头部插入一些代码,这些代码会记录这个函数被调用了,然后把统计到的数据上传分析,就可以知道哪些代码是用不到的了 。

Facebook 给 LLVM 提的 order_file[2]的 feature 就是实现了类似的插桩。

链接

经过编译后,我们有很多个目标文件,接着这些目标文件会和静态库,动态库一起,链接出一个 Mach-O。链接的过程并不产生新的代码,只会做一些移动和补丁。



  • tbd 的全称是 text-based stub library,是因为链接的过程中只需要符号就可以了,所以 Xcode 6 开始,像 UIKit 等系统库就不提供完整的 Mach-O,而是提供一个只包含符号等信息的 tbd 文件。

举一个基于链接优化启动速度的例子:

最开始讲解 Page In 的时候,我们提到 TEXT 段的页解密很耗时,有没有办法优化呢?

可以通过 ld 的-rename_section,把 TEXT 段中的内容,比如字符串移动到其他的段(启动路径上难免会读很多字符串),从而规避这个解密的耗时


抖音的重命名方案:


"-Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring"
,
"-Wl,-rename_section,__TEXT,__const,__RODATA,__const"
"-Wl,-rename_section,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab"
"-Wl,-rename_section,__TEXT,__objc_methname,__RODATA,__objc_methname"
"-Wl,-rename_section,__TEXT,__objc_classname,__RODATA,__objc_classname",
"-Wl,-rename_section,__TEXT,__objc_methtype,__RODATA,__objc_methtype"

裁剪

编译完 Mach-O 之后会进行裁剪(strip),是因为里面有些信息,如调试符号,是不需要带到线上去的。裁剪有多种级别,一般的配置如下:

  • All Symbols,主二进制
  • Non-Global Symbols,动态库
  • Debugging Symbols,二方静态库

为什么二方库在出静态库的时候要选择 Debugging Symbols 呢?是因为像 order_file 等链接期间的优化是基于符号的,如果把符号裁剪掉,那么这些优化也就不会生效了

签名 & 上传

裁剪完二进制后,会和编译好的资源文件一起打包成.app 文件,接着对这个文件进行签名。签名的作用是保证文件内容不多不少,没有被篡改过。接着会把包上传到 iTunes Connect,上传后会对__TEXT段加密,加密会减弱 IPA 的压缩效果,增加包大小,也会降低启动速度(iOS 13 优化了加密过程,不会对包大小和启动耗时有影响)

dyld3 启动流程

Apple 在 iOS 13 上对第三方 App 启用了 dyld3,官方数据[3]显示,过去四年新发布的设备中有 93%的设备是 iOS 13,所以我们重点看下 dyld3 的启动流程。

Before dyld

用户点击图标之后,会发送一个系统调用 execve 到内核,内核创建进程。接着会把主二进制 mmap 进来,读取 load command 中的 LC_LOAD_DYLINKER,找到 dyld 的的路径。然后 mmap dyld 到虚拟内存,找到 dyld 的入口函数_dyld_start,把 PC 寄存器设置成_dyld_start,接下来启动流程交给了 dyld。

注意这个过程都是在内核态完成的,这里提到了 PC 寄存器,PC 寄存器存储了下一条指令的地址,程序的执行就是不断修改和读取 PC 寄存器来完成的。

dyld

创建启动闭包

dyld 会首先创建启动闭包,闭包是一个缓存,用来提升启动速度的。既然是缓存,那么必然不是每次启动都创建的,只有在重启手机或者更新/下载 App 的第一次启动才会创建。闭包存储在沙盒的 tmp/com.apple.dyld 目录,清理缓存的时候切记不要清理这个目录

闭包是怎么提升启动速度的呢?我们先来看一下闭包里都有什么内容:

  • dependends,依赖动态库列表
  • fixup:bind & rebase 的地址
  • initializer-order:初始化调用顺序
  • optimizeObjc: Objective C 的元数据
  • 其他:main entry, uuid…

动态库的依赖是树状的结构,初始化的调用顺序是先调用树的叶子结点,然后一层层向上,最先调用的是 libSystem,因为他是所有依赖的源头。


为什么闭包能提高启动速度呢?

因为这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是 Objective C 的运行时数据(Class/Method…)解析非常慢。

fixup

有了闭包之后,就可以用闭包启动 App 了。这时候很多动态库还没有加载进来,会首先对这些动态库 mmap 加载到虚拟内存里。接着会对每个 Mach-O 做 fixup,包括 Rebase 和 Bind。

  • Rebase:修复内部指针。这是因为 Mach-O 在 mmap 到虚拟内存的时候,起始地址会有一个随机的偏移量 slide,需要把内部的指针指向加上这个 slide。
  • Bind:修复外部指针。这个比较好理解,因为像 printf 等外部函数,只有运行时才知道它的地址是什么,bind 就是把指针指向这个地址。

举个例子:一个 Objective C 字符串@"1234",编译到最后的二进制的时候是会存储在两个 section 里的

  • __TEXT,__cstring,存储实际的字符串"1234"
  • __DATA,__cfstring,存储 Objective C 字符串的元数据,每个元数据占用 32Byte,里面有两个指针:内部指针,指向__TEXT,__cstring中字符串的位置;外部指针 isa,指向类对象的,这就是为什么可以对 Objective C 的字符串字面量发消息的原因。

如下图,编译的时候,字符串 1234 在__cstring的 0x10 处,所以 DATA 段的指针指向 0x10。但是 mmap 之后有一个偏移量 slide=0x1000,这时候字符串在运行时的地址就是 0x1010,那么 DATA 段的指针指向就不对了。Rebase 的过程就是把指针从 0x10,加上 slide 变成 0x1010。运行时类对象的地址已经知道了,bind 就是把 isa 指向实际的内存地址



LibSystem Initializer

Bind & Rebase 之后,首先会执行 LibSystem 的 Initializer,做一些最基本的初始化:

  • 初始化 libdispatch
  • 初始化 objc runtime,注册 sel,加载 category

注意这里没有初始化 objc 的类方法等信息,是因为启动闭包的缓存数据已经包含了 optimizeObjc。

Load & Static Initializer

接下来会进行 main 函数之前的一些初始化,主要包括+load 和 static initializer。这两类初始化函数都有个特点:调用顺序不确定,和对应文件的链接顺序有关系。那么就会存在一个隐藏的坑:有些注册逻辑在+load 里,对应会有一些地方读取这些注册的数据,如果在+load 中读取,很有可能读取的时候还没有注册。

那么,如何找到代码里有哪些 load 和 static initializer 呢?

在 Build Settings 里可以配置 write linkmap,这样在生成的 linkmap 文件里就可以找到有哪些文件里包含 load 或者 static initializer:

  • __mod_init_func,static initializer
  • __objc_nlclslist,实现+load 的类
  • __objc_nlcatlist,实现+load 的 Category

load 举例

如果+load 方法里的内容很简单,会影响启动时间么?比如这样的一个+load 方法?

+ (void)load printf("1234"); }

编译完了之后,这个函数会在二进制中的 TEXT 两个段存在:__text存函数二进制,cstring存储字符串 1234。为了执行函数,首先要访问__text触发一次 Page In 读入物理内存,为了打印字符串,要访问__cstring,还会触发一次 Page In。

为了执行这个简单的函数,系统要额外付出两次 Page In 的代价,所以 load 函数多了,page in 会成为启动性能的瓶颈。


static initializer 产生的条件

静态初始化是从哪来的呢?以下几种代码会导致静态初始化

  • __attribute__((constructor))
  • static class object
  • static object in global namespace

注意,并不是所有的 static 变量都会产生静态初始化,编译器很智能,对于在编译期间就能确定的变量是会直接 inline。

//会产生静态初始化
class Demo{ 
static const std::string var_1; 
};
const std::string var_2 = "1234"
static Logger logger;
//不会产生静态初始化
static const int var_3 = 4; 
static const char * var_4 = "1234";

std::string 会合成 static initializer 是因为初始化的时候必须执行构造函数,这时候编译器就不知道怎么做了,只能延迟到运行时~

UIKit Init

+load 和 static initializer 执行完毕之后,dyld 会把启动流程交给 App,开始执行 main 函数。main 函数里要做的最重要的事情就是初始化 UIKit。UIKit 主要会做两个大的初始化:

  • 初始化 UIApplication
  • 启动主线程的 Runloop

由于主线程的 dispatch_async 是基于 runloop 的,所以在+load 里如果调用了 dispatch_async 会在这个阶段执行。

Runloop

线程在执行完代码就会退出,很明显主线程是不能退出的,那么就需要一种机制:事件来的时候执行任务,否则让线程休眠,Runloop 就是实现这个功能的。

Runloop 本质上是一个While 循环,在图中橙色部分的 mach_msg_trap 就是触发一个系统调用,让线程休眠,等待事件到来,唤醒 Runloop,继续执行这个 while循环。

Runloop 主要处理几种任务:Source0,Source1,Timer,GCD MainQueue,Block。在循环的合适时机,会以 Observer 的方式通知外部执行到了哪里。


那么,Runloop 与启动又有什么关系呢?

  • App 的 LifeCycle 方法是基于 Runloop 的 Source0 的
  • 首帧渲染是基于 Runloop Block 的



Runloop 在启动上主要有几点应用:

  • 精准统计启动时间
  • 找到一个时机,在启动结束去执行一些预热任务
  • 利用 Runloop 打散耗时的启动预热任务

Tips : 会有一些逻辑要在启动之后 delay 一小段时间再回到主线程上执行,对于性能较差的设备,主线程 Runloop 可能一直处于忙的状态,所以这个 delay 的任务并不一定能按时执行。

AppLifeCycle

UIKit 初始化之后,就进入了我们熟悉的 UIApplicationDelegate 回调了,在这些会调里去做一些业务上的初始化:

  • willFinishLaunch

  • didFinishLaunch

  • didFinishLaunchNotification

要特别提一下 didFinishLaunchNotification,是因为大家在埋点的时候通常会忽略还有这个通知的存在,导致把这部分时间算到 UI 渲染里。

First Frame Render

一般会用 Root Controller 的 viewDidApper 作为渲染的终点,但其实这时候首帧已经渲染完成一小段时间了,Apple 在 MetricsKit 里对启动终点定义是第一个CA::Transaction::commit()

什么是 CATransaction 呢?我们先来看一下渲染的大致流程




iOS 的渲染是在一个单独的进程 RenderServer 做的,App 会把 Render Tree 编码打包给 RenderServer,RenderServer 再调用渲染框架(Metal/OpenGL ES)来生成 bitmap,放到帧缓冲区里,硬件根据时钟信号读取帧缓冲区内容,完成屏幕刷新。CATransaction 就是把一组 UI 上的修改,合并成一个事务,通过 commit 提交。

渲染可以分为四个步骤

  • Layout(布局),源头是 Root Layer 调用[CALayer layoutSubLayers],这时候 UIViewController 的 viewDidLoad 和 LayoutSubViews 会调用,autolayout 也是在这一步生效
  • Display(绘制),源头是 Root Layer 调用[CALayer display],如果 View 实现了 drawRect 方法,会在这个阶段调用
  • Prepare(准备),这个过程中会完成图片的解码
  • Commit(提交),打包 Render Tree 通过 XPC 的方式发给 Render Server




  1. 点击图标,创建进程
  2. mmap 主二进制,找到 dyld 的路径
  3. mmap dyld,把入口地址设为_dyld_start
  4. 重启手机/更新/下载 App 的第一次启动,会创建启动闭包
  5. 把没有加载的动态库 mmap 进来,动态库的数量会影响这个阶段
  6. 对每个二进制做 bind 和 rebase,主要耗时在 Page In,影响 Page In 数量的是 objc 的元数据
  7. 初始化 objc 的 runtime,由于闭包已经初始化了大部分,这里只会注册 sel 和装载 category
  8. +load 和静态初始化被调用,除了方法本身耗时,这里还会引起大量 Page In
  9. 初始化 UIApplication,启动 Main Runloop
  10. 执行 will/didFinishLaunch,这里主要是业务代码耗时
  11. Layout,viewDidLoad 和Layoutsubviews 会在这里调用,Autolayout 太多会影响这部分时间
  12. Display,drawRect 会调用
  13. Prepare,图片解码发生在这一步
  14. Commit,首帧渲染数据打包发给 RenderServer,启动结束

dyld2

dyld2 和 dyld3 的主要区别就是没有启动闭包,就导致每次启动都要:

  • 解析动态库的依赖关系
  • 解析 LINKEDIT,找到 bind & rebase 的指针地址,找到 bind 符号的地址
  • 注册 objc 的 Class/Method 等元数据,对大型工程来说,这部分耗时会很长

总结

本文回顾了 Mach-O,虚拟内存,mmap,Page In,Runloop 等基础概念,接下来介绍了 IPA 的构建流程,以及两个典型的利用编译器来优化启动的方案,最后详细的讲解了 dyld3 的启动 pipeline。

之所以花这么大篇幅讲原理,是因为任何优化都一样,只有深入理解系统运作的原理,才能找到性能的瓶颈


摘自https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247486932&idx=1&sn=eb4d294e00375d506b93a00b535c6b05&chksm=e9d0c636dea74f20ec800af333d1ee94969b74a92f3f9a5a66a479380d1d9a4dbb8ffd4574ca&cur_album_id=1590407423234719749&scene=190#rd




收起阅读 »

RxSwift异步事件追踪定位工具

文章概要:本文主要从分析RxSwift操作符的实现原理入手,然后介绍了Swift反射机制、Swift的函数派发机制及命名空间机制,同时我们设计了一套实现Hook Swift的动态及静态方法的解决方案,希望对广大iOS开发者有所帮助。1. 背景:RxSwift之...
继续阅读 »
文章概要:本文主要从分析RxSwift操作符的实现原理入手,然后介绍了Swift反射机制、Swift的函数派发机制及命名空间机制,同时我们设计了一套实现Hook Swift的动态及静态方法的解决方案,希望对广大iOS开发者有所帮助。


1. 背景:RxSwift之痛
RxSwift是GitHub的ReactiveX团队研发的一套函数响应式编程框架,其主要思想是把事件封装成信号流并采用观察者模式来实现监听。
当你使用RxSwift来实现一些简单的功能如发送一次网络请求、监听按钮点击事件等会让你的代码看起来非常直观简洁,但是如果你使用RxSwift实现了一个异步热流且在不同的类之间层层传递和加工转换之后代码的可读性就大大降低,甚至因为抓不到异步事件产生的堆栈而出现难以调试的情况。


为解决RxSwift的调试难题,我们通过阅读源码分析RxSwift操作符实现原理,然后利用Swift反射机制来dump “Observable Link”,最后又根据Swift语言的函数派发机制和命名空间机制设计了一套安全高效的hook Swift的动态及静态方法的方案,通过这套hook方案完成了对流事件传递链上的关键函数的拦截处理从而顺利实现了精准定位和调试RxSwift中异步事件的目标。


2. Dump Observable Link
2.1 RxSwift操作符实现原理简析
一个Observable使用操作符可以转换成一个新的Observable,而这个源Observable经过一些连续的操作符转换之后就形成了一条Observable Link,要追踪一个异步事件的源头首先需要找到整个Observable Link的Head节点。
阅读RxSwift的源码之后发现RxSwift的各种操作符的基本原理就是当你使用某个操作符对一个Observable A进行转换的时候,这个操作符都会生成一个新的Observable B,并且在这个新的Observable B内部持有原来的那个Observable A,当有其他人订阅Observable B的时候,Observable B内部同时也会订阅Observable A以此来实现整个Observable Link的“联动”效果。此时你也许会有了一些思路,既然每个操作符都会在其内部持有上一个Observable,那我们根据这个规律沿着一个操作符Observable一直往上回溯直到根Observable是不是就可以dump出整个Observable Link了?这个思路是正确的,然而现实却很残酷——所有操作符Observable用于持有其源Observable的属性都是Private的,这也就意味着你根本无法直接获取到这些属性!然而天无绝人之路,所幸的是我们还可以利用Swift的反射机制来到达目的。
2.2 Swift反射机制
尽管 Swift一直都在强调强类型、编译时安全并推荐使用静态调度,但它的标准库仍然提供了一个基于Mirror的Struct来实现的反射机制。简单来说,例如你有一个Class A并创建了一个A的实例对象a,此时你就可以通过Mirror(reflecting: a)来生成一个Mirror对象m,然后遍历m.children就可以获取到a对象的所有属性。
看到这里你应该知道如何去dump一个Observable Link了吧,话不多说,先上代码为敬:

2.3 为已有的类动态添加存储型属性
dump出的Observable Link上的所有Observable都是我们需要在运行时重点观察的对象,那么我们该如何对这些Observable与其它Observable做出区分呢?我们可以为Observable添加一个tag属性,在运行时如果发现某个Observable的tag不为空就监控这个Observable上产生的event。不过这里有一个关联类型问题,any类型可以转换为某种协议类型,但无法转换为关联类型协议的类型,因为关联的具体类型是未知的。为解决这个问题,我们设计了一个无关联类型的协议RxEventTrackType,在这个协议的extension里面为其添加eventTrackerTag属性,然后让Obseverble遵守此协议。为了给一个协议类型在extension中添加一个存储型属性,这里我选择了一个在OC时代经常使用的实现方案:objc_setAssociatedObject。

3. Hook Swift动态和静态方法
3.1 Swift的函数派发机制
函数派发就是处理如何去调用一个函数的问题。编译型语言有三种常见的函数派发方式:直接派发(Direct Dispatch)、函数表派发(Table Dispatch)和消息派发(Message Dispatch)。Swift同时支持这三种函数派发方式。
直接派发(Direct Dispatch)是最快的,不止是因为需要调用的指令集会更少,并且编译器还能够有很大的优化空间,例如函数内联等。然而静态调用对于编程来说也就意味着因为缺乏动态性而无法支持继承。
函数表派发(Table Dispatch)是编译型语言实现动态行为最常见的实现方式。函数表使用了一个数组来存储类声明的每一个函数的指针。大部分语言把这个称为“virtual table”(虚函数表),Swift里称为 “witness table”。每一个类都会维护一个函数表,里面记录着类所有需要通过函数表派发的函数,如果在本类中override了父类函数的话表里面只会保存被override之后的函数。一个子类在声明体内新添加的函数都会被插入到这个函数表的后面,运行时会根据这一个表去决定实际要被调用的函数。
消息机制(Message Dispatch)是调用函数最动态的方式,这样的机制催生了KVO,UIAppearence和CoreData等功能。这种运作方式的关键在于开发者可以在运行时改变函数的行为,不止可以通过swizzling来改变,甚至可以用isa-swizzling修改对象的继承关系,可以在面向对象的基础上实现自定义派发。
Swift函数派发规则总结:
  • 值类型声明作用域里的函数总是会使用直接派发
  • Class声明作用域里的函数都会使用函数表进行派发(某些特殊情况下编译器会优化为直接派发)
  • 而协议和类的extension都会使用直接派发
  • 协议里声明的,并且带有默认实现的函数会使用函数表进行派发
  • 用dynamic修饰的函数会通过运行时进行消息机制派发
3.2 静态语言Swift的Hook难点
相比于动态语言OC,静态语言Swift的方法Hook变得异常困难。主要原因如下:
1. 目标函数查找难
在OC中我们可以通过一个Selector(你可以简单理解为一个字符串)查找到对应的method,这个method内部的imp字段存储的即是函数指针。而Swift中的动态方法利用witness table或者protocol witness table通过偏移寻址来查找对应函数指针,Swift中的静态方法的地址更是在编译期就已经确定。
2.强行直接替换函数指针比较危险
如果非要Hook Swift中的动态方法,我们还是可以利用Xcode的lldb调试工具在运行时通过反汇编观察并记录某个函数对应的在witness table中的偏移量,然后找到这个类的meta data并根据这些偏移量找到对应的函数指针来进行Hook。然而这是一个非常危险的做法,如果某天Swift调整了其类对象的内存模型,我们通过固有偏移来实现的Hook将一触及崩!
3.3 移花接木——巧用命名空间
在Swift中每个module都代表了一个单独的命名空间,在不同的module里面可以定义相同的类型名称或者方法名称。例如Swift为我们提供的基本数据类型String里面定义了一个lowercased方法,如果此时我们在自己的module里面利用extension给String再增加一个lowercased方法,此时这两个lowercased方法是可以共存的,而且当你在自己的module里面调用String的lowercased方法时候默认优先调用的是你自己module里面的lowercased方法。
现在,你是不是感觉在Swift中Hook方法似乎有了一些眉目,然而目前还有一个更重要的问题亟待解决:如何在我们自己的lowercased方法中调用原生的lowercased方法呢?答案同样是利用命名空间。我们可以另外再建一个B module(demo中利用创建一个pod库的方式实现),在这个B module中给String增加一个originalLowercased方法,这个方法的内部实现很简单就是直接调用一下String的原生lowercased方法。然后就可以在我们自己module的lowercased方法中调用originalLowercased从而间接实现对String的原生lowercased方法的调用。

稍微有些遗憾的是,利用上面所述的这种方案Hook的方法只在我们自己的module里面有效,不过对于一般的Hook需求来说已经足够使用了。


4. Hook RxSwift的方法
上面关于Hook的介绍已经给我们提高了充分的理论基础,下面我们就可以用理论来指导实践了。
如果要追踪一个流事件产生的源头,关键要做的就是监听ObserverType的onNext、onError、onComplete方法和BehaviorRelay的accept方法。然后当一个ObserverType的对象的onNext等方法被调用的时候如果发现这个对象带有observerTypeTrackerTag就认为这是一个需要被重点观察和监控的对象并作出相应的处理,我们也可以同时在这里加上一个条件断点方便调试,代码截图如下:  

使用此定位工具来追踪和定位一步事件源调试效果如下Gif图所示:


5. 总结
在此次RxSwift异步事件追踪定位工具的研发过程中,最为关键也是难点之一的就是如何实现hook Swift的动态及静态方法,我们在尝试了两三种方案之后才最终确定了这种利用Swift语言的函数派发机制和命名空间机制来安全高效的hook Swift的动态及静态方法的方案,相信我们的这套hook方案也会给你在以后的开发中在处理类似问题时带来更多的思路和灵感。

摘自https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247485124&idx=1&sn=f5cbdca57e5f5d8a0bafe6fb3b783b61&chksm=e9d0cd26dea744305deea33e6b7424efb19ccbdcbe1261e6c56f577325689096f32971a2e57d&cur_album_id=1590407423234719749&scene=190#rd
字节跳动技术团队
收起阅读 »

Android之使用Assets目录中的xml布局、网页、音乐等资源

众所周知,Android中Activity加载布局的方式常用的有以下几种: setContentView(View view) setContentView(@LayoutRes int layoutResID) View的加载方式可通过下列方式...
继续阅读 »



众所周知,Android中Activity加载布局的方式常用的有以下几种:


setContentView(View view)    
setContentView(@LayoutRes int layoutResID)

View的加载方式可通过下列方式加载:


View.inflate(Context context, @LayoutRes int resource, ViewGroup root)
LayoutInflater.from(Context context).inflate(@LayoutRes int resource, @Nullable ViewGroup root)
LayoutInflater.from(Context context).inflate(XmlPullParser parser, @Nullable ViewGroup root)

由于Android的特殊机制,assets和raw目录下的文件不会被编译(即不能通过R.xx.id访问),所以我们只能采用LayoutInflater.from(Context context).inflate(XmlPullParser parser, @Nullable ViewGroup root)方法来访问其中的xml布局。


所以我们接着来看如何获取到XmlPullParset对象,通过context.assets可获取到AssetManager对象,而AssetManager则可以通过openXmlResourceParser(@NonNull String fileName)获取到XmlResourceParser对象。


所以通过上面的分析我们可得出下列代码:


    fun getView(ctx: Context, filename: String): View? {
return LayoutInflater.from(ctx).inflate(am.openXmlResourceParser(filename), null)
}

1.当我们兴高采烈的写好demo,实机运行时,会遇到第一个坑:


程序抛出了FileNotFoundException的异常


java.io.FileNotFoundException: activity_main.xml

通过查阅资料后你发现原来要在文件名的前面加上"assets/"的前缀


2.这时你修改了你的代码,要对文件前缀进行判断


    fun getView(ctx: Context, filename: String): View? {
var name = filename
if(!filename.startsWith("assets/")){
name = "assets/$filename"
}
return LayoutInflater.from(ctx).inflate(am.openXmlResourceParser(name), null)
}

修改完代码后,你紧接着开始了第二波测试,却发现程序又抛出了异常:


java.io.FileNotFoundException: Corrupt XML binary file

这个错误则代表这你的xml布局文件格式不对,放入到assets目录下的xml文件应该是编译后的文件(即apk中xml文件)如下图: 在这里插入图片描述


3.于是你将你的apk中的layout/activity_main.xml拷贝到工程的assets目录下,开始了第三波测试:


这时你发现APK运行正常,但是你冥冥中发现了一丝不对劲,你发现你即使能拿到该布局所对应的ViewGroup,却发现并不能通过findViewById(id)方法来获取到子View,于是你开始查看ViewGroup的源码,机智的你发现了如下方法:


public final <T extends View> T findViewWithTag(Object tag) {
if (tag == null) {
return null;
}
return findViewWithTagTraversal(tag);
}

该方法可以通过设置的tag,来获取到对应的子View


4.于是你在xml中为子View设置好tag后,写好代码,开始了第四波测试 在这里插入图片描述 在这里插入图片描述 这时候你查看手机上的APP,发现textView显示的字符发生了改变: 在这里插入图片描述



入坑指南



  1. java.io.FileNotFoundException: activity_main.xml xml布局文件名需加前缀"assets/"


  2. java.io.FileNotFoundException: Corrupt XML binary file xml布局文件需要放入编译后的xml,如果只是普通的xml文件,则不需要


  3. 在xml中对子View设置tag,通过ViewGroup的findViewWithTag(tag)方法即可获取到子View


  4. 使用html网页 "file:///android_asset/$filename" filename为assets目录下的文件路径



工具类源码:


package com.coding.am_demo

import android.content.Context
import android.content.res.AssetManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.LayoutInflater
import android.view.View
import java.io.IOException

/**
* @author: Coding.He
* @date: 2020/10/9
* @emil: 229101253@qq.com
* @des:获取assets目录下资源的工具类
*/
object AssetsTools {
private lateinit var am: AssetManager
private lateinit var appCtx: Context

/**
* 初始化AssetsTools,使用前必须初始化
* */
fun init(ctx: Context) {
this.appCtx = ctx.applicationContext
am = ctx.applicationContext.assets
}

/**
* 获取assets目录下的xml布局
* 需要以.xml结尾
* */
@Throws(IOException::class)
fun getView(filename: String): View? {
if (!filename.endsWith(".xml"))
return null
val name = when {
filename.startsWith("assets/") -> filename
else -> "assets/$filename"
}
return LayoutInflater.from(appCtx).inflate(am.openXmlResourceParser(name), null)
}

/**
* 获取assets目录下的图片资源
* */
fun getBitmap(filename: String): Bitmap? {
var bitmap: Bitmap? = null
try {
val ins = am.open(filename)
ins.use {
bitmap = BitmapFactory.decodeStream(ins)
}
} catch (e: IOException) {
e.printStackTrace()
}
return bitmap
}

/**
* 获取assets目录下的html路径
* */
fun getHtmlUrl(filename: String):String{
return "file:///android_asset/$filename"
}

}

demo项目地址

收起阅读 »

RecyclerView GridLayoutManger平分间距问题

背景问题 在RecyclerView的网格布局中,我们经常会遇到要给每个Item设置间距的情况,并使用GridLayoutManger,如下图: A(0) ~ A(3)是网格中的一行,要个每个Item设置间距SpaceH,两边分别设置边距为edgeH,...
继续阅读 »

背景问题


在RecyclerView的网格布局中,我们经常会遇到要给每个Item设置间距的情况,并使用GridLayoutManger,如下图:


image.png


A(0) ~ A(3)是网格中的一行,要个每个Item设置间距SpaceH,两边分别设置边距为edgeH,要实现这种情况,我们一般会使用ItemDecoration,重写它的getItemOffsets方法计算每个Item的左右边距,很容易误写成一下方式: (gridSize为一行有几列)


override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {

super.getItemOffsets(outRect, view, parent, state)
val position = parent.getChildAdapterPosition(view)

// 获取第几列
val column = position % gridSize
outRect.left = if (column == 0) edgeH else spaceH / 2
outRect.right = if (column < gridSize - 1) spaceH / 2 else edgeH
}

写成这样的原因主要是认为只要给每个Item合适的左右间距就好了,然而运行以后会发现每个Item的宽度不相等,这还要从GridLayoutManager平分原理说起,每个Item的宽度是这样计算的



  1. 平分reyclerView的宽度,得到每个网格的宽度grideWidth = parentWidth / gridSize

  2. 减去每个item左右间距,childWidth = gridWidth - outRect.left - outRect.right


有了以上计算公式,可以很容易发现item的宽度会出现不一定相等的情况,例如



  • A(0) = grideWidth - edgeH - spaceH / 2

  • A(1) = grideWidth - spaceH


可以发现A(0) 和A(1)的宽度只有在edgeH = spaceH / 2 时才相等,其他时候都是不等的。


推导过程


那究竟怎么算呢?根据childWidth = gridWidth - outRect.left - outRect.right,我们可以知道,要求每个Item都相等,只需要每个Item对应的outRect.left + outRect.right都相等即可。


我们将第n个item左边的边距 定为 L(n), 右边的边距定为R(n), 将他们的和定为p,p目前是未知的,得到第一个算式



① L(n) + R(n) = p



另外,我们设置网格时都会设置两个Item之间的间距,我们定为spaceH,那么第n个和n+1个之间的间距由R(n) + L(n+1)组成,可以得到第二个算式



② R(n) + L(n+1) = spaceH



得到这两个算式后就是纯粹的数学问题了



  1. 首先第一个算式,我们可以把所有情况枚举出来,下面gridSize为网格的列数,它肯定是已知的


L(0) + R(0) = p
L(1) + R(1) = p
....
L(gridSize-1) + R(gridSize-1) = p

将这些式子全部相加可以发现,R(0) + L(1) , R(1) + L(2)这些,都是第②个算式,总共有gridSize-1个,所有就有一下算式


L(0) + (gridSize - 1) * h + R(gridSize -1 ) = gridSize * p

又由于网格两边都为edgeH,即L(0)和R(gridSize -1 )为edgeH,可以算出p的值为



p = (2 * edgeH + (gridSize - 1) * spaceH) / gridSize




  1. 再仔细发现算式①和②左边都有R(n),我们通过减法将他消除掉消除掉,即②-①,就剩下:


L(n+1) - L(n) = spaceH - p

这个式子明显是一个等差数列,等差数列是有公式的,可以直接得出一下结论


L(n) = L(0) + n * (spaceH - p)



注L(0)为edgeH,且因为我们的下标是从0开始算的,所以后面是乘以n




  1. 由于p在第一步已经算出来了,所以L(n)的值就是已知的了



L(n) = edgeH + n * (spaceH - p)



那么R(n)格局算式①和②都可以算出来,



R(n) = p - L(n)



ItemDecoration实现


最终,我们可以得到这样的结果


class GridSpaceDecoration(
private val gridSize: Int,
private val spaceH: Int = 0,
private val spaceV: Int = 0,
private val edgeH: Int = 0 // 网格两边的间距
): RecyclerView.ItemDecoration() {


override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
val position = parent.getChildAdapterPosition(view)

// 获取第几列
val column = position % gridSize
// 第几行
val row: Int = position / gridSize
if (row != 0) { // 设置top
outRect.top = spaceV
}

// p为每个Item都需要减去的间距
val p = (2 * edgeH + (gridSize - 1) * spaceH) * 1f / gridSize
val left = edgeH + column * (spaceH - p)
val right = p - left

outRect.left = Math.round(left)
outRect.right = Math.round(right)
}

}



  1. 也许有人会说,两边的间距可以通过recyclerView的paddingLeft和paddingRight计算得来,这样的确可以,但关键问题在于,很多时候我们需要通过GridLayoutManger实现不同类型的Item,不同Item之间可能就需要通过ItemDecoration来设置了,至于多类型的怎么写这里就不做赘述了。

  2. 网上很多文章的算式很多都没有考虑左右的边距,而且没有推导过程,都是找规律的,这里主要是用数学方式做推导,记录下推导过程

  3. 细心一下可以发现,如果edgeH大于spaceH,那么得到的item左右边距有些是负数,不过并不影响最终效果,这个也是同事通过测试后发现的,自己本能的以为edgeH是不能大于spaceH的。。。


收起阅读 »

二叉树、平衡二叉树、红黑树

树 树是具有“一对多”关系的、非线性存储结构的数据元素的集合。树的最坏时间复杂度是O(n). 二叉树 二叉树是具有特殊性质的树,满足下面两个条件的树就是二叉树: 本身是有序树 树中包含的所有节点的度不能超过2(度是节点包含子树的数量) 二叉树的特殊性质...
继续阅读 »


树是具有“一对多”关系的、非线性存储结构的数据元素的集合。树的最坏时间复杂度是O(n).


2-1FS0094003158.png


二叉树


二叉树是具有特殊性质的树,满足下面两个条件的树就是二叉树:



  1. 本身是有序

  2. 树中包含的所有节点的度不能超过2(度是节点包含子树的数量)


2-1Q226195I0M1.gif


二叉树的特殊性质:



  1. 二叉树的第i层最多有2i12^{i-1}个节点

  2. 深度为K的二叉树,最多有2K2^K-1个节点

  3. 二叉树中,终端结点数(叶子结点数)为n0n_0,度为2的结点数为n2n_2,则n0n_0=n2n_2+1。计算方法:



对于一个二叉树来说,除了度为 0 的叶子结点和度为 2 的结点,剩下的就是度为 1 的结点(设为 n1),那么总结点 n=n0+n1+n2。同时,对于每一个结点来说都是由其父结点分支表示的,假设树中分枝数为 B,那么总结点数 n=B+1。而分枝数是可以通过 n1 和 n2 表示的,即 B=n1+2n2。所以,n 用另外一种方式表示为 n=n1+2n2+1。两种方式得到的 n 值组成一个方程组,就可以得出 n0=n2+1。



二叉树还可以分成满二叉树、完全二叉树


满二叉树:每个非叶子节点的为2的二叉树


2-1Q226195949495.gif


完全二叉树除去最后一层的节点为满二叉树,且最后一层的节点依次从左到右排列的二叉树


2-1Q22620003J18.gif


平衡二叉树(AVL树)


平衡二叉树 是任何两个子树的高度差不超过1(平衡因子)的二叉树(可以是空树)。


平衡二叉树为了保持“完全平衡”,当由于增删数据发生不平衡时,会通过旋转达到平衡的目的。旋转方式:



  • 左旋


328857972-5dce36aaf2a23_articlex (1).png



  • 右旋


1244963393-5dce36ab8ba03_articlex.png


红黑树(RBT)


红黑树是一种含有红黑节点,并能自平衡的二叉查找树。红黑树必须具有以下特性:



  1. 所有节点必须是红色或黑色

  2. 根节点是黑色

  3. 所有叶子节点(NIL)都是黑色

  4. 每个红色节点的两个子节点一定都是黑色

  5. 每个节点到叶子节点的路径上,都包含相同数量的黑色节点

  6. 如果一个节点为黑色,那么这个节点一定有两个子节点


2392382-abedf3ecc733ccd5.png


红黑树是一种完全平衡的二叉查找树,如图,根节点的左子树明显比右子树高,但是左子树和右子树的黑色节点的层数是相等的,即属性5。每次添加、删除节点,红黑树会通过旋转变色来保持自平衡,且旋转次数最多为3,复杂度是O(lgn)。


红黑树查找



  1. 从根节点开始查找,把根节点设置为当前节点

  2. 若当前节点为null,返回null

  3. 若当前节点不为null,用当前节点的Key和查找key对比

  4. 若当前节点的key等于查找key,返回当前节点

  5. 若当前节点的key大于查找key,设置当前节点的左子节点为当前节点,重复步骤2

  6. 若当前节点的key小于查找key,设置当前节点的右子节点为当前节点,重复步骤2


2392382-07b47eb3722981e6.png


作者:大白兔的二表哥
链接:https://juejin.cn/post/6989602410364665864
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android 中使用WebViewJavaScriptBridge进行H5和原生的交互

1. 概述 当我们采用H5与Native原生结合开发,使用H5去开发一些功能的时候,肯定会涉及到Android与Js互相调用的问题,通常有两种实现方式, 第一种 使用原生的addJavascriptInterface()来解决 第二种 使用第三...
继续阅读 »

1. 概述



当我们采用H5与Native原生结合开发,使用H5去开发一些功能的时候,肯定会涉及到Android与Js互相调用的问题,通常有两种实现方式,



第一种 使用原生的addJavascriptInterface()来解决


第二种 使用第三方框架WebViewJavascriptBridge 这也是我今天要分享的部分


2.为什么要使用WebViewJavascriptBridge


对于安卓开发有一段时间的人来说,知道安卓4.4以前谷歌的webview存在安全漏洞,网站可以通过js注入就可以随便拿到客户端的重要信息,甚至轻而易举的调用本地代码进行流氓行为,谷歌后来发现有此漏洞后,增加了防御措施,如果要是js调用本地代码,开发者必须在代码中申明JavascriptInterface


列如在4.0之前我们要使得webView加载js只需如下代码:


mWebView.addJavascriptInterface(new JsToJava(), "myjsfunction");  

4.4之后调用需要在调用方法加入加入@JavascriptInterface注解,如果代码无此申明,那么也就无法使得js生效,也就是说这样就可以避免恶意网页利用js对安卓客户端的窃取和攻击。


但是即使这样,我们很多时候需要在js记载本地代码的时候,要做一些判断和限制,或者有可能也会做些过滤和对用户友好提示,因此JavascriptInterface也就无法满足我们的需求了,特此有大神就写出了WebViewJavascriptBridge框架。


3.开始使用


第一步.Android Studio 导包

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

dependencies {
compile 'com.github.lzyzsd:jsbridge:1.0.4'
}

第二步.在布局文件中添加

<com.github.lzyzsd.jsbridge.BridgeWebView
android:id="@+id/wv_web_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

第三步.代码中添加交互方法

H5调android方法


//android端代码
mWebView.registerHandler("test", new BridgeHandler() {
@Override
public void handler(String data, CallBackFunction function) {
function.onCallBack("指定Handler收到Web发来的数据,回传数据给你");
}

//H5端代码
function test() {
//调用本地java方法
//第一个参数是 调用java的函数名字 第二个参数是要传递的数据 第三个参数js在被回调后具体执行方法,responseData为java层回传数据
var data='发送消息给java代码指定接收';
window.WebViewJavascriptBridge.callHandler(
'test'
,data
, function(responseData) {
bridgeLog('来自Java的回传数据: ' + responseData);
}
);
}

或者


//android端代码 
mWebView.setDefaultHandler(new BridgeHandler() {
@Override
public void handler(String data, CallBackFunction function) {
function.onCallBack("指定Handler收到Web发来的数据,回传数据给你");
}
});

//H5端代码
function test() {
//发送消息给java代码
var data = '发送消息给java代码全局接收';

window.WebViewJavascriptBridge.send(
data
, function(responseData) {
bridgeLog('来自Java的回传数据: ' +responseData);
}
);
}

以上两种方式 一个是指定调具体协定好的方法,一个是全局调用


android调H5


//android端代码 
mWebView.send("发送数据给web默认接收",new CallBackFunction(){
@Override
public void onCallBack(String data) {
Log.e(TAG, "来自web的回传数据:" + data);
}
});

//H5端代码
//注册回调函数,第一次连接时调用 初始化函数
connectWebViewJavascriptBridge(function(bridge) {
bridge.init(function(message, responseCallback) {
bridgeLog('默认接收收到来自Java数据: ' + message);
var responseData = '默认接收收到来自Java的数据,回传数据给你';
responseCallback(responseData);
});


})

或者


//android端代码 
mWebView.callHandler("test","发送数据给web指定接收",new CallBackFunction(){
@Override
public void onCallBack(String data) {
Log.e(TAG, "来自web的回传数据:" + data);
}
});
//H5端代码
connectWebViewJavascriptBridge(function(bridge) {
bridge.registerHandler("test", function(data, responseCallback) {
bridgeLog('指定接收收到来自Java数据: ' + data);
var responseData = '指定接收收到来自Java的数据,回传数据给你';
responseCallback(responseData);
});
})

同样 两种方式一个是不指定方法,另一个是指定具体方法 到此为止还无法交互,还需要配置setWebViewClient


mWebView.setWebViewClient(new BridgeWebViewClient(mWebView));

这步非常关键,如果不配置 测试点击压根就不响应,如果你需要自定义WebViewClient,必须实现对应构造方法,而且重写的方法必须调用父类方法,如下:


private class MyWebViewClient extends BridgeWebViewClient {
//必须
public MyWebViewClient(BridgeWebView webView) {
super(webView);
}


@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
super.onReceivedError(view, request, error);//这个不能省略
// 避免出现默认的错误界面
view.loadUrl("about:blank");
}

到此为止,配置完毕,H5和Android就可以互相调用了


收起阅读 »

Android三个流量优化方案 (建议收藏)

前言 套餐虽然优惠,流量还是很贵,对用户而言网络流量就是钱呐!用户习惯打开系统自带 APP 流量统计功能,从 APP 的角度,总不希望用户一眼看出自家的 APP 是流量大户,所以有必要花时间知道 APP 的流量怎么流失的。但是系统的流量统计功能只是很粗略的对...
继续阅读 »

前言


套餐虽然优惠,流量还是很贵,对用户而言网络流量就是钱呐!用户习惯打开系统自带 APP 流量统计功能,从 APP 的角度,总不希望用户一眼看出自家的 APP 是流量大户,所以有必要花时间知道 APP 的流量怎么流失的。但是系统的流量统计功能只是很粗略的对每个 APP 消耗的流量总量(分时)进行统计,但是程序员需要对 APP 的流量进行更精细、多维度的分析,从而有针对性地优化 APP 数据流量,所以有了以下几种方案。
该文不仅仅包括流量优化,文末还列举了Android程序各类性能优化,请慢慢阅读


一、数据缓存


OkHttp 缓存


如果我们仔细跟一下自己项目中的接口,就会发现很多对实时性没有那么高要求的接口,使用缓存不仅可以节约流量,而且能大幅提升数据访问速度。


我们常用的网络库,比如 OkHttp 和 Volley,都有比较好的缓存实践。


而且没做缓存对用户体验也不好,一般的 App 会在打开后显示一个无数据的界面,和展示上一次的数据相比,这个用户体验其实是比较差的。
1. 无网拦截器
下面我们重点看下 OkHttp 的缓存实践,首先定义一个无网拦截器。



然后是给 OkHttpClient 添加拦截器。

添加了无网络拦截器后,当无网络的情况下打开我们的 App 时,也能获取到上一次的数据,也能使用 App,这样就能提升用户体验。


2. OkHttp 缓存处理流程
OkHttp 的缓存拦截器对于请求的处理流程如下。


过期时间与增量更新


1. 过期时间


在服务端返回的数据中加上一个过期时间,这样我们每次请求的时候判断一下有没有过期,如果没有过期就不需要去重新请求。


2. 增量更新


数据增量更新的具体思路,就是在数据中加上一个版本的概念,每次接收数据都进行版本对比,只接收有变化的数据。


这样传输的数据量就会减少很多,比如省市区和配置等数据比较少更新,如果每次都要请求省市区的数据,这就是在浪费流量。


我们只需要更新发生变化的数据,因为和服务器相关比较密切,在这里就不给大家举例了。


二、数据压缩


1. Gzip


对于 Post 请求,Body 是用 Gzip 压缩的,也就是请求的时候带上 Gzip 请求头,服务端返回的时候也加上 Gzip 压缩,这样数据流就是被压缩过的。


2. 压缩请求头


请求头也占用一定的体积,在请求头不变的情况下,我们可以只传递一次,以后都只需要传递上一次请求头的 MD5 值,服务端做一个缓存,在需要请求头中的某些信息时,就可以直接从之前的缓存中取。


3. 合并网络请求


每一个网络请求都会有冗余信息,比如请求头,而合并网络请求就可以减少冗余信息的传递;


三、图片压缩


1. 缩略图


图片压缩的第一个手段,就是在列表中优先使用缩略图,因为展示原图会加大内存消耗和流量消耗,而且在列表中直接展示原图没有意义。


下面是原图和缩略图的对比大小,缩略图尺寸为原图的 50%,大小为原图的 10%。


2. WebP
图片压缩的第二个手段,就是使用 Webp 格式,下面是同一张图片在 PNG 格式和 WebP 格式下的对比,WebP 格式的大小为 PNG 格式的 51%。

3. Luban


比如我们在上传图片的时候,做一个压缩比如在本地是一个 2M 的图片,完整地上传上去意义不大,只会增加我们的流量消耗,最好是压缩后再上传。


而在图片压缩上做得比较好的就是鲁班,下面我们来看下鲁班的使用方法。


首先添加依赖。


dependencies {
// 图片压缩
implementation 'top.zibin:Luban:1.1.8'
}

然后添加对图片进行压缩。


下面这张图片的原始大小为 1.6M,压缩后变成了 213KB,体积为原始大小的 13%。




以上就是本文所有内容了,有需要了解更多Android性能优化的,请往下看。(文末有惊喜)



ANR问题解析



crash监控方案



启动速度与执行效率优化项目实战



内存优化



耗电优化



网络传输与数据存储优化



apk大小优化



项目实战


收起阅读 »

iOS大解密:玄之又玄的KVO (下)

首先我们看下 NSSetIntValueAndNotify_block_invoke 的汇编实现:Foundation`___NSSetIntValueAndNotify_block_invoke:->  0x10bf27fe1&nb...
继续阅读 »

首先我们看下 NSSetIntValueAndNotify_block_invoke 的汇编实现:

Foundation`___NSSetIntValueAndNotify_block_invoke:
->  0x10bf27fe1 <+0>:  pushq  %rbp
    0x10bf27fe2 <+1>:  movq   %rsp, %rbp
    0x10bf27fe5 <+4>:  pushq  %rbx
    0x10bf27fe6 <+5>:  pushq  %rax
    0x10bf27fe7 <+6>:  movq   %rdi, %rbx
    0x10bf27fea <+9>:  movq   0x28(%rbx), %rax
    0x10bf27fee <+13>: movq   0x30(%rbx), %rsi
    0x10bf27ff2 <+17>: movq   (%rax), %rdi
    0x10bf27ff5 <+20>: callq  0x10c1422b2               ; symbol stub for: class_getMethodImplementation
    0x10bf27ffa <+25>: movq   0x20(%rbx), %rdi
    0x10bf27ffe <+29>: movq   0x30(%rbx), %rsi
    0x10bf28002 <+33>: movl   0x38(%rbx), %edx
    0x10bf28005 <+36>: addq   $0x8, %rsp
    0x10bf28009 <+40>: popq   %rbx
    0x10bf2800a <+41>: popq   %rbp
    0x10bf2800b <+42>: jmpq   *%rax

___NSSetIntValueAndNotify_block_invoke 翻译成伪代码如下:

void ___NSSetIntValueAndNotify_block_invoke(SDTestStackBlock *block) {
    SDTestKVOClassIndexedIvars *indexedIvars = block->captureVar2;
    SEL methodSel =  block->captureVar3;
    IMP imp = class_getMethodImplementation(indexedIvars->originalClass);
    id obj = block->captureVar1;
    SEL sel = block->captureVar3;
    int num = block->captureVar4;
    imp(obj, sel, num);
}

这个 block 的内部实现其实就是从 KVO 类的 indexedIvars 里取到原始类,然后根据 sel 从原始类中取出原始的方法实现来执行并最终完成了一次 KVO 调用。我们发现整个 KVO 运作过程中 KVO 类的 indexedIvars 是一个贯穿 KVO 流程始末的关键数据,那么这个 indexedIvars 是何时生成的呢?indexedIvars 里又包含哪些数据呢?想要弄清楚这个问题,我们就必须从 KVO 的源头看起,我们知道既然 KVO 要用到 isa 交换那么最终肯定要调用到 object_setClass 方法,这里我们不妨以 object_setClass 函数为线索,通过设置条件符号断点来追踪 object_setClass 的调用,lldb 调试截图如下:


断点到 object_setClass 之后,我们再验证看下寄存器 rdi、rsi 里面的参数打印出来分别是<Test: 0x600003df01b0>、NSKVONotifying_Test



不错,我们现在已经成功定位到 KVO 的 isa 交换现场了,然而为了找到 KVO 类的生成的地方我们还需要沿着调用栈向前回溯,最终我们定位到 KVO 类的生成函数_NSKVONotifyingCreateInfoWithOriginalClass,其汇编代码如下:


Foundation`_NSKVONotifyingCreateInfoWithOriginalClass:
->  0x10c557d79 <+0>:   pushq  %rbp
    0x10c557d7a <+1>:   movq   %rsp, %rbp
    0x10c557d7d <+4>:   pushq  %r15
    0x10c557d7f <+6>:   pushq  %r14
    0x10c557d81 <+8>:   pushq  %r12
    0x10c557d83 <+10>:  pushq  %rbx
    0x10c557d84 <+11>:  subq   $0x20, %rsp
    0x10c557d88 <+15>:  movq   %rdi, %r14
    0x10c557d8b <+18>:  movq   0x2b463e(%rip), %rax      ; (void *)0x000000011012d070: __stack_chk_guard
//篇幅限制删除一部分 .完整版在评论   

翻译成伪代码如下:

typedef struct {
    Class originalClass;                // offset 0x0
    Class KVOClass;                     // offset 0x8
    CFMutableSetRef mset;               // offset 0x10
    CFMutableDictionaryRef mdict;       // offset 0x18
    pthread_mutex_t *lock;              // offset 0x20
    void *sth1;                         // offset 0x28
    void *sth2;                         // offset 0x30
    void *sth3;                         // offset 0x38
    void *sth4;                         // offset 0x40
    void *sth5;                         // offset 0x48
    void *sth6;                         // offset 0x50
    void *sth7;                         // offset 0x58
    bool flag;                          // offset 0x60
} SDTestKVOClassIndexedIvars;


Class _NSKVONotifyingCreateInfoWithOriginalClass(Class originalClass) {
    const char *clsName = class_getName(originalClass);
    size_t len = strlen(clsName);
    len += 0x10;
    char *newClsName = malloc(len);
    const char *prefix = "NSKVONotifying_";
    __strlcpy_chk(newClsName, prefix, len);
    __strlcat_chk(newClsName, clsName, len, -1);
    Class newCls = objc_allocateClassPair(originalClass, newClsName, 0x68);
    if (newCls) {
        objc_registerClassPair(newCls);
        SDTestKVOClassIndexedIvars *indexedIvars = object_getIndexedIvars(newCls);
        indexedIvars->originalClass = originalClass;
        indexedIvars->KVOClass = newCls;
        CFMutableSetRef mset = CFSetCreateMutable(nil, 0, kCFCopyStringSetCallBacks);
        indexedIvars->mset = mset;
        CFMutableDictionaryRef mdict = CFDictionaryCreateMutable(nil, 0, nil, kCFTypeDictionaryValueCallBacks);
        indexedIvars->mdict = mdict;
        pthread_mutex_init(indexedIvars->lock);
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            bool flag = true;
            IMP willChangeValueForKeyImp = class_getMethodImplementation(indexedIvars->originalClass, @selector(willChangeValueForKey:));
            IMP didChangeValueForKeyImp = class_getMethodImplementation(indexedIvars->originalClass, @selector(didChangeValueForKey:));
            if (willChangeValueForKeyImp == _NSKVONotifyingCreateInfoWithOriginalClass.NSObjectWillChange && didChangeValueForKeyImp == _NSKVONotifyingCreateInfoWithOriginalClass.NSObjectDidChange) {
                flag = false;
            }
            indexedIvars->flag = flag;
            NSKVONotifyingSetMethodImplementation(indexedIvars, @selector(_isKVOA), NSKVOIsAutonotifying, nil)
            NSKVONotifyingSetMethodImplementation(indexedIvars, @selector(dealloc), NSKVODeallocate, nil)
            NSKVONotifyingSetMethodImplementation(indexedIvars, @selector(class), NSKVOClass, nil)
        });
    } else {
        // 错误处理过程省略......
        return nil
    }
    return newCls;
}

通过_NSKVONotifyingCreateInfoWithOriginalClass 的这段伪代码你会发现我们之前频繁提到 indexedIvars 原来就是在这里初始化生成的。objc_allocateClassPair 在 runtime.h 中的声明为 Class _Nullable objc_allocateClassPair(Class _Nullable superclass, const char * _Nonnull name, size_t extraBytes) ,苹果对 extraBytes 参数的解释为“The number of bytes to allocate for indexed ivars at the end of the class and metaclass objects.”,这就是说当我们在通过 objc_allocateClassPair 来生成一个新的类时可以通过指定 extraBytes 来为此类开辟额外的空间用于存储一些数据。系统在生成 KVO 类时会额外分配 0x68 字节的空间,其具体内存布局和用途我用一个结构体描述如下:

typedef struct {
   Class originalClass;                // offset 0x0
   Class KVOClass;                     // offset 0x8
   CFMutableSetRef mset;               // offset 0x10
   CFMutableDictionaryRef mdict;       // offset 0x18
   pthread_mutex_t *lock;              // offset 0x20
   void *sth1;                         // offset 0x28
   void *sth2;                         // offset 0x30
   void *sth3;                         // offset 0x38
   void *sth4;                         // offset 0x40
   void *sth5;                         // offset 0x48
   void *sth6;                         // offset 0x50
   void *sth7;                         // offset 0x58
   bool flag;                          // offset 0x60
} SDTestKVOClassIndexedIvars;

3. 如何解决 custom-KVO 导致的 native-KVO Crash

读到这里相信你对 KVO 实现细节有了大致的了解,然后我们再回到最初的问题,为什么“先调用 native-KVO 再调用 custom-KVO,custom-KVO 运行正常,native-KVO 会 crash”呢?我们还以上面提到过的 Test 类为例说明一下:

首先用 Test 类实例化了一个实例 test,然后对 test 的 num 属性进行 native-KVO 操作,这时 test 的 isa 指向了 NSKVONotifying_Test 类。然后我们再对 test 进行 custom-KVO 操作,这时我们的 custom-KVO 会基于 NSKVONotifying_Test 类再生成一个新的子类 SD_NSKVONotifying_Test_abcd,此时问题就来了,如果我们没有仿照 native-KVO 的做法额外分配 0x68 字节的空间用于存储 KVO 关键信息,那么当我们向 test 发送 setNum:消息然后 setNum:方法调用 super 实现走到了 KVO 的_NSSetIntValueAndNotify 方法时还按照 SDTestKVOClassIndexedIvars *indexedIvars = object_getIndexedIvars(cls)方式来获取 KVO 信息并尝试获取从中获取数据时发生异常导致 crash。

找到问题的根源之后我们就可以见招拆招,我们可以仿照 native-KVO 的做法在生成 SD_NSKVONotifying_Test_abcd 也额外分配 0x68 自己的空间,然后当要进行 custom-KVO 操作时将 NSKVONotifying_Test 的 indexedIvars 拷贝一份到 SD_NSKVONotifying_Test_abcd 即可,代码实现如下:





一般情况下在 native-KVO 的基础上再做 custom-KVO 的话拷贝完 native-KVO 类的 indexedIvars 到 custom-KVO 类上就可以了,而我们的 SDMagicHook 只做到这些还不够,因为 SDMagicHook 在生成的新类上以消息转发的形式来调度方法,这样一来问题瞬间就变得更为复杂。举例说明如下:

由于用到消息转发,我们会将 SD_NSKVONotifying_Test_abcd 的setNum:对应的实现指向_objc_msgForward,然后生成一个新的 SEL__sd_B_abcd_setNum:来指向其子类的原生实现,在我们这个例子中就是 NSKVONotifying_TestsetNum:实现的即void _NSSetIntValueAndNotify(id obj, SEL sel, int number)函数。当 test 实例收到setNum:消息时会先触发消息转发机制,然后 SDMagicHook 的消息调度系统会最终通过向 test 实例发送一个__sd_B_abcd_setNum:消息来实现对被 Hook 的原生方法的回调,而现在__sd_B_abcd_setNum:对应的实现函数正是void _NSSetIntValueAndNotify(id obj, SEL sel, int number),所以__sd_B_abcd_setNum:就会被作为 sel 参数传递到_NSSetIntValueAndNotify函数。然后当_NSSetIntValueAndNotify函数内部尝试从 indexedIvars 拿到原始类 Test 然后从 Test 上查找__sd_B_abcd_setNum:对应的方法并调用时由于找不到对应函数实现而发生 crash。为解决这个问题,我们还需要为 Test 类新增一个__sd_B_abcd_setNum:方法并将其实现指向setNum:的实现,代码如下:


至此,“先调用 native-KVO 再调用 custom-KVO,custom-KVO 运行正常,native-KVO 会 crash”这个问题就可以顺利解决了。

4. 如何解决 native-KVO 导致 custom-KVO 失效的问题

目前还剩下一个问题“先调用 native-KVO 再调用 custom-KVO 再调用 native-KVO,native-KVO 运行正常,custom-KVO 失效,无 crash”。为什么会出现这个问题呢?这次我们依然以 Test 类为例,首先用 Test 类实例化了一个实例 test,然后对 test 的 num 属性进行 native-KVO 操作,这时 test 的 isa 指向了 NSKVONotifying_Test 类。然后我们再对 test 进行 custom-KVO 操作,这时我们的 custom-KVO 会基于 NSKVONotifying_Test 类再生成一个新的子类 SD_NSKVONotifying_Test_abcd,这时如果再对 test 的 num 属性进行 native-KVO 操作就会惊奇地发现 test 的 isa 又重新指向了 NSKVONotifying_Test 类然后 custom-KVO 就全部失效了。

WHY?!!原来 native-KVO 会持有一个全局的字典:_NSKeyValueContainerClassForIsa.NSKeyValueContainerClassPerOriginalClass 以 KVO 操作的原类为 key 和 NSKeyValueContainerClass 实例为 value 存储 KVO 类信息。



这样一来,当我们再次对 test 实例进行 KVO 操作时,native-KVO 就会以 Test 类为 key 从 NSKeyValueContainerClassPerOriginalClass 中查找到之前存储的 NSKeyValueContainerClass 并从中直接获取 KVO 类 NSKVONotifying_Test 然后调用 object_setclass 方法设置到 test 实例上然后 custom-KVO 就直接失效了。

想要解决这个问题,我想到了两种思路:1.修改 NSKVONotifying_Test 相关 KVO 数据 2.hook 拦截系统的 setclass 操作。然后仔细一想方案 1 是不可取的,因为 NSKVONotifying_Test 的相关数据是被所有 Test 类的实例在进行 KVO 操作时共享的,任何改动都有可能对 Test 类实例的 KVO 产生全局影响。所以,我们就需要借助 FishHook 来 hook 系统的 object_setclass 函数,当系统以 NSKVONotifying_Test 为参数对一个实例进行 setclass 操作时,我们检查如果当前的 isa 指针是 SD_NSKVONotifying_Test_abcd 且 SD_NSKVONotifying_Test_abcd 继承自系统的 NSKVONotifying_Test 时就跳过此次 setclass 操作。

但是这样做还不够,因为 custom-KVO 采用了特殊的消息转发机制来调度被 hook 的方法,如果先进行 custom-KVO 然后在进行 native-KVO 就会导致被观察属性被重复调用。所以,我们在对一个实例进行首次 custom-KVO 操作之前先进行 native-KVO,这样一来就可以保证我们的 custom-KVO 的方法调度正常工作了。代码如下:



总结

KVO 的本质其实就是基于被观察的实例的 isa 生成一个新的类并在这个类的 extra 空间中存放各种和 KVO 操作相关的关键数据,然后这个新的类以一个中间人的角色借助 extra 空间中存放各种数据完成复杂的方法调度。

系统的 KVO 实现比较复杂,很多函数的调用层次也比较深,我们一开始不妨从整个函数调用栈的末端层层向前梳理出主要的操作路径,在对 KVO 操作有个大致的了解之后再从全局的角度正向全面分析各个流程和细节。我们正是借助这种方式实现了对 KVO 的快速了解和认识。

至此,一个良好兼容 native-KVO 的 custom-KVO 就全部完成了。回头来看,这个解决方案其实还是过于 tricky 了,不过这也只能是在 iOS 系统的各种限制下的无奈的选择了。我们不提倡随意使用类似的 tricky 操作,更多是想要通过这个例子向大家介绍一下 KVO 的本质以及我们分析和解决问题的思路。如果各位读者可以从中汲取一些灵感,那么这篇文章“倒也算是不负恩泽”,倘若大家可以将这篇文章介绍到的思路和方法用于处理自己开发中的遇到的各种疑难杂症“那便真真是极好的了”!


摘自字节跳动技术团队:https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247486231&idx=1&sn=1c6584e9dcc3edf71c42cf396bcab051&chksm=e9d0c0f5dea749e34bf23de8259cbc7c868d3c8a6fc56c4366412dfb03eac8f037ee1d8668a1&cur_album_id=1590407423234719749&scene=190#rd




收起阅读 »

iOS大解密:玄之又玄的KVO (上)

导读:大多数 iOS 开发人员对 KVO 的认识只局限于 isa 指针交换这一层,而 KVO 的实现细节却鲜为人知。如果自己也仿照 KVO 基础原理来实现一套类 KVO 操作且独立运行时会发现一切正常,然而一旦你的实现和系统的 KVO 实现同时作用在同一个实例...
继续阅读 »

导读:

大多数 iOS 开发人员对 KVO 的认识只局限于 isa 指针交换这一层,而 KVO 的实现细节却鲜为人知。

如果自己也仿照 KVO 基础原理来实现一套类 KVO 操作且独立运行时会发现一切正常,然而一旦你的实现和系统的 KVO 实现同时作用在同一个实例上那么各种各样诡异的 bug 和 crash 就会层出不穷。

这究竟是为什么呢?此类问题到底该如何解决呢?接下来我们将尝试从汇编层面来入手以层层揭开 KVO 的神秘面纱......

1. 缘起 Aspects

SDMagicHook 开源之后很多小伙伴在问“ SDMagicHook 和 Aspects 的区别是什么?”,我在 GitHub 上找到 Aspects 了解之后发现 Aspects 也是以 isa 交换为基础原理进行的 hook 操作,但是两者在具体实现和 API 设计上也有一些区别,另外 SDMagicHook 还解决了 Aspects 未能解决的 KVO 冲突难题。

1.1 SDMagicHook 的 API 设计更加友好灵活

SDMagicHook 和 Aspects 的具体异同分析见:https://github.com/larksuite/SDMagicHook/issues/3。

1.2 SDMagicHook 解决了 Aspects 未能解决的 KVO 冲突难题

在 Aspects 的 readme 中我还注意到了这样一条关于 KVO 兼容问题的描述:



SDMagicHook 会不会有同样的问题呢?测试了一下发现 SDMagicHook 果然也中招了,而且其实此类问题的实际情况要比 Aspects 作者描述的更为复杂和诡异,问题的具体表现会随着系统 KVO(以下简称 native-KVO)和自己实现的类 KVO(custom-KVO)的调用顺序和次数的不同而各异,具体如下:

  1. 先调用 custom-KVO 再调用 native-KVO,native-KVO 和 custom-KVO 都运行正常
  2. 先调用 native-KVO 再调用 custom-KVO,custom-KVO 运行正常,native-KVO 会 crash
  3. 先调用 native-KVO 再调用 custom-KVO 再调用 native-KVO,native-KVO 运行正常,custom-KVO 失效,无 crash

目前,SDMagicHook 已经解决了上面提到的各类问题,具体的实现方案我将在下文中详细介绍。

2. 从汇编层面探索 KVO 本质

想要弄明白这个问题首先需要研究清楚系统的 KVO 到底是如何实现的,而系统的 KVO 实现又相当复杂,我们该从哪里入手呢?想要弄清楚这个问题,我们首先需要了解下当对被 KVO 观察的目标属性进行赋值操作时到底发生了什么。这里我们以自建的 Test 类为例来说明,我们对 Test 类实例的 num 属性进行 KVO 操作:


当我们给 num 赋值时,可以看到断点命中了 KVO 类自定义的 setNum:的实现即_NSSetIntValueAndNotify 函数


那么_NSSetIntValueAndNotify 的内部实现是怎样的呢?我们可以从汇编代码中发现一些蛛丝马迹:



Foundation`_NSSetIntValueAndNotify:
    0x10e5b0fc2 <+0>:   pushq  %rbp
->  0x10e5b0fc3 <+1>:   movq   %rsp, %rbp
    0x10e5b0fc6 <+4>:   pushq  %r15
    0x10e5b0fc8 <+6>:   pushq  %r14
    0x10e5b0fca <+8>:   pushq  %r13
    0x10e5b0fcc <+10>:  pushq  %r12
    0x10e5b0fce <+12>:  pushq  %rbx
    0x10e5b0fcf <+13>:  subq   $0x48, %rsp
    0x10e5b0fd3 <+17>:  movl   %edx, -0x2c(%rbp)
    0x10e5b0fd6 <+20>:  movq   %rsi, %r15
    0x10e5b0fd9 <+23>:  movq   %rdi, %r13
    0x10e5b0fdc <+26>:  callq  0x10e7cc882               ; symbol stub for: object_getClass
    0x10e5b0fe1 <+31>:  movq   %rax, %rdi
    0x10e5b0fe4 <+34>:  callq  0x10e7cc88e               ; symbol stub for: object_getIndexedIvars
    0x10e5b0fe9 <+39>:  movq   %rax, %rbx
    0x10e5b0fec <+42>:  leaq   0x20(%rbx), %r14
    0x10e5b0ff0 <+46>:  movq   %r14, %rdi
    0x10e5b0ff3 <+49>:  callq  0x10e7cca26               ; symbol stub for: pthread_mutex_lock
    0x10e5b0ff8 <+54>:  movq   0x18(%rbx), %rdi
    0x10e5b0ffc <+58>:  movq   %r15, %rsi
    0x10e5b0fff <+61>:  callq  0x10e7cb472               ; symbol stub for: CFDictionaryGetValue
    0x10e5b1004 <+66>:  movq   0x36329d(%rip), %rsi      ; "copyWithZone:"
    0x10e5b100b <+73>:  xorl   %edx, %edx
    0x10e5b100d <+75>:  movq   %rax, %rdi
    0x10e5b1010 <+78>:  callq  *0x2b2862(%rip)           ; (void *)0x000000010eb89d80: objc_msgSend
    0x10e5b1016 <+84>:  movq   %rax, %r12
    0x10e5b1019 <+87>:  movq   %r14, %rdi
    0x10e5b101c <+90>:  callq  0x10e7cca32               ; symbol stub for: pthread_mutex_unlock
    0x10e5b1021 <+95>:  cmpb   $0x0, 0x60(%rbx)
    0x10e5b1025 <+99>:  je     0x10e5b1066               ; <+164>
    0x10e5b1027 <+101>: movq   0x36439a(%rip), %rsi      ; "willChangeValueForKey:"
    0x10e5b102e <+108>: movq   0x2b2843(%rip), %r14      ; (void *)0x000000010eb89d80: objc_msgSend
    0x10e5b1035 <+115>: movq   %r13, %rdi
    0x10e5b1038 <+118>: movq   %r12, %rdx
    0x10e5b103b <+121>: callq  *%r14
    0x10e5b103e <+124>: movq   (%rbx), %rdi
    0x10e5b1041 <+127>: movq   %r15, %rsi
    0x10e5b1044 <+130>: callq  0x10e7cc2b2               ; symbol stub for: class_getMethodImplementation
    0x10e5b1049 <+135>: movq   %r13, %rdi
    0x10e5b104c <+138>: movq   %r15, %rsi
    0x10e5b104f <+141>: movl   -0x2c(%rbp), %edx
    0x10e5b1052 <+144>: callq  *%rax
    0x10e5b1054 <+146>: movq   0x364385(%rip), %rsi      ; "didChangeValueForKey:"
    0x10e5b105b <+153>: movq   %r13, %rdi
    0x10e5b105e <+156>: movq   %r12, %rdx
    0x10e5b1061 <+159>: callq  *%r14
    0x10e5b1064 <+162>: jmp    0x10e5b10be               ; <+252>
    0x10e5b1066 <+164>: movq   0x2b22eb(%rip), %rax      ; (void *)0x00000001120b9070: _NSConcreteStackBlock
    0x10e5b106d <+171>: leaq   -0x68(%rbp), %r9
    0x10e5b1071 <+175>: movq   %rax, (%r9)
    0x10e5b1074 <+178>: movl   $0xc2000000, %eax         ; imm = 0xC2000000
    0x10e5b1079 <+183>: movq   %rax, 0x8(%r9)
    0x10e5b107d <+187>: leaq   0xf5d(%rip), %rax         ; ___NSSetIntValueAndNotify_block_invoke
    0x10e5b1084 <+194>: movq   %rax, 0x10(%r9)
    0x10e5b1088 <+198>: leaq   0x2b7929(%rip), %rax      ; __block_descriptor_tmp.77
    0x10e5b108f <+205>: movq   %rax, 0x18(%r9)
    0x10e5b1093 <+209>: movq   %rbx, 0x28(%r9)
    0x10e5b1097 <+213>: movq   %r15, 0x30(%r9)
    0x10e5b109b <+217>: movq   %r13, 0x20(%r9)
    0x10e5b109f <+221>: movl   -0x2c(%rbp), %eax
    0x10e5b10a2 <+224>: movl   %eax, 0x38(%r9)
    0x10e5b10a6 <+228>: movq   0x364fab(%rip), %rsi      ; "_changeValueForKey:key:key:usingBlock:"
    0x10e5b10ad <+235>: xorl   %ecx, %ecx
    0x10e5b10af <+237>: xorl   %r8d, %r8d
    0x10e5b10b2 <+240>: movq   %r13, %rdi
    0x10e5b10b5 <+243>: movq   %r12, %rdx
    0x10e5b10b8 <+246>: callq  *0x2b27ba(%rip)           ; (void *)0x000000010eb89d80: objc_msgSend
    0x10e5b10be <+252>: movq   0x362f73(%rip), %rsi      ; "release"
    0x10e5b10c5 <+259>: movq   %r12, %rdi
    0x10e5b10c8 <+262>: callq  *0x2b27aa(%rip)           ; (void *)0x000000010eb89d80: objc_msgSend
    0x10e5b10ce <+268>: addq   $0x48, %rsp
    0x10e5b10d2 <+272>: popq   %rbx
    0x10e5b10d3 <+273>: popq   %r12
    0x10e5b10d5 <+275>: popq   %r13
    0x10e5b10d7 <+277>: popq   %r14
    0x10e5b10d9 <+279>: popq   %r15
    0x10e5b10db <+281>: popq   %rbp
    0x10e5b10dc <+282>: retq

上面这段汇编代码翻译为伪代码大致如下:

typedef struct {
    Class originalClass;                // offset 0x0
    Class KVOClass;                     // offset 0x8
    CFMutableSetRef mset;               // offset 0x10
    CFMutableDictionaryRef mdict;       // offset 0x18
    pthread_mutex_t *lock;              // offset 0x20
    void *sth1;                         // offset 0x28
    void *sth2;                         // offset 0x30
    void *sth3;                         // offset 0x38
    void *sth4;                         // offset 0x40
    void *sth5;                         // offset 0x48
    void *sth6;                         // offset 0x50
    void *sth7;                         // offset 0x58
    bool flag;                          // offset 0x60
} SDTestKVOClassIndexedIvars;

typedef struct {
    Class isa;                          // offset 0x0
    int flags;                          // offset 0x8
    int reserved;
    IMP invoke;                         // offset 0x10
    void *descriptor;                   // offset 0x18
    void *captureVar1;                  // offset 0x20
    void *captureVar2;                  // offset 0x28
    void *captureVar3;                  // offset 0x30
    int captureVar4;                    // offset 0x38

} SDTestStackBlock;

void _NSSetIntValueAndNotify(id obj, SEL sel, int number) {
    Class cls = object_getClass(obj);
    // 获取类实例关联的信息
    SDTestKVOClassIndexedIvars *indexedIvars = object_getIndexedIvars(cls);
    pthread_mutex_lock(indexedIvars->lock);
    NSString *str = (NSString *)CFDictionaryGetValue(indexedIvars->mdict, sel);
    str = [str copyWithZone:nil];
    pthread_mutex_unlock(indexedIvars->lock);
    if (indexedIvars->flag) {
        [obj willChangeValueForKey:str];
        ((void(*)(id obj, SEL sel, int number))class_getMethodImplementation(indexedIvars->originalClass, sel))(obj, sel, number);
        [obj didChangeValueForKey:str];
    } else {
        // 生成block
        SDTestStackBlock block = {};
        block.isa = _NSConcreteStackBlock;
        block.flags = 0xC2000000;
        block.invoke = ___NSSetIntValueAndNotify_block_invoke;
        block.descriptor = __block_descriptor_tmp;
        block.captureVar2 = indexedIvars;
        block.captureVar3 = sel;
        block.captureVar1 = obj;
        block.captureVar4 = number;
        [obj _changeValueForKey:str key:nil key:nil usingBlock:&SDTestStackBlock];
    }
}

这段代码的大致意思是说首先通过 object_getIndexedIvars(cls)获取到 KVO 类的 indexedIvars,如果 indexedIvars->flag 为 true 即开发者自己重写实现过 willChangeValueForKey:或者 didChangeValueForKey:方法的话就直接以 class_getMethodImplementation(indexedIvars->originalClass, sel))(obj, sel, number)的方式实现对被观察的原方法的调用,否则就用默认实现为 NSSetIntValueAndNotify_block_invoke 的栈 block 并捕获 indexedIvars、被 KVO 观察的实例、被观察属性对应的 SEL、赋值参数等所有必要参数并将这个 block 作为参数传递给 [obj _changeValueForKey:str key:nil key:nil usingBlock:&SDTestStackBlock]调用。看到这里你或许会有个疑问:伪代码中通过 object_getIndexedIvars(cls)获取到的 indexedIvars 是什么信息呢?block.invoke = ___ NSSetIntValueAndNotify_block_invoke 又是如何实现的呢?


篇幅过长  分上下2篇

收起阅读 »

iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+

iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+iOS OOM 崩溃在生产环境中的归因一直是困扰业界已久的疑难问题,字节跳动旗下的头条、抖音等产品也面临同样的问题。在字节跳动性能与稳定性保障团队的研发实践中,我们自研了一款基于内存快照技术并且可应用...
继续阅读 »

iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+

iOS OOM 崩溃在生产环境中的归因一直是困扰业界已久的疑难问题,字节跳动旗下的头条、抖音等产品也面临同样的问题。

在字节跳动性能与稳定性保障团队的研发实践中,我们自研了一款基于内存快照技术并且可应用于生产环境中的 OOM 归因方案——线上 Memory Graph。基于此方案,3 个月内头条抖音 OOM 崩溃率下降 50%+。

本文主要分享下该解决方案的技术背景,技术原理以及使用方式,旨在为这个疑难问题提供一种新的解决思路。

OOM 崩溃背景介绍

OOM

OOM 其实是Out Of Memory的简称,指的是在 iOS 设备上当前应用因为内存占用过高而被操作系统强制终止,在用户侧的感知就是 App 一瞬间的闪退,与普通的 Crash 没有明显差异。但是当我们在调试阶段遇到这种崩溃的时候,从设备设置->隐私->分析与改进中是找不到普通类型的崩溃日志,只能够找到Jetsam开头的日志,这种形式的日志其实就是 OOM 崩溃之后系统生成的一种专门反映内存异常问题的日志。那么下一个问题就来了,什么是Jetsam

Jetsam

Jetsam是 iOS 操作系统为了控制内存资源过度使用而采用的一种资源管控机制。不同于MacOSLinuxWindows等桌面操作系统,出于性能方面的考虑,iOS 系统并没有设计内存交换空间的机制,所以在 iOS 中,如果设备整体内存紧张的话,系统只能将一些优先级不高或占用内存过大的进程直接终止掉。


上图是截取一份Jetsam日志中最关键的一部分。关键信息解读:

  • pageSize:指的是当前设备物理内存页的大小,当前设备是iPhoneXs Max,大小是 16KB,苹果 A7 芯片之前的设备物理内存页大小则是 4KB。
  • states:当前应用的运行状态,对于Heimdallr-Example这个应用而言是正在前台运行的状态,这类崩溃我们称之为FOOM(Foreground Out Of Memory);与此相对应的也有应用程序在后台发生的 OOM 崩溃,这类崩溃我们称之为BOOM(Background Out Of Memory)。
  • rpages:是resident pages的缩写,表明进程当前占用的内存页数量,Heimdallr-Example 这个应用占用的内存页数量是 92800,基于 pageSize 和 rpages 可以计算出应用崩溃时占用的内存大小:16384 * 92800 / 1024 /1024 = 1.4GB。
  • reason:表明进程被终止的的原因,Heimdallr-Example这个应用被终止的原因是超过了操作系统允许的单个进程物理内存占用的上限。

Jetsam机制清理策略可以总结为下面两点:

1.  单个 App 物理内存占用超过上限
2.  整个设备物理内存占用收到压力按照下面优先级完成清理:
    1. 后台应用>前台应用
    2. 内存占用高的应用>内存占用低的应用
    3. 用户应用>系统应用

Jetsam的代码在开源的XNU代码中可以找到,这里篇幅原因就不具体展开了,具体的源码解析可以参考本文最后第 2 和第 3 篇参考文献。

为什么要监控 OOM 崩溃

前面我们已经了解到,OOM 分为FOOMBOOM两种类型,显然前者因为用户的感知更明显,所以对用户的体验的伤害更大,下文中提到的 OOM 崩溃仅指的是FOOM。那么针对 OOM 崩溃问题有必要建立线上的监控手段吗?

答案是有而且非常有必要的!原因如下:

  1. 重度用户也就是使用时间更长的用户更容易发生FOOM,对这部分用户体验的伤害导致用户流失的话对业务损失更大。
  2. 头条,抖音等多个产品线上数据均显示FOOM量级比普通崩溃还要多,因为过去缺乏有效的监控和治理手段导致问题被长期忽视。
  3. 内存占用过高即使没导致FOOM也可能会导致其他应用BOOM的概率变大,一旦用户发现从微信切换到我们 App 使用,再切回微信没有停留在之前微信的聊天页面而是重新启动的话,对用户来说,体验是非常糟糕的。

OOM 线上监控



翻阅XNU源码的时候我们可以看到在Jetsam机制终止进程的时候最终是通过发送SIGKILL异常信号来完成的。

#define SIGKILL 9 kill (cannot be caught or ignored)

从系统库 signal.h 文件中我们可以找到SIGKILL这个异常信号的解释,它不可以在当前进程被忽略或者被捕获,我们之前监听异常信号的常规 Crash 捕获方案肯定也就不适用了。那我们应该如何监控 OOM 崩溃呢?

正面监控这条路行不通,2015 年的时候Facebook提出了另外一种思路,简而言之就是排除法。具体流程可以参考下面这张流程图:



我们在每次 App 启动的时候判断上一次启动进程终止的原因,那么已知的原因有:

  • App 更新了版本
  • App 发生了崩溃
  • 用户手动退出
  • 操作系统更新了版本
  • App 切换到后台之后进程终止

如果上一次启动进程终止的原因不是上述任何一个已知原因的话,就判定上次启动发生了一次FOOM崩溃。

曾经Facebook旗下的Fabric也是这样实现的。但是通过我们的测试和验证,上述这种方式至少将以下几种场景误判:

  • WatchDog 崩溃
  • 后台启动
  • XCTest/UITest 等自动化测试框架驱动
  • 应用 exit 主动退出

在字节跳动 OOM 崩溃监控上线之前,我们已经排除了上面已知的所有误判场景。需要说明的是,因为排除法毕竟没有直接的监控来的那么精准,或多或少总有一些 bad case,但是我们会保证尽量的准确。

自研线上 Memory Graph,OOM 崩溃率下降 50%+

OOM 生产环境归因

目前在 iOS 端排查内存问题的工具主要包括 Xcode 提供的 Memory Graph 和 Instruments 相关的工具集,它们能够提供相对完备的内存信息,但是应用场景仅限于开发环境,无法在生产环境使用。由于内存问题往往发生在一些极端的使用场景,线下开发测试一般无法覆盖对应的问题,Xcode 提供的工具无法分析处理大多数偶现的疑难问题。

对此,各大公司都提出了自己的线上解决方案,并开源了例如MLeaksFinderOOMDetectorFBRetainCycleDetector等优秀的解决方案。

在字节跳动内部的使用过程中,我们发现现有工具各有侧重,无法完全满足我们的需求。主要的问题集中在以下两点:

  • 基于 Objective-C 对象引用关系找循环引用的方案,适用范围比较小,只能处理部分循环引用问题,而内存问题通常是复杂的,类似于内存堆积,Root Leak,C/C++层问题都无法解决。
  • 基于分配堆栈信息聚类的方案需要常驻运行,对内存、CPU 等资源存在较大消耗,无法针对有内存问题的用户进行监控,只能广撒网,用户体验影响较大。同时,通过某些比较通用的堆栈分配的内存无法定位出实际的内存使用场景,对于循环引用等常见泄漏也无法分析。

为了解决头条,抖音等各产品日益严峻的内存问题,我们自行研发了一款基于内存快照技术的线上方案,我们称之为——线上 Memory Graph。上线后接入了集团内几乎所有的产品,帮助各产品修复了多年的历史问题,OOM 率降低一个数量级,3 个月之内抖音最新版本 OOM 率下降了 50%,头条下降了 60%。线上突发 OOM 问题定位效率大大提升,彻底告别了线上 OOM 问题归因“两眼一抹黑”的时代。

线上 Memory Graph 核心的原理是扫描进程中所有 Dirty 内存,通过内存节点中保存的其他内存节点的地址值建立起内存节点之间的引用关系的有向图,用于内存问题的分析定位,整个过程不使用任何私有 API。这套方案具备的能力如下:


  • 完整还原用户当时的内存状态。
  • 量化线上用户的大内存占用和内存泄漏,可以精确的回答 App 内存到底大在哪里这个问题。
  • 通过内存节点符号和引用关系图回答内存节点为什么存活这个问题。
  • 严格控制性能损耗,只有当内存占用超过异常阈值的时候才会触发分析。没有运行时开销,只有采集时开销,对 99.9%正常使用的用户几乎没有任何影响。
  • 支持主要的编程语言,包括 OC,C/C++,Swift,Rust 等。


  • 内存快照采集

    线上 Memory Graph 采集内存快照主要是为了获取当前运行状态下所有内存对象以及对象之间的引用关系,用于后续的问题分析。主要需要获取的信息如下:

    • 所有内存的节点,以及其符号信息(如OC/Swift/C++ 实例类名,或者是某种有特殊用途的 VM 节点的 tag 等)。
    • 节点之间的引用关系,以及符号信息(偏移,或者实例变量名),OC/Swift成员变量还需要记录引用类型。

    由于采集的过程发生在程序正常运行的过程中,为了保证不会因为采集内存快照导致程序运行异常,整个采集过程需要在一个相对静止的运行环境下完成。因此,整个快照采集的过程大致分为以下几个步骤:

    1. 挂起所有非采集线程。
    2. 获取所有的内存节点,内存对象引用关系以及相应的辅助信息。
    3. 写入文件。
    4. 恢复线程状态。

    下面会分别介绍整个采集过程中一些实现细节上的考量以及收集信息的取舍。

    内存节点的获取

    程序的内存都是由虚拟内存组成的,每一块单独的虚拟内存被称之为VM Region,通过 mach 内核的vm_region_recurse/vm_region_recurse64函数我们可以遍历进程内所有VM Region,并通过vm_region_submap_info_64结构体获取以下信息:

    • 虚拟地址空间中的地址和大小。
    • Dirty 和 Swapped 内存页数,表示该VM Region的真实物理内存使用。
    • 是否可交换,Text 段、共享 mmap 等只读或随时可以被交换出去的内存,无需关注。
    • user_tag,用户标签,用于提供该VM Region的用途的更准确信息。

    大多数 VM Region 作为一个单独的内存节点,仅记录起始地址和 Dirty、Swapped 内存作为大小,以及与其他节点之间的引用关系;而 libmalloc 维护的堆内存所在的 VM Region 则由于往往包含大多数业务逻辑中的 Objective-C 对象、C/C++对象、buffer 等,可以获取更详细的引用信息,因此需要单独处理其内部节点、引用关系。

    在 iOS 系统中为了避免所有的内存分配都使用系统调用产生性能问题,相关的库负责一次申请大块内存,再在其之上进行二次分配并进行管理,提供给小块需要动态分配的内存对象使用,称之为堆内存。程序中使用到绝大多数的动态内存都通过堆进行管理,在 iOS 操作系统上,主要的业务逻辑分配的内存都通过libmalloc进行管理,部分系统库为了性能也会使用自己的单独的堆管理,例如WebKit内核使用bmallocCFNetwork也使用自己独立的堆,在这里我们只关注libmalloc内部的内存管理状态,而不关心其它可能的堆(即这部分特殊内存会以VM Region的粒度存在,不分析其内部的节点引用关系)。

    我们可以通过malloc_get_all_zones获取libmalloc内部所有的zone,并遍历每个zone中管理的内存节点,获取 libmalloc 管理的存活的所有内存节点的指针和大小。

    符号化

    获取所有内存节点之后,我们需要为每个节点找到更加详细的类型名称,用于后续的分析。其中,对于 VM Region 内存节点,我们可以通过 user_tag 赋予它有意义的符号信息;而堆内存对象包含 raw buffer,Objective-C/Swift、C++等对象。对于 Objective-C/Swift、C++这部分,我们通过内存中的一些运行时信息,尝试符号化获取更加详细的信息。

    Objective/Swift 对象的符号化相对比较简单,很多三方库都有类似实现,Swift在内存布局上兼容了Objective-C,也有isa指针,objc相关方法可以作用于两种语言的对象上。只要保证 isa 指针合法,对象实例大小满足条件即可认为正确。

    C++对象根据是否包含虚表可以分成两类。对于不包含虚表的对象,因为缺乏运行时数据,无法进行处理。

    对于对于包含虚表的对象,在调研 mach-o 和 C++的 ABI 文档后,可以通过 std::type_info 和以下几个 section 的信息获取对应的类型信息。


  • type_name string
     - 类名对应的常量字符串,存储在__TEXT/__RODATA段的__const section中。
  • type_info - 存放在__DATA/__DATA_CONST段的__const section中。
  • vtable - 存放在__DATA/__DATA_CONST段的__const section中。

  • 在 iOS 系统内,还有一类特殊的对象,即CoreFoundation。除了我们熟知的CFStringCFDictionary外等,很多很多系统库也使用 CF 对象,比如CGImageCVObject等。从它们的 isa 指针获取的Objective-C类型被统一成__NSCFType。由于 CoreFoundation 类型支持实时的注册、注销类型,为了细化这部分的类型,我们通过逆向拿到 CoreFoundation 维护的类型 slot 数组的位置并读取其数据,保证能够安全的获取准确的类型。


    引用关系的构建

    整个内存快照的核心在于重新构建内存节点之间的引用关系。在虚拟内存中,如果一个内存节点引用了其它内存节点,则对应的内存地址中会存储指向对方的指针值。基于这个事实我们设计了以下方案:

    1. 遍历一个内存节点中所有可能存储了指针的范围获取其存储的值 A。
    2. 搜索所有获得的节点,判断 A 是不是某一个内存节点中任何一个字节的地址,如果是,则认为是一个引用关系。
    3. 对所有内存节点重复以上操作。

    对于一些特定的内存区域,为了获取更详细的信息用于排查问题,我们对栈内存以及 Objective-C/Swift 的堆内存进行了一些额外的处理。

    其中,栈内存也以VM Region的形式存在,栈上保存了临时变量和 TLS 等数据,获取相应的引用信息可以帮助排查诸如 autoreleasepool 造成的内存问题。由于栈并不会使用整个栈内存,为了获取 Stack 的引用关系,我们根据寄存器以及栈内存获取当前的栈可用范围,排除未使用的栈内存造成的无效引用。


    而对于Objective-C/Swift对象,由于运行时包含额外的信息,我们可以获得Ivar的强弱引用关系以及Ivar的名字,带上这些信息有助于我们分析问题。通过获得Ivar的偏移,如果找到的引用关系的偏移和Ivar的偏移一致,则认为这个引用关系就是这个Ivar,可以将Ivar相关的信息附加上去。

    数据上报策略

    我们在 App 内存到达设定值后采集 App 当时的内存节点和引用关系,然后上传至远端进行分析,可以精准的反映 App 当时的内存状态,从而定位问题,总的流程如下:


    整个线上 Memory Graph 模块工作的完整流程如上图所示,主要包括:

    1. 后台线程定时检测内存占用,超过设定的危险阈值后触发内存分析。
    2. 内存分析后数据持久化,等待下次上报。
    3. 原始文件压缩打包。
    4. 检查后端上报许可,因为单个文件很大,后端可能会做一些限流的策略。
    5. 上报到后端分析,如果成功后清除文件,失败后会重试,最多三次之后清除,防止占用用户太多的磁盘空间。

    后台分析

    这是字节监控平台 Memory Graph 单点详情页的一个 case:



    我们可以看到这个用户的内存占用已经将近 900MB,我们分析时候的思路一般是:

    1. 从对象数量和对象内存占用这两个角度尝试找到类列表中最有嫌疑的那个类。
    2. 从对象列表中随机选中某个实例,向它的父节点回溯引用关系,找到你认为最有嫌疑的一条引用路径。
    3. 点击引用路径模块右上角的Add Tag来判断当前选中的引用路径在同类对象中出现过多少次。
    4. 确认有问题的引用路径之后再判断究竟是哪个业务模块发生的问题。


    通过上图中引用路径的分析我们发现,所有的图片最终都被TTImagePickController这个类持有,最终排查到是图片选择器模块一次性把用户相册中的所有图片都加载到内存里,极端情况下会发生这个问题。

    整体性能和稳定性

    采集侧优化策略

    由于整个内存空间一般包含的内存节点从几十万到几千万不等,同时程序的运行状态瞬息万变,采集过程有着很大的性能和稳定性的压力。

    我们在前面的基础上还进行了一些性能优化:

    • 写出采集数据使用mmap映射,并自定义二进制格式保证顺序读写。
    • 提前对内存节点进行排序,建立边引用关系时使用二分查找。通过位运算对一些非法内存地址进行提前快速剪枝。

    对于稳定性部分,我们着重考虑了下面几点:

    • 死锁

    由于无法保证 Objective-C 运行时锁的状态,我们将需要通过运行时 api 获取的信息在挂起线程前提前缓存。同时,为了保证libmalloc锁的状态安全,在挂起线程后我们对 libmalloc 的锁状态进行了判断,如果已经锁住则恢复线程重新尝试挂起,避免堆死锁。

    • 非法内存访问

    在挂起所有其他线程后,为了减少采集本身分配的内存对采集的影响,我们使用了一个单独的malloc_zone管理采集模块的内存使用。

    性能损耗

    因为在数据采集的时候需要挂起所有线程,会导致用户感知到卡顿,所以字节模块还是有一定性能损耗的,经过我们测试,在iPhone8 Plus设备上,App 占用 1G 内存时,采集用时 1.5-2 秒,采集时额外内存消耗 10-20MB,生成的文件 zip 后大小在 5-20MB。

    为了严格控制性能损耗,线上 Memory Graph 模块会应用以下策略,避免太频繁的触发打扰用户正常使用,避免自身内存和磁盘等资源过多的占用:



    稳定性

    该方案已经在字节全系产品线上稳定运行了 6 个月以上,稳定性和成功率得到了验证,目前单次采集成功率可以达到 99.5%,剩下的失败基本都是由于内存紧张提前 OOM,考虑到大多数应用只有不到千分之一的用户会触发采集,这种情况属于极低概率事件。

    试用路径

    目前,线上 Memory Graph 已搭载在字节跳动火山引擎旗下应用性能管理平台(APMInsight)上赋能给外部开发者使用。

    APMInsight 的相关技术经过今日头条、抖音、西瓜视频等众多应用的打磨,已沉淀出一套完整的解决方案,能够定位移动端、浏览器、小程序等多端问题,除了支持崩溃、错误、卡顿、网络等基础问题的分析,还提供关联到应用启动、页面浏览、内存优化的众多功能。

    摘自字节跳动技术团队 :https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247486858&idx=1&sn=ec5964b0248b3526836712b26ef1b077&chksm=e9d0c668dea74f7e1e16cd5d65d1436c28c18e80e32bbf9703771bd4e0563f64723294ba1324&cur_album_id=1590407423234719749&scene=190#rd




    收起阅读 »

    如何清晰地掌握 Android 应用中后台任务的执行情况?

    Android Studio 包含了许多像 布局检查器 和 数据库检查器 这样的检查器,来帮助您调查并了解应用在运行时的内部状态。在 Android Studio Arctic Fox 中,我们发布了一个新的检查器 (Background Task Inspe...
    继续阅读 »

    Android Studio 包含了许多像 布局检查器数据库检查器 这样的检查器,来帮助您调查并了解应用在运行时的内部状态。在 Android Studio Arctic Fox 中,我们发布了一个新的检查器 (Background Task Inspector),用于帮助您监控和调试在应用中使用 WorkManager 2.5.0 或更高版本所调度的 Worker。


    对于运行后台的异步任务,甚至是在应用被关闭之后的情况下,都推荐使用 WorkManager。虽然可以很方便的将任务配置成 WorkManager 的 Worker,但将 Worker 加入到队列中后就很难监控它的执行情况,遇到问题也不方便调试。


    您可以通过后台任务检查器轻松地监控一个 Worker 的工作状态,查看它和与其链接的其他 Worker 的关系,或者检查 Worker 的输出、频率及其他与时间相关的信息。让我们通过一个示例项目来看看后台任务检查器能做些什么。


    我将使用 architectural-components 仓库 中的 WorkManager 示例应用来演示后台任务检查器 (需要将工程中 versions.gradle 里的 versions.work 设置为 2.5.0 或更高版本以使得 Background Task Inspect 更好的工作)。如果您想试一试,可以检出该仓库并随着阅读文章一起尝试。该应用使用 WorkManager 将用户所选择的滤镜应用在已挑选的照片上。用户可以通过该应用在图库中选择一张图片或者简单地使用一张库存照片。为了演示后台任务检查器如何工作,我将会运行应用并选择一张图片来应用滤镜。


    △ 为选定的图像应用所选的滤镜


    △ 为选定的图像应用所选的滤镜


    这些滤镜都是作为 WorkManager Worker 实现的。稍等一会儿该应用就会展示应用了所选滤镜的图片。在不了解示例应用的情况下,来看看我还能通过后台任务检查器知道些什么。


    选择菜单栏上的 View > Tool Windows > App Inspection 打开后台任务检查器。


    △ View > Tool Windows > App Inspection


    △ View > Tool Windows > App Inspection


    在 App Inspection (应用检查) 窗口中选择 Background Task Inspector 栏后,我在 API 级别 26 或更高的设备/模拟器上再次运行该应用。如果没有自动选中应用,在下拉菜单中选择应用进程。连接到应用进程后,就可以回到我正在运行的应用,选择所有的滤镜并点击 "APPLY"。此时我可以在后台任务检查器中看到运行中的作业列表。


    △ 正在运行的作业列表


    △ 正在运行的作业列表


    后台任务检查器列出了所有正在运行、已失败和已完成作业的类名、当前状态、开始时间、重试次数以及输出数据。点击列表中的一个作业打开 Work Details 面板。


    △ Work Details 面板


    △ Work Details 面板


    该面板提供了 Worker 的 Description (描述)、Execution (执行情况)、WorkContinuation (工作延续性) 和 Results (结果)。让我们来仔细看看每个部分。


    △ Work Details


    △ Work Details


    Description (描述) 一节列出了 Worker 包含完全限定软件包名称、指派给它的标签和它的 UUID。


    △ Execution


    △ Execution


    接下来,Execution (执行情况) 一节展示了 Worker 的约束 (如果有)、运行频率、状态以及是哪个类创建了该 worker 并将其加入了队列。


    △ WorkContinuation


    △ WorkContinuation


    WorkContinuation (工作延续性) 一节显示了该 Worker 在工作链上的位置。您可以检查前一个、后一个或工作链上的其他 Worker (如果有)。您可以通过点击另一个 Worker 的 UUID 导航到它的详情。在这个工作链上,我能看到应用使用了 5 个不同的 Worker。Worker 的数量根据用户选择的滤镜情况可能有所不同。


    这是个很棒的功能,但当您面对不熟悉的应用时不一定能想象出工作链。而后台任务检查器另一个很棒的特性就是它能够以图形化的形式展示工作链。仅需点击 WorkContinuation 一节中的 "Show in graph" 链接或点击作业列表顶部的 "show Graph View" 按钮来切换到 Graph View 即可。


    △ Graph View


    △ Graph View


    Graph View 能帮您了解 Worker 的顺序、在不同阶段之间传递的数据以及它们各自的状态。


    △ Results


    △ Results


    Work Details 面板的最后一节是 Results 窗格。在这一节您能看到选中的 Worker 的开始时间、重试次数及输出数据。


    现在假设我想测试当一个 Worker 停止时会发生什么。为了实现这个目的,我将再次运行应用,选择 Worker,等它的状态变为正在运行后点击左上角的 "Cancel Selected Work" 按钮。一旦我这么做了,我选择的 Worker 和链中剩余的 Worker 的状态都将变为 Canceled。


    △ 您可以取消任何正在运行的 Worker


    △ 您可以取消任何正在运行的 Worker


    如果您的应用中包含像这样复杂的链式关系,那 Graph View 就会很有用。您能够在这个图中快速查看一组复杂的 Worker 之间的关系并监控它们的进展。


    △ WorkManager 艺术展示 =)


    △ WorkManager 艺术展示 =)


    如果您想用后台任务检查器尝试一些更复杂的图形或者制作一些 WorkManager 艺术,请参阅 DummyWorker 代码,并将其 加入到 continuation 对象 中。


    后台任务检查器将会跟随 Android Studio Arctic Fox 的发布一同推出,但您现在就可以在 最新的 Arctic Fox 版本 中试用!如果您的应用使用了 WorkManager,请尝试使用并告诉我们您的想法,或者和我们分享您的 WorkManager 艺术!



    收起阅读 »

    它来了!Flutter 应用内调试工具 UME 开源啦

    作者:字节跳动终端技术 —— 赵瑞 先说重点 Pub 地址:pub.dev/packages/fl… GitHub 地址:github.com/bytedance/f… 背景 字节跳动已有累计超过 70 款 App 使用了 Flutter...
    继续阅读 »


    作者:字节跳动终端技术 —— 赵瑞


    先说重点



    背景


    字节跳动已有累计超过 70 款 App 使用了 Flutter 技术,公司内有超过 600 位 Flutter 开发者。在这一数字背后,有一条完整的 Flutter 基础设施链路作为支撑。


    UME 是由字节跳动 Flutter Infra 团队出品的 Flutter 应用内调试工具,目的是在脱离 Flutter IDE 与 DevTools 的情况下,提供应用内的调试功能。


    在字节跳动,UME 内部版已打磨了近一年时间,服务了近二十个 App,众多插件功能广受开发者好评。本次发布的开源版 UME 提供了 10 个不同功能的调试插件,覆盖 UI 检查、性能工具、代码查看、日志查看等众多功能。无论是设计师、产品经理、研发工程师或质量工程师,都能直接从应用内获取有用信息,从而提升整个团队的研发、调试与验收效率。


    功能介绍


    UI 插件包



















    Widget 信息 Widget 详情
    颜色吸管 对齐标尺

    UI 检查插件包,提供了通过点选 widget 获取 Widget 基本信息、代码所在目录、Widget 层级结构、RenderObject 的 build 链与描述的能力,颜色吸管与对齐标尺在视觉验收环节提供有力帮助。


    代码查看


    代码查看


    代码查看插件,默认基于 WidgetInspectorService 提取 creationLocation, 拿到当前页面的 library,再通过 VM Service 获取对应代码内容。


    允许用户输入关键字,通过遍历 scriptList 对 library 进行模糊匹配,实现对任意代码内容的查看能力。


    日志展示


    日志展示


    通过重定向 foundation 包中的 debugPrint,实现对日志输出函数的插桩处理,并记录日志输出时间等额外信息,通过统一面板提供筛选、导出等功能。


    性能插件包















    性能浮层 内存信息

    性能插件包将 Flutter 官方提供的性能浮层引入,实现脱离 DevTools 查看性能浮层的能力;内存信息方面提供了当前 VM 对象实例数量与内存占用大小等信息。


    设备信息插件包















    设备信息 CPU 信息

    设备信息插件展示了 device_info Plugin 提供的信息;CPU 信息插件基于 system_info Plugin,直接从 Dart 层读取系统基础信息。


    开发自定义插件


    除了上述的 UME 内置插件外,开发者可以基于 UME 提供的统一插件管理与基础服务,开发适合自己业务的插件包。


    实现方式也非常简单,只需要实现 Pluggable 虚类中的方法,提供插件必要信息即可,代码示例如下图。


    自定义插件


    开发者可以参考开源仓库中的 custom_plugin_example 示例,以及 kits 目录下的所有插件包代码,来了解如何实现一个自定义插件包。


    访问基础服务


    为简化插件开发,提高代码复用性,UME 封装了 Inspector、VM Service 等作为基础服务,插件可方便地拓展能力。


    VM Service mixin


    除此之外,UME 还提供了 FloatingWidget 通用浮窗容器、StoreMixin 存储能力等,供插件使用。


    欢迎参与开源贡献与共建


    由于很多功能依赖引擎及工具链的改动,因此开源版的 UME 相比于公司内部版本在功能上进行了很多精简。但开发团队也在不断寻求解决方案,避免修改引擎,或将改动合入官方仓库,将更多实用功能引入开源版 UME。


    我们鼓励广大开发者,参与到 UME 的开发与生态建设上。比如开发更多实用的插件并贡献给社区,在 GitHub Issues 上提交功能建议、问题反馈,或修复问题并提交 Pull Request。


    欢迎各位开发者加入字节跳动 Flutter 技术交流群参与技术交流与讨论。


    关于字节终端技术团队


    字节跳动终端技术团队(Client Infrastructure)是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、瓜瓜龙等,在移动端、Web、Desktop等各终端都有深入研究。

    收起阅读 »

    自如客APP裸眼3D效果的实现

    3d
    背景 移动端界面设计如此火热的今天,各类大厂的设计规范和设计语言已经非常的成熟,我们想做一些在这套成熟的设计规范之外的尝试和创新,所以有别于传统的banner交互形式成为了我们的发力点。 设计理念 由于app版面空间有限,除了功能导向、阅读习惯和设计美观...
    继续阅读 »

    我的影片 1.2021-07-26 19_41_36.gif


    背景


    移动端界面设计如此火热的今天,各类大厂的设计规范和设计语言已经非常的成熟,我们想做一些在这套成熟的设计规范之外的尝试和创新,所以有别于传统的banner交互形式成为了我们的发力点。


    设计理念


    由于app版面空间有限,除了功能导向、阅读习惯和设计美观外,自如想在既定的框下,做一下不同的设计尝试,哪怕这种尝试只能提升用户1%的观感。可能租了几年自如的房子,用了几年自如客app,你可能也不会注意到一些小的细节。但如果哪天,作为用户的你突然发现了这个隐藏的“彩蛋”,看到了自如在这些小细节上的用心,我相信那天你将会对自如这个品牌有更深层次的认识和理解。


    裸眼3D技术一般都是应用在裸眼3D大屏、全息投影等等比较常见的场景中,在APP的banner上应用,的确也是一次全新的尝试。我们通过借助移动设备上的传感器、以及自身的屏显清晰度、画面呈现,将2D影像转化为景深效果,以呈现出不用"3D"眼镜就可看到的3D效果。


    实现方式


    以下以Android为例,介绍一下该效果的实现方式。


    分层


    自如客app的banner其实一直在创新当中,有专门注意过的同学可能知道,在裸眼3D效果之前,自如客app其实就已经实现了分层,当时为了实现更加自然和精致的切换效果:在每个banner滑入滑出的时候,底部其实会在原地进行渐显渐隐,内容会跟随手势滑入滑出。此次为了实现3D效果,我们在以前分层的基础上加了一层中景,将原有的前景拆分为前景和中景。


    image.png


    上图的sl_bg为背景,pv_middle为中景,sl为前景


    由于切换的交互,实际上banner使用了两个viewpager进行了联动。背景在最底层的viewpager里面,中景和前景在另外一个viewpager里。


    跟手位移


    打开自如客app后,用户操作设备可以明显感受到画面的错位移动,造成视觉上的景深效果。这种错位移动其实就是借助设备本身的传感器来实现的,具体实现方式是我们让中景始终保持不动,同时从设备传感器获取当前设备对应的倾斜角,根据倾斜角计算出背景和前景的移动距离,然后执行背景和前景移动的动作。如下图所示:


    image.png


    为了使用的方便,我们封装了一个SensorLayout,专门用于根据设备的倾斜角执行内容的位移; SensorLayout内部的主要实现:


    注册对应的传感器


    mSensorManager = (SensorManager) getContext().getSystemService(Context.SENSOR_SERVICE);
    // 重力传感器
    mAcceleSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
    // 地磁场传感器
    mMagneticSensor = mSensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);

    mSensorManager.registerListener(this, mAcceleSensor, SensorManager.SENSOR_DELAY_GAME);
    mSensorManager.registerListener(this, mMagneticSensor, SensorManager.SENSOR_DELAY_GAME);

    计算偏转角度


    if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
    mAcceleValues = event.values;
    }
    if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
    mMageneticValues = event.values;
    }

    float[] values = new float[3];
    float[] R = new float[9];
    SensorManager.getRotationMatrix(R, null, mAcceleValues, mMageneticValues);
    SensorManager.getOrientation(R, values);
    // x轴的偏转角度
    values[1] = (float) Math.toDegrees(values[1]);
    // y轴的偏转角度
    values[2] = (float) Math.toDegrees(values[2]);


    通过重力传感器和地磁场传感器,获取设备的偏转角度


    根据偏转角度执行滑动


    if (mDegreeY <= 0 && mDegreeY > mDegreeYMin) {
    hasChangeX = true;
    scrollX = (int) (mDegreeY / Math.abs(mDegreeYMin) * mXMoveDistance*mDirection);
    } else if (mDegreeY > 0 && mDegreeY < mDegreeYMax) {
    hasChangeX = true;
    scrollX = (int) (mDegreeY / Math.abs(mDegreeYMax) * mXMoveDistance*mDirection);
    }
    if (mDegreeX <= 0 && mDegreeX > mDegreeXMin) {
    hasChangeY = true;
    scrollY = (int) (mDegreeX / Math.abs(mDegreeXMin) * mYMoveDistance*mDirection);
    } else if (mDegreeX > 0 && mDegreeX < mDegreeXMax) {
    hasChangeY = true;
    scrollY = (int) (mDegreeX / Math.abs(mDegreeXMax) * mYMoveDistance*mDirection);
    }
    smoothScrollTo(hasChangeX ? scrollX : mScroller.getFinalX(), hasChangeY ? scrollY : mScroller.getFinalY());

    mDegreeX即为第二部中获取的偏转角度,mDegreeXMin和mDegreeXMax为X轴可发生偏转位移的角度最大值和最小值,mYMoveDistance即为Y轴上的最大偏移距离(围绕X轴发生旋转,视图会沿Y轴上发生位移);Y轴上的偏转同理;就算好X轴和Y轴的偏移距离后,使用scroller进行滑动;


    实现总结


    读到这里,相信大家已经基本了解了这套banner的实现方案。Android端在布局上进行了分层,中景位置不变,借助重力传感器和地磁场传感器获取偏转角度,根据角度使背景和前景进行错位移动。iOS端的实现原理也基本一致,不再赘述。



    本文作者:自如大前端研发中心-黄进




    收起阅读 »

    1分钟教你App点击秒开技能

    1分钟教你App点击秒开技能背景刚开始开发应用,不少人没有注意到点击桌面图标打开App有短暂的白屏或者黑屏的情况,短暂的白屏或者黑屏多多少少会影响用户的体验。其实只要我们简单设置一下,你的App就没有了白屏或者黑屏,实现秒开的效果。哪里不会点哪里,So eas...
    继续阅读 »

    1分钟教你App点击秒开技能

    背景

    刚开始开发应用,不少人没有注意到点击桌面图标打开App有短暂的白屏或者黑屏的情况,短暂的白屏或者黑屏多多少少会影响用户的体验。其实只要我们简单设置一下,你的App就没有了白屏或者黑屏,实现秒开的效果。哪里不会点哪里,So easy...

    步骤一:设置启动页主题

    //在style.xml添加一个启动页主题

    步骤二:给启动页设置主题


    android:name=".LauncherActivity"
    android:theme="@style/LauncherTheme"
    android:screenOrientation="portrait">
    ...


    步骤三:设置启动页主题windowBackground样式

    drawable/bg_splash.xml

    注意:启动页的layout.xml也需要用同一个背景图

    android:background="@drawable/bg_splash"

    "1.0" encoding="utf-8"?>
    xmlns:android="http://schemas.android.com/apk/res/android">
    android:drawable="#ffffff">










    android:bottom="40dp">
    android:gravity="bottom|clip_vertical"
    android:src="@drawable/launcher_bottom"/>



    步骤四:恢复默认主题

    protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setTheme(R.style.AppTheme);//恢复默认主题样式
    setContentView(R.layout.activity_main);
    }

    结语

    效果图就不放了,这种实现方式是市面比较流行的做法,底部LOGO+全白的背景颜色,一般LOGO上面可以添加广告。冷启动白屏优化就是这么简单。如果你们有更好的秒开启动方案,可以留言。共同学习进步!

    收起阅读 »

    Android 禁止截屏、录屏 — 解决PopupWindow无法禁止录屏问题

    项目开发中,为了用户信息的安全,会有禁止页面被截屏、录屏的需求。这类资料,在网上有很多,一般都是通过设置Activity的Flag解决,如://禁止页面被截屏、录屏 getWindow().addFlags(WindowManager.LayoutParams...
    继续阅读 »

    项目开发中,为了用户信息的安全,会有禁止页面被截屏、录屏的需求。

    这类资料,在网上有很多,一般都是通过设置Activity的Flag解决,如:

    //禁止页面被截屏、录屏
    getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);

    这种设置可解决一般的防截屏、录屏的需求。
    如果页面中有弹出Popupwindow,在录屏视频中的效果是:

    非Popupwindow区域为黑色 但Popupwindow区域仍然是可以看到的

    如下面两张Gif图所示:

    未设置FLAG_SECURE,录屏的效果,如下图(git图片中间的水印忽略):

    普通界面录屏效果.gif

    设置了FLAG_SECURE之后,录屏的效果,如下图(git图片中间的水印忽略):
    界面仅设置了FLAG_SECURE.gif(图片中间的水印忽略)

    原因分析

    看到了上面的效果,我们可能会有疑问PopupWindow不像Dialog有自己的window对象,而是使用WindowManager.addView方法将View显示在Activity窗体上的。那么,Activity已经设置了FLAG_SECURE,为什么录屏时还能看到PopupWindow?

    我们先通过getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);来分析下源码:

    1、Window.java

    //window布局参数
    private final WindowManager.LayoutParams mWindowAttributes =
    new WindowManager.LayoutParams();

    //添加标识
    public void addFlags(int flags) {
    setFlags(flags, flags);
    }

    //通过mWindowAttributes设置标识
    public void setFlags(int flags, int mask) {
    final WindowManager.LayoutParams attrs = getAttributes();
    attrs.flags = (attrs.flags&~mask) | (flags&mask);
    mForcedWindowFlags |= mask;
    dispatchWindowAttributesChanged(attrs);
    }

    //获得布局参数对象,即mWindowAttributes
    public final WindowManager.LayoutParams getAttributes() {
    return mWindowAttributes;
    }

    通过源码可以看到,设置window属性的源码非常简单,即:通过window里的布局参数对象mWindowAttributes设置标识即可。

    2、PopupWindow.java

    //显示PopupWindow
    public void showAtLocation(View parent, int gravity, int x, int y) {
    mParentRootView = new WeakReference<>(parent.getRootView());
    showAtLocation(parent.getWindowToken(), gravity, x, y);
    }

    //显示PopupWindow
    public void showAtLocation(IBinder token, int gravity, int x, int y) {
    if (isShowing() || mContentView == null) {
    return;
    }

    TransitionManager.endTransitions(mDecorView);

    detachFromAnchor();

    mIsShowing = true;
    mIsDropdown = false;
    mGravity = gravity;

    //创建Window布局参数对象
    final WindowManager.LayoutParams p =createPopupLayoutParams(token);
    preparePopup(p);

    p.x = x;
    p.y = y;

    invokePopup(p);
    }

    //创建Window布局参数对象
    protected final WindowManager.LayoutParams createPopupLayoutParams(IBinder token) {
    final WindowManager.LayoutParams p = new WindowManager.LayoutParams();
    p.gravity = computeGravity();
    p.flags = computeFlags(p.flags);
    p.type = mWindowLayoutType;
    p.token = token;
    p.softInputMode = mSoftInputMode;
    p.windowAnimations = computeAnimationResource();
    if (mBackground != null) {
    p.format = mBackground.getOpacity();
    } else {
    p.format = PixelFormat.TRANSLUCENT;
    }
    if (mHeightMode < 0) {
    p.height = mLastHeight = mHeightMode;
    } else {
    p.height = mLastHeight = mHeight;
    }
    if (mWidthMode < 0) {
    p.width = mLastWidth = mWidthMode;
    } else {
    p.width = mLastWidth = mWidth;
    }
    p.privateFlags = PRIVATE_FLAG_WILL_NOT_REPLACE_ON_RELAUNCH
    | PRIVATE_FLAG_LAYOUT_CHILD_WINDOW_IN_PARENT_FRAME;
    p.setTitle("PopupWindow:" + Integer.toHexString(hashCode()));
    return p;
    }

    //将PopupWindow添加到Window上
    private void invokePopup(WindowManager.LayoutParams p) {
    if (mContext != null) {
    p.packageName = mContext.getPackageName();
    }

    final PopupDecorView decorView = mDecorView;
    decorView.setFitsSystemWindows(mLayoutInsetDecor);

    setLayoutDirectionFromAnchor();

    mWindowManager.addView(decorView, p);

    if (mEnterTransition != null) {
    decorView.requestEnterTransition(mEnterTransition);
    }
    }

    通过PopupWindow的源码分析,我们不难看出,在调用showAtLocation时,会单独创建一个WindowManager.LayoutParams布局参数对象,用于显示PopupWindow,而该布局参数对象上并未设置任何防止截屏Flag。

    如何解决

    原因既然找到了,那么如何处理呢?
    再回头分析下Window的关键代码:

    //通过mWindowAttributes设置标识
    public void setFlags(int flags, int mask) {
    final WindowManager.LayoutParams attrs = getAttributes();
    attrs.flags = (attrs.flags&~mask) | (flags&mask);
    mForcedWindowFlags |= mask;
    dispatchWindowAttributesChanged(attrs);
    }

    其实只需要获得WindowManager.LayoutParams对象,再设置上flag即可。
    但是PopupWindow并没有像Activity一样有直接获得window的方法,更别说设置Flag了。我们再分析下PopupWindow的源码:

    //将PopupWindow添加到Window上
    private void invokePopup(WindowManager.LayoutParams p) {
    if (mContext != null) {
    p.packageName = mContext.getPackageName();
    }

    final PopupDecorView decorView = mDecorView;
    decorView.setFitsSystemWindows(mLayoutInsetDecor);

    setLayoutDirectionFromAnchor();

    //添加View
    mWindowManager.addView(decorView, p);

    if (mEnterTransition != null) {
    decorView.requestEnterTransition(mEnterTransition);
    }
    }

    我们调用showAtLocation,最终都会执行mWindowManager.addView(decorView, p);
    那么是否可以在addView之前获取到WindowManager.LayoutParams呢?

    答案很明显,默认是不可以的。因为PopupWindow并没有公开获取WindowManager.LayoutParams的方法,而且mWindowManager也是私有的。

    如何才能解决呢?
    我们可以通过hook的方式解决这个问题。我们先使用动态代理拦截PopupWindow类的addView方法,拿到WindowManager.LayoutParams对象,设置对应Flag,再反射获得mWindowManager对象去执行addView方法。

    风险分析:

    不过,通过hook的方式也有一定的风险,因为mWindowManager是私有对象,不像Public的API,谷歌后续升级Android版本不会考虑其兼容性,所以有可能后续Android版本中改了其名称,那么我们通过反射获得mWindowManager对象不就有问题了。不过从历代版本的Android源码去看,mWindowManager被改的几率不大,所以hook也是可以用的,我们尽量写代码时考虑上这种风险,避免以后出问题。

    public class PopupWindow {
    ......
    private WindowManager mWindowManager;
    ......
    }

    而addView方法是ViewManger接口的公共方法,我们可以放心使用。

    public interface ViewManager
    {
    public void addView(View view, ViewGroup.LayoutParams params);
    public void updateViewLayout(View view, ViewGroup.LayoutParams params);
    public void removeView(View view);
    }

    功能实现

    考虑到hook的可维护性和扩展性,我们将相关代码封装成一个独立的工具类吧。

    package com.ccc.ddd.testpopupwindow.utils;

    import android.os.Handler;
    import android.view.WindowManager;
    import android.widget.PopupWindow;

    import java.lang.reflect.Field;
    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Method;
    import java.lang.reflect.Proxy;

    public class PopNoRecordProxy implements InvocationHandler {
    private Object mWindowManager;//PopupWindow类的mWindowManager对象

    public static PopNoRecordProxy instance() {
    return new PopNoRecordProxy();
    }

    public void noScreenRecord(PopupWindow popupWindow) {
    if (popupWindow == null) {
    return;
    }
    try {
    //通过反射获得PopupWindow类的私有对象:mWindowManager
    Field windowManagerField = PopupWindow.class.getDeclaredField("mWindowManager");
    windowManagerField.setAccessible(true);
    mWindowManager = windowManagerField.get(popupWindow);
    if(mWindowManager == null){
    return;
    }
    //创建WindowManager的动态代理对象proxy
    Object proxy = Proxy.newProxyInstance(Handler.class.getClassLoader(), new Class[]{WindowManager.class}, this);

    //注入动态代理对象proxy(即:mWindowManager对象由proxy对象来代理)
    windowManagerField.set(popupWindow, proxy);
    } catch (IllegalAccessException e) {
    e.printStackTrace();
    } catch (NoSuchFieldException e) {
    e.printStackTrace();
    }
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
    //拦截方法mWindowManager.addView(View view, ViewGroup.LayoutParams params);
    if (method != null && method.getName() != null && method.getName().equals("addView")
    && args != null && args.length == 2) {
    //获取WindowManager.LayoutParams,即:ViewGroup.LayoutParams
    WindowManager.LayoutParams params = (WindowManager.LayoutParams) args[1];
    //禁止录屏
    setNoScreenRecord(params);
    }
    } catch (Exception ex) {
    ex.printStackTrace();
    }
    return method.invoke(mWindowManager, args);
    }

    /**
    * 禁止录屏
    */

    private void setNoScreenRecord(WindowManager.LayoutParams params) {
    setFlags(params, WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE);
    }

    /**
    * 允许录屏
    */

    private void setAllowScreenRecord(WindowManager.LayoutParams params) {
    setFlags(params, 0, WindowManager.LayoutParams.FLAG_SECURE);
    }

    /**
    * 设置WindowManager.LayoutParams flag属性(参考系统类Window.setFlags(int flags, int mask))
    *
    * @param params WindowManager.LayoutParams
    * @param flags The new window flags (see WindowManager.LayoutParams).
    * @param mask Which of the window flag bits to modify.
    */

    private void setFlags(WindowManager.LayoutParams params, int flags, int mask) {
    try {
    if (params == null) {
    return;
    }
    params.flags = (params.flags & ~mask) | (flags & mask);
    } catch (Exception ex) {
    ex.printStackTrace();
    }
    }
    }

    Popwindow禁止录屏工具类的使用,代码示例:

        //创建PopupWindow
    //正常项目中,该方法可改成工厂类
    //正常项目中,也可自定义PopupWindow,在其类中设置禁止录屏
    private PopupWindow createPopupWindow(View view, int width, int height) {
    PopupWindow popupWindow = new PopupWindow(view, width, height);
    //PopupWindow禁止录屏
    PopNoRecordProxy.instance().noScreenRecord(popupWindow);
    return popupWindow;
    }

    //显示Popupwindow
    private void showPm() {
    View view = LayoutInflater.from(this).inflate(R.layout.pm1, null);
    PopupWindow pw = createPopupWindow(view,ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    pw1.setFocusable(false);
    pw1.showAtLocation(this.getWindow().getDecorView(), Gravity.BOTTOM | Gravity.RIGHT, PopConst.PopOffsetX, PopConst.PopOffsetY);
    }

    录屏效果图:
    录屏效果图.gif

    收起阅读 »

    学会黑科技,一招搞定 iOS 14.2 的 libffi crash

    作者:字节移动技术 —— 谢俊逸苹果升级 14.2,全球 iOS 遭了秧。libffi 在 iOS14.2 上发生了 crash, 我司的许多 App 深受困扰,有许多基础库都是用了 libffi。经过定位,发现是 vmremap 导致的 code sign ...
    继续阅读 »

    作者:字节移动技术 —— 谢俊逸

    苹果升级 14.2,全球 iOS 遭了秧。libffi 在 iOS14.2 上发生了 crash, 我司的许多 App 深受困扰,有许多基础库都是用了 libffi。


    经过定位,发现是 vmremap 导致的 code sign error。我们通过使用静态 trampoline 的方式让 libffi 不需要使用 vmremap,解决了这个问题。这里就介绍一下相关的实现原理。

    libffi 是什么

    高层语言的编译器生成遵循某些约定的代码。这些公约部分是单独汇编工作所必需的。“调用约定”本质上是编译器对函数入口处将在哪里找到函数参数的假设的一组假设。“调用约定”还指定函数的返回值在哪里找到。

    一些程序在编译时可能不知道要传递给函数的参数。例如,在运行时,解释器可能会被告知用于调用给定函数的参数的数量和类型。Libffi 可用于此类程序,以提供从解释器程序到编译代码的桥梁。

    libffi 库为各种调用约定提供了一个便携式、高级的编程接口。这允许程序员在运行时调用调用接口描述指定的任何函数。

    ffi 的使用

    简单的找了一个使用 ffi 的库看一下他的调用接口

    ffi_type *returnType = st_ffiTypeWithType(self.signature.returnType);
    NSAssert(returnType, @"can't find a ffi_type of %@", self.signature.returnType);

    NSUInteger argumentCount = self->_argsCount;
    _args = malloc(sizeof(ffi_type *) * argumentCount) ;

    for (int i = 0; i < argumentCount; i++) {
      ffi_type* current_ffi_type = st_ffiTypeWithType(self.signature.argumentTypes[i]);
      NSAssert(current_ffi_type, @"can't find a ffi_type of %@", self.signature.argumentTypes[i]);
      _args[i] = current_ffi_type;
    }

    // 创建 ffi 跳板用到的 closure
    _closure = ffi_closure_alloc(sizeof(ffi_closure), (void **)&xxx_func_ptr);

    // 创建 cif,调用函数用到的参数和返回值的类型信息, 之后在调用时会结合call convention 处理参数和返回值
    if(ffi_prep_cif(&_cif, FFI_DEFAULT_ABI, (unsigned int)argumentCount, returnType, _args) == FFI_OK) {

            // closure 写入 跳板数据页
      if (ffi_prep_closure_loc(_closure, &_cif, _st_ffi_function, (__bridge void *)(self), xxx_func_ptr) != FFI_OK) {
        NSAssert(NO, @"genarate IMP failed");
      }
    else {
      NSAssert(NO, @"");
    }

    看完这段代码,大概能理解 ffi 的操作。

    1. 提供给外界一个指针(指向 trampoline entry)
    2. 创建一个 closure, 将调用相关的参数返回值信息放到 closure 里
    3. 将 closure 写入到 trampoline 对应的 trampoline data entry 处

    之后我们调用 trampoline entry func ptr 时,

    1. 会找到 写入到 trampoline 对应的 trampoline data entry 处的 closure 数据
    2. 根据 closure 提供的调用参数和返回值信息,结合调用约定,操作寄存器和栈,写入参数 进行函数调用,获取返回值。

    那 ffi 是怎么找到 trampoline 对应的 trampoline data entry 处的 closure 数据 呢?

    我们从 ffi 分配 trampoline 开始说起:

    static ffi_trampoline_table *
    ffi_remap_trampoline_table_alloc (void)
    {
    .....
      /* Allocate two pages -- a config page and a placeholder page */
      config_page = 0x0;
      kt = vm_allocate (mach_task_self (), &config_page, PAGE_MAX_SIZE * 2,
                        VM_FLAGS_ANYWHERE);
      if (kt != KERN_SUCCESS)
          return NULL;

      /* Allocate two pages -- a config page and a placeholder page */
      //bdffc_closure_trampoline_table_page

      /* Remap the trampoline table on top of the placeholder page */
      trampoline_page = config_page + PAGE_MAX_SIZE;
      trampoline_page_template = (vm_address_t)&ffi_closure_remap_trampoline_table_page;
    #ifdef __arm__
      /* bdffc_closure_trampoline_table_page can be thumb-biased on some ARM archs */
      trampoline_page_template &= ~1UL;
    #endif
      kt = vm_remap (mach_task_self (), &trampoline_page, PAGE_MAX_SIZE, 0x0,
                     VM_FLAGS_OVERWRITE, mach_task_self (), trampoline_page_template,
                     FALSE, &cur_prot, &max_prot, VM_INHERIT_SHARE);
      if (kt != KERN_SUCCESS)
      {
          vm_deallocate (mach_task_self (), config_page, PAGE_MAX_SIZE * 2);
          return NULL;
      }


      /* We have valid trampoline and config pages */
      table = calloc (1sizeof (ffi_trampoline_table));
      table->free_count = FFI_REMAP_TRAMPOLINE_COUNT/2;
      table->config_page = config_page;
      table->trampoline_page = trampoline_page;

    ......
      return table;
    }

    首先 ffi 在创建 trampoline 时,会分配两个连续的 page

    trampoline page 会 remap 到我们事先在代码中汇编写的 ffi_closure_remap_trampoline_table_page。

    其结构如图所示:



    当我们 ffi_prep_closure_loc(_closure, &_cif, _st_ffi_function, (__bridge void *)(self), entry1)) 写入 closure 数据时, 会写入到 entry1 对应的 closuer1。

    ffi_status
    ffi_prep_closure_loc (ffi_closure *closure,
                          ffi_cif* cif,
                          void (*fun)(ffi_cif*,void*,void**,void*),
                          void *user_data,
                          void *codeloc)

    {
    ......
      if (cif->flags & AARCH64_FLAG_ARG_V)
          start = ffi_closure_SYSV_V; // ffi 对 closure的处理函数
      else
          start = ffi_closure_SYSV;

      void **config = (void**)((uint8_t *)codeloc - PAGE_MAX_SIZE);
      config[0] = closure;
      config[1] = start;
    ......
    }

    这是怎么对应到的呢? closure1 和 entry1 距离其所属 Page 的 offset 是一致的,通过 offset,成功建立 trampoline entry 和 trampoline closure 的对应关系。

    现在我们知道这个关系,我们通过代码看一下到底在程序运行的时候 是怎么找到 closure 的。

    这四条指令是我们 trampoline entry 的代码实现,就是 ffi 返回的 xxx_func_ptr

    adr x16, -PAGE_MAX_SIZE
    ldp x17, x16, [x16]
    br x16
    nop

    通过 .rept 我们创建 PAGE_MAX_SIZE / FFI_TRAMPOLINE_SIZE 个跳板,刚好一个页的大小

    # 动态remap的 page
    .align PAGE_MAX_SHIFT
    CNAME(ffi_closure_remap_trampoline_table_page):
    .rept PAGE_MAX_SIZE / FFI_TRAMPOLINE_SIZE
    # 这是我们的 trampoline entry, 就是ffi生成的函数指针
    adr x16, -PAGE_MAX_SIZE // 将pc地址减去PAGE_MAX_SIZE, 找到 trampoine data entry
    ldp x17, x16, [x16] // 加载我们写入的 closure, start 到 x17, x16
    br x16 // 跳转到 start 函数
    nop /* each entry in the trampoline config page is 2*sizeof(void*) so the trampoline itself cannot be smaller that 16 bytes */
    .endr

    通过 pc 地址减去 PAGE_MAX_SIZE 就找到对应的 trampoline data entry 了。

    静态跳板的实现

    由于代码段和数据段在不同的内存区域。

    我们此时不能通过 像 vmremap 一样分配两个连续的 PAGE,在寻找 trampoline data entry 只是简单的-PAGE_MAX_SIZE 找到对应关系,需要稍微麻烦点的处理。

    主要是通过 adrp 找到_ffi_static_trampoline_data_page1 和 _ffi_static_trampoline_page1的起始地址,用 pc-_ffi_static_trampoline_page1的起始地址计算 offset,找到 trampoline data entry。

    # 静态分配的page
    #ifdef __MACH__
    #include <mach/machine/vm_param.h>

    .align 14
    .data
    .global _ffi_static_trampoline_data_page1
    _ffi_static_trampoline_data_page1:
    .space PAGE_MAX_SIZE*5
    .align PAGE_MAX_SHIFT
    .text
    CNAME(_ffi_static_trampoline_page1):

    _ffi_local_forwarding_bridge:
    adrp x17, ffi_closure_static_trampoline_table_page_start@PAGE;// text page
    sub x16, x16, x17;// offset
    adrp x17, _ffi_static_trampoline_data_page1@PAGE;// data page
    add x16, x16, x17;// data address
    ldp x17, x16, [x16];// x17 closure x16 start
    br x16
    nop
    nop
    .align PAGE_MAX_SHIFT
    CNAME(ffi_closure_static_trampoline_table_page):

    #这个label 用来adrp@PAGE 计算 trampoline 到 trampoline page的offset
    #留了5个用来调试。
    # 我们static trampoline 两条指令就够了,这里使用4个,和remap的保持一致
    ffi_closure_static_trampoline_table_page_start:
    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop

    // 5 * 4
    .rept (PAGE_MAX_SIZE*5-5*4) / FFI_TRAMPOLINE_SIZE
    adr x16, #0
    b _ffi_local_forwarding_bridge
    nop
    nop
    .endr

    .globl CNAME(ffi_closure_static_trampoline_table_page)
    FFI_HIDDEN(CNAME(ffi_closure_static_trampoline_table_page))
    #ifdef __ELF__
    .type CNAME(ffi_closure_static_trampoline_table_page), #function
    .size CNAME(ffi_closure_static_trampoline_table_page), . - CNAME(ffi_closure_static_trampoline_table_page)
    #endif
    #endif


    摘自字节跳动技术团队:https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247488493&idx=1&sn=e86780883d5c0cf3bb34a59ec753b4f3&chksm=e9d0d80fdea751196c807991cd46f5928f6828fe268268872ec3582b4fdcad086e1cebcab2d5&cur_album_id=1590407423234719749&scene=189#rd




    收起阅读 »

    抖音iOS最复杂功能的重构之路--播放器交互区重构实践分享

    背景介绍本文以抖音中最为复杂的功能,也是最重要的功能之一的交互区为例,和大家分享一下此次重构过程中的思考和方法,主要侧重在架构、结构方面。交互区简介交互区是指播放页面中可以操作的区域,简单理解就是除视频播放器外附着的功能,如下图红色区域中的作者名称、描述文案、...
    继续阅读 »

    背景介绍

    本文以抖音中最为复杂的功能,也是最重要的功能之一的交互区为例,和大家分享一下此次重构过程中的思考和方法,主要侧重在架构、结构方面。

    交互区简介

    交互区是指播放页面中可以操作的区域,简单理解就是除视频播放器外附着的功能,如下图红色区域中的作者名称、描述文案、头像、点赞、评论、分享按钮、蒙层、弹出面板等等,几乎是用户看到、用到最多的功能,也是最主要的流量入口。


    发现问题

    不要急于改代码,先梳理清楚功能、问题、代码,建立全局观,找到问题根本原因。

    现状


    上图是代码量排行,排在首位的就是交互区的 ViewController,遥遥领先其他类,数据来源自研的代码量化系统,这是一个辅助业务发现架构、设计、代码问题的工具。

    可进一步查看版本变化:



    每周 1 版,在不到 1 年的时间,代码量翻倍,个别版本代码量减少,是局部在做优化,大趋势仍是快速增长。

    除此之外:

    • 可读性差:ViewController 代码量 1.8+万行,是抖音中最大的类,超过第 2 大的类一倍有余,另外交互区使用了 VIPER 结构(iOS 常用的结构:MVC、MVVM、MVP、VIPER),加上 IPER 另外 4 层,总代码规模超过了 3 万行,这样规模的代码,很难记清某个功能在哪,某个业务逻辑是什么样的,为了修改一处,需要读懂全部代码,非常不友好
    • 扩展性差:新增、修改每个功能需要改动 VIPER 结构中的 5 个类,明明业务逻辑独立的功能,却需要大量耦合已有功能,修改已有代码,甚至引起连锁问题,修一个问题,结果又出了一个新问题
    • 维护人员多:统计 commit 历史,每个月都有数个业务线、数十人提交代码,改动时相互的影响、冲突不断

    理清业务

    作者是抖音基础技术组,负责业务架构工作,交互区业务完全不了解,需要重新梳理。

    事实上已经没有一个人了解所有业务,包括产品经理,也没有一个完整的需求文档查阅,需要根据代码、功能页面、操作来梳理清楚业务逻辑,不确定的再找相关开发、产品同学,省略中间过程,总计梳理了 10+个业务线,100+子功能,梳理这些功能的目的是:

    • 按重要性分清主次,核心功能优先保障,分配更多的时间开发、测试
    • 子功能之间的布局、交互是有一定的规律的,这些规律可以指导重构的设计
    • 判断产品演化趋势,设计既要满足当下、也要有一定的前瞻性
    • 自测时需要用,避免遗漏

    理清代码

    所有业务功能、问题最终都要落在代码上,理清代码才能真正理清问题,解决也从代码中体现,梳理如下:

    • 代码量:VC 1.8 万行、总代码量超过 3 万行
    • 接口:对外暴露了超过 200 个方法、100 个属性
    • 依赖关系:VIPER 结构使用的不理想,Presenter 中直接依赖了 VC,互相耦合
    • 内聚、耦合:一个子功能,代码散落在各处,并和其他子功能产生过多耦合
    • 无用代码:大量无用的代码、不知道做什么的代码
    • View 层级:所有的子功能 View 都放在 VC 的直接子 View 中,也就是说 VC 有 100+个 subView,实际仅需要显示 10 个左右的子功能,其他的通过设置了 hidden 隐藏,但是创建并参与布局,会严重消耗性能
    • ABTest(分组对照试验):有几十个 ABTest,最长时间可以追溯到数年前,这些 ABTest 在自测、测试都难以全面覆盖

    简单概括就是,需要完整的读完代码,重点是类之间的依赖关系,可以画类图结合着理解。

    每一行代码都是有原因的,即便感觉没用,删一行可能就是一个线上事故。

    趋势

    抖音产品特性决定,视频播放页面占据绝大部分流量,各业务线都想要播放页面的导流,随着业务发展,不断向多样性、复杂性演化。

    从播放页面的形态上看,已经经过多次探索、尝试,目前的播放页面模式相对稳定,业务主要以导流形式的入口扩展。

    曾经尝试过的方式

    ViewController 拆分 Category

    将 ViewController 拆分为多个 Category,按 View 构造、布局、更新、业务线逻辑将代码拆分到 Category。这个方式可以解决部分问题,但有限,当功能非常复杂时就无法很好的支撑了,主要问题有:

    • 拆分了 ViewController,但是 IPER 层没有拆分,拆分的不彻底,职责还是相互耦合
    • Category 之间互相访问需要的属性、内部方法时,需要暴露在头文件中,而这些是应该隐藏的
    • 无法支持批量调用,如 ViewDidLoad 时机,需要各个 Category 方法定义不同方法(同名会被覆盖),逐个调用

    左侧和底部的子功能放在一个 UIStackView 中

    这个思路方向大体正确了,但是在尝试大半年后失败,删掉了代码。

    正确的点在于:抽象了子功能之间的关系,利用 UIStackView 做布局。

    失败的点在于:

    • 局部重构:仅仅是局部重构,没有深入的分析整体功能、逻辑,没有彻底解决问题,Masonry 布局代码和 UIStackView 使用方式都放在 ViewController 中,不同功能的 view 很容易耦合,劣化依然存在,很快又然难以维护,这类似破窗效应
    • 实施方案不完善:布局需要实现 2 套代码,开发、测试同学非常容易忽略,线上经常出问题
    • UIStackView crash:概率性 crash,崩在系统库中,大半年时间也没有找到原因

    其他

    还有一些提出 MVP、MVVM 等结构的方案,有的浅尝辄止、有的通过不了技术评审、有的不了了之。

    关键问题

    上面仅列举部分问题,如果按人头收集,那将数不胜数,但这些基本都是表象问题,找到问题的本质、原因,解决关键问题,才能彻底解决问题,很多表象问题也会被顺带解决。

    经常提到的内聚、耦合、封装、分层等等思想感觉很好,用时却又没有真正解决问题,下面扩展两点,辅助分析、解决问题:

    • 复杂度
    • “变量”与“常量”

    复杂度

    复杂功能难以维护的原因的是因为复杂。

    是的,很直接,相对的,设计、重构等手法都是让事情变得简单,但变简单的过程并不简单,从 2 个角度切入来拆解:

    • 关系

    :量是显性的,功能不断增加,相应的需要更多人来开发、维护,需要写更多代码,也就越来越难维护,这些是显而易见的。

    关系:关系是隐性的,功能之间产生耦合即为发生关系,假设 2 个功能之间有依赖,关系数量记为 1,那 3 者之间关系数量为 3,4 者之间关系数量为 6,这是一个指数增加的,当数量足够大时,复杂度会很夸张,关系并不容易看出来,因此很容易产生让人意想不到的变化。

    功能的数量大体可以认为是随产品人数线性增长的,即复杂度也是线性增长,随着开发人数同步增长是可以继续维护的。如果关系数量指数级增长,那么很快就无法维护了。



    “变量”与“常量”

    “变量”是指相比上几个版本,哪些代码变了,与之对应的“常量”即哪些代码没变,目的是:

    从过去的变化中找到规律,以适应未来的变化。

    平常提到的封装、内聚、解耦等概念,都是静态的,即某一个时间点合理,不意味着未来也合理,期望改进可以在更长的时间范围内合理,称之为动态,找到代码中的“变量”与“常量”是比较有效的手段,相应的代码也有不同的优化趋向:

    • 对于“变量”,需要保证职责内聚、单一,易扩展
    • 对于“常量”,需要封装,减少干扰,对使用者透明

    回到交互区重构场景,发现新加的子功能,基本都加在固定的 3 个区域中,布局是上下撑开,这里的变指的就是新加的子功能,不变指的是加的位置和其他子功能的位置关系、逻辑关系,那么变化的部分,可以提供一个灵活的扩展机制来支持,不变的部分中,业务无关的下沉为底层框架,业务相关的封装为独立模块,这样整体的结构也就出来了。

    “变量”与“常量”同样可以检验重构效果,比如模块间常常通过抽象出的协议进行通信,如果通信方法都是具体业务的,那每个同学都可能往里添加各自的方法,这个“变量”就会失去控制,难以维护。

    设计方案

    梳理问题的过程中,已经在不断的在思考什么样的方式可以解决问题,大致雏形已经有了,这部分更多的是将设计方案系统化。

    思路

    • 通过上述梳理功能发现 UI 设计和产品的规律:
      • 整体可分为 3 个区域,左侧、右侧、底部,每个子功能都可以归到 3 个区域中,按需显示,数据驱动
      • 左侧区域中的作者名称、描述、音乐信息是自底向上挨个排列
      • 右侧主要是按钮类型,头像、点赞、评论,排列方式和左侧规律相同
      • 底部可能有个警告、热点,只显示 1 个或者不显示
    • 为了统一概念,将 3 个区域定义为容器、容器中放置的子功能定义为元素,容器边界和能力可以放宽一些,支持弱类型实例化,这样就能支持物理隔离元素代码,形成一个可插拔的机制。
    • 元素将 View、布局、业务逻辑代码都内聚在一起,元素和交互区、元素和元素之间不直接依赖,职责内聚,便于维护。
    • 众多的接口可以抽象归类,大体可分为 UI 生命周期调用、播放器生命周期调用,将业务性的接口抽象,分发到具体的元素中处理逻辑。

    架构设计

    下图是期望达到的最终目标形态,实施过程会分为多步,确定最终形态,避免实施时偏离目标




    整体指导原则:简单、适用、可演化。

    • SDK 层:抽象出和业务完全无关的 SDK 层,SDK 负责管理 Element、Element 间通信
    • 业务框架层:将通用业务、共性代码等低频率修改代码独立出来,形成框架层,这层代码是可由专人维护,业务线同学无法修改
    • 业务扩展层:各业务线具体的子功能在此层实现,提供灵活的注册、插拔能力,Element 间无耦合,代码影响限定在 Element 内部

    SDK 层

    Container

    所有的 Element 都通过 Container 来管理,包括 2 部分:

    • 对 Element 的创建、持有
    • 持有了一个 UIStackView,Element 创建的 View 都加入到此 UIStackView 中

    使用 UIStackView 是为了实现自底向上的流式布局。

    Element

    子功能的 UI、逻辑、操作等所有代码封装的集合体,定义为 Element,借鉴了网页中的 Element 概念,对外的行为可抽象为:

    • View:最终显示的 View,lazy 的形式构造
    • 布局:自适应撑开,Container 中的 UIStackView 可以支持
    • 事件:通用的事件,处理 handler 即可,view 内部也可自行添加事件
    • 更新:传入模型,内部根据模型内容,赋值到 view 中

    View

    View 在 BaseElement 中的定义如下:

    @interface BaseElement : NSObject <BaseElementProtocol>

    @property (nonatomic, strong, nullable) UIView *view;
    @property (nonatomic, assign) BOOL appear;

    - (void)viewDidLoad;

    @end
    • BaseElement 是抽象基类,公开 view 属性形式上看 view 属性、viewDidLoad 方法,和 UIViewController 使用方式的非常类似,设计意图是想靠向 UIViewController,以便让大家更快的接受和理解
    • appear 表示 element 是否显示,appear 为 YES 时,view 被自动创建,viewDidLoad 方法被调用,相关的子 view、布局等业务代码在 viewDidLoad 方法中复写,和 UIViewController 使用类似
    • appear 和 hidden 的区别在于,hidden 只是视觉看不到了,内存并没有释放掉,而低频次使用的 view 没必要常驻内存,因此 appear 为 NO 时,会移除 view 并释放内存

    布局

    • UIStackView 的 axis 设置了 UILayoutConstraintAxisVertical,布局时自底向上的流式排列
    • 容器内的元素自下向上布局,最底部的元素参照容器底部约束,依次布局,容器高度参照最上面的元素位置
    • 元素内部自动撑开,可直接设置固定高度,也可以用 autolayout 撑开

    事件

    @protocol BaseElementProtocol <NSObject>
    @optional
    - (void)tapHandler:(UITapGestureRecognizer *)sender;

    @end
    • 实现协议方法,自动添加手势,支持点击事件
    • 也可以自行添加事件,如按钮,使用原生的 addTarget 点击体验更好

    更新

    data 属性赋值,触发更新,通过 setter 形式实现。

    @property (nonatomic, strong, nullable) id data;

    赋值时会调用 setData 方法。

    - (void)setData:(id)data {
        _data = data;
        [self processAppear:self.appear];
    }

    赋值时,processAppear 方法会根据 appear 状态更新 View 的状态,决定创建或销毁 View。

    数据流图

    Element 的生命周期、更新时的数据流向示意图,这里就不细讲了。



    图中是实际需要支持的业务场景,目前是 ABTest 阶段,老代码实现方式主要问题:

    • 对每处 view 都用 GET_AB_TEST_CASE(videoPlayerInteractionOptimization)判断处理了,代码中共有 32 处判断
    • 每个 View 使用 Transform 动画隐藏

    这个实现方式非常分散,加新 view 时很容易被遗漏,Element 支持更优的方式:

    • 左侧所有子功能都在一个容器中,因此隐藏容器即可,不需要操作每个子功能
    • 右侧单独隐藏头像、音乐单独处理即可


    扩展性

    Element 之间无依赖,可以做到每个 Element 物理隔离,代码放在各自的业务组件中,业务组件依赖交互区业务框架层即可,独立的 Element 通过 runtime 形式,使用注册的方式提供给交互区,框架会将字符串的类实例化,让其正常工作。

    [self.container addElementByClassName:@"PlayInteractionAuthorElement"];
    [self.container addElementByClassName:@"PlayInteractionRateElement"];
    [self.container addElementByClassName:@"PlayInteractionDescriptionElement"];

    业务框架层

    容器管理

    SDK 中仅提供了容器的抽象定义和实现,在业务场景中,需要结合具体业务场景,进一步定义容器的范围和职责。

    上面梳理了功能中将整个页面分为左侧、右侧、底部 3 个区域,那么这 3 个区域就是相应的容器,所有子功能都可以归到这 3 个容器中,如下图:

    协议

    Feed 是用 UITableView 实现,Cell 中除了交互区外只有播放器,因此所有的外部调用都可以抽象,如下图所示。



    协议

    Feed 是用 UITableView 实现,Cell 中除了交互区外只有播放器,因此所有的外部调用都可以抽象,如下图所示。


    从概念上讲只需要 1 个交互区协议,但这里可以细分为 2 部分:

    • 页面生命周期
    • 播放器生命周期

    所有 Element 都要实现这个协议,因此在 SDK 中的 Element 基类之上,继承实现了 PlayInteractionBaseElement,这样具体 Element 中不需要实现的方法可以不写。

    @interface PlayInteractionBaseElement : BaseElement <PlayInteractionDispatcherProtocol>
    @end

    为了更清晰定义协议职责,用接口隔离的思想继续拆分,PlayInteractionDispatcherProtocol 作为统一的聚合协议。

    @protocol PlayInteractionDispatcherProtocol <PlayInteractionCycleLifeDispatcherProtocol, PlayInteractionPlayerDispatcherProtocol>

    @end

    页面生命周期协议:PlayInteractionCycleLifeDispatcherProtocol

    简单列了部分方法,这些方法都是 ViewController、TableView、Cell 对应的生命周期方法,是完全抽象的、和业务无关的,因此不会随着业务量的增加而膨胀。

    @protocol PlayInteractionCycleLifeDispatcherProtocol <NSObject>

    - (void)willDisplay;

    - (void)setHide:(BOOL)flag;

    - (void)reset;

    @end

    播放器生命周期协议:PlayInteractionPlayerDispatcherProtocol

    播放器的状态和方法,也是抽象的、和业务无关。

    @protocol PlayInteractionPlayerDispatcherProtocol <NSObject>

    @property (nonatomic, assign) PlayInteractionPlayerStatus playerStatus;

    - (void)pause;

    - (void)resume;

    - (void)videoDidActivity;

    @end

    Manager - 弹窗、蒙层

    弹窗、蒙层的 view 规律并不在容器管理之中,所以需要一套额外的管理方式,这里定义了 Manager 概念,是一个相对抽象的概念,即可以实现弹窗、蒙层等功能,也可以实现 View 无关的功能,和 Element 同样,将代码拆分开。

    @interface PlayInteractionBaseManager : NSObject <PlayInteractionDispatcherProtocol>

    - (UIView *)view;

    @end
    • PlayInteractionBaseManager 同样实现了 PlayInteractionDispatcherProtocol 协议,因此具备了所有的交互区协议调用能力
    • Manager 不提供 View 的创建能力,这里的 view 是 UIViewController 的 view 引用,比如需要加蒙层,那么加到 manager 的 view 中就相当于加到 UIViewController 的 view 中
    • 弹窗、蒙层通过此种方式实现,Manager 并不负责弹窗、蒙层间的互斥、优先级逻辑处理,需要单独的机制去做

    方法派发

    业务框架层中定义的协议,需要框架层调用,SDK 层是感知不到的,由于 Element、Manager 众多,需要一个机制来封装批量调用过程,如下图所示:

    分层结构

    旧交互区使用了 VIPER 范式,抖音里整体使用的 MVVM,多套范式会增加学习、维护成本,并且使用 Element 开发时,VIPER 层级过多,因此考虑统一为 MVVM。

    VIPER 整体分层结构


    MVVM 整体分层结构


    在 MVVM 结构中,Element 职责和 ViewController 概念很接近,也可以理解为更纯粹、更专用的的 ViewController。

    经过 Element 拆分后,每个子功能已经内聚在一起,代码量是有限的,可以比较好的支撑业务开发。

    Element 结合 MVVM 结构





    • Element:如果是特别简单的元素,那么只提供 Element 的实现即可,Element 层负责基本的实现和跳转
    • ViewModel:部分元素逻辑比较复杂,需要将逻辑抽离出来,作为 ViewModel,对应目前的 Presentor 层
    • Tracker:埋点工具,埋点也可以写在 VM 中,对应目前的 Interactor
    • Model:绝大多数使用主 Model 即可

    业务层

    业务层中存放的是 Element 实现,主要有两种类型:

    • 通用业务:如作者信息、描述、头像、点赞、评论等通用的功能
    • 子业务线业务:十几条子业务线,不一一列举

    通用业务 Element 和交互区代码放在一起,子业务线 Element 放在业务线中,代码物理隔离后,职责会更明确,但是这也带来一个问题,当框架调整时,需要改多个仓库,并且可能修改遗漏,所以重构初期可以先放一起,稳定后再迁出去。

    过度设计误区

    设计往往会走两个极端,没有设计、过度设计。

    所谓没有设计是在现有的架构、模式下,没有额外思考过差异、特点,照搬使用。

    过渡设计往往是在吃了没有设计的亏后,成了惊弓之鸟,看什么都要搞一堆配置、组合、扩展的设计,简单的反而搞复杂了,过犹不及。

    设计是在质量、成本、时间等因素之间做出权衡的艺术。

    实施方案

    业务开发不能停,一边开发、一边重构,相当于在高速公路上不停车换轮胎,需要有足够的预案、备案,才能保证设计方案顺利落地。

    改动评估

    先估算一下修改规模、周期:

    • 代码修改量:近 4 万行
    • 时间:半年

    改动巨大、时间很长,风险是难以控制的,每个版本都有大量业务需求,需要改大量的代码,在重构的同时,如果重构的代码和新需求代码冲突,是非常难解的,因此考虑分期。

    上面已经多次说到功能的重要性,需要考虑重构后,功能是否正常,如果出了问题如何处理、如何证明重构后的功能和之前是一致的,对产品数据无影响。

    实施策略

    基本思路是实现一个新页面,通过 ABTest 来切换,核心指标无明显负向则放量,全量后删除旧代码,示意图如下:


    共分为三期:

    • 一期改造内容如上图红色所示:抽取协议,面向协议编程,不依赖具体类,改造旧 VC,实现协议,将协议之外暴露的方法、属性收敛到内部
    • 二期改造内容如蓝色所示:新建个新 VC,新 VC 和旧 VC 在功能上是完全一致,实现协议,通过 ABTest 来控制使用方拿到的是旧 VC 还是新 VC
    • 三期内容:删掉旧 VC、ABTest,协议、新 VC 保留,完成替换工作

    其中二期是重点,占用时间最多,此阶段需要同时维护新旧两套页面,开发、测试工作量翻倍,因此要尽可能的缩短二期时间,不要着急改代码,可以将一期做完善了、各方面的设计准备好再开始。

    ABTest

    2 个目的:

    • 利用 ABTest 作为开关,可以灵活的切换新旧页面
    • 用数据证明新旧页面是一致的,从业务功能上来说,二者完全一致,但实际情况是否符合预期,需要用留存、播放、渗透率等核心指标证明

    两套页面的开发方式

    在二期中,两套页面 ABTest 切换方式是有成本的,需求开发两套、测试两遍,虽然部分代码可共用,但成本还是大大增加,因此需要将这个阶段尽可能缩短。

    另外开发、测试两套,不容易发现问题,而一旦出问题,即便能用 ABTest 灵活切换,但修复问题、重新上线、ABTest 数据有结论,也需要非常长的周期。

    如果每个版本都出问题,那将会是上线、发现问题,重新修复再上线,又发现了新问题,无限循环,可能一直无法全量。

    图片

    如上图所示,版本单周迭代,发现问题跟下周修复,那么需要经过灰度、上线灰度(AppStore 的灰度放量)、ABTest 验证(AB 数据稳定要 2 周),总计要 6 周的时间。

    让每个同学理解整体运作机制、成本,有助于统一目标,缩短此阶段周期。

    删掉旧代码

    架构设计上准备充足,删掉旧代码非常简单,删掉旧文件、ABTest 即可,事实上也是如此,1 天内就完成了。

    代码后入后,有些长尾的事情会持续 2、3 个版本,例如有些分支,已经修改了删掉的代码,因为文件已经不存在了,只要修改,必定会冲突,合之前,需要 git merge 一下源分支,将有冲突的老页面再删掉。

    防崩溃兜底

    面向协议开发两套页面,如果增加一个功能时,新页面遗漏了某个方法的话,期望可以不崩溃。利用 Objective-C 语言消息转发可以实现这特性,在 forwardingTargetForSelector 方法中判断方法是否存在,如果不存在,添加一个兜底方法即可,用来处理即可。


    - (id)forwardingTargetForSelector:(SEL)aSelector {
      Class clazz = NSClassFromString(@"TestObject");
      if (![self isExistSelector:aSelector inClass:clazz]) {
        class_addMethod(clazz, aSelector, [self safeImplementation:aSelector], [NSStringFromSelector(aSelector) UTF8String]);
      }

      Class Protector = [clazz class];
      id instance = [[Protector alloc] init];
      return instance;
    }

    - (BOOL)isExistSelector:(SEL)aSelector inClass:(Class)clazz {
      BOOL isExist = NO;
      unsigned int methodCount = 0;
      Method *methods = class_copyMethodList(clazz, &methodCount);
      NSString *aSelectorName = NSStringFromSelector(aSelector);
      for (int i = 0; i < methodCount; i++) {
        Method method = methods[i];
        SEL selector = method_getName(method);
        NSString *selectorName = NSStringFromSelector(selector);
        if ([selectorName isEqualToString: aSelectorName]) {
          isExist = YES;
          break;
        }
      }
      return isExist;
    }

    - (IMP)safeImplementation:(SEL)aSelector {
      IMP imp = imp_implementationWithBlock(^(){
        // log
      });
      return imp;
    }

    线上兜底降低影响范围,内测提示尽早发现,在开发、内测阶段时可以用比较强的交互手段提示,如 toast、弹窗等,另外可以接打点上报统计。

    防劣化

    需要明确的规则、机制防劣化,并持续投入精力维护。

    不是每个人都能理解设计意图,不同职责的代码放在应该放的位置,比如业务无关的代码,应该下沉到框架层,降低被破坏的概率,紧密的开发节奏,即便简单的 if else 也容易写出问题,例如再加 1 个条件,几乎都会再写 1 个 if,直至写了几十个后,发现写不下去了,再推倒重构,期望重构一次后,可以保持得尽可能久一些。

    更严重的是在重构过程中,代码就可能劣化,如果问题出现的速度超过解决的速度,那么将会一直疲于救火,永远无法彻底解决。



    新方案中,业务逻辑都放在了 Element 中,ViewController、容器中剩下通用的代码,这部分代码业务同学是没必要去修改,不理解整体也容易改出问题,因此这部分代码由专人来维护,各业务同学有需要改框架层代码的需求,专人来修改。

    各 Element 按照业务线划分为独立文件,自己维护的文件可以加 reviewer 或文件变更通知,也可以迁到业务仓库中,进行物理隔离。

    日志 & 问题排查

    稳定复现的问题,比较容易排查和解决,但概率性的问题,尤其是 iOS 系统问题引起的概率性问题,比较难排查,即便猜测可能引起问题的原因,修改后,也难以自测验证,只能上线再观察。

    关键信息提前加日志记录,如用户反馈某个视频有问题,那么需要根据日志,找到相应的 model、Element、View、布局、约束等信息。

    信息同步

    改动过广,需要及时周知业务线的开发、测试、产品同学,几个方式:

    • 拉群通知
    • 周会、周报

    开发同学最关注的点是什么时候放量、什么时候全量、什么时候可以删掉老代码,不用维护 2 套代码。

    其次是改动,框架在不够稳定时,是需要经常改的,如果改动,需要相应受影响的功能的维护同学验证,以及确认测试是否介入。

    产品同学也要周知,虽然产品不关注怎么做,但是一旦出问题,没有周知,很麻烦。

    保证质量

    最重要的是及时发现问题,这是避免或者减少影响的前提条件。

    常规的 RD 自测、QA 功能测试、集成测试等是必备的,这里不多说,主要探讨其他哪些手段可以更加及时的发现问题。

    新开发的需求,需要开发新、老页面两套代码,同样,也要测试两次,虽然多次强调,但涉及到多个业务线、跨团队、跨职责、时间线长,很容易遗漏,而新页面 ABTest 放量很小,一旦出问题,很难被发现,因此对线上和测试用户区分处理:

    • 线上、线下流量策略:线上 AppStore 渠道 ABTest 按数据分析师设计放量;内测、灰度等线下渠道放量 50%,新旧两套各占一半,内测、灰度人员还是有一定规模的,如果是明显的问题,比较容易发现的
    • ABTest 产品指标对照:灰度、线上数据都是有参考价值的,按照 ABTest 数据量,粗评一下是否有问题,如果有明显问题,可及时深入排查
    • Slardar ABTest 技术指标对照:最常用的是 crash 率,对比对照组和实验组的 crash 率,看下是否有新 crash,实验组放量比较小,单独的看 crash 数量是很难发现的,也容易忽略。另外还要别的技术指标,也可以关注下
    • Slardar 技术打点告警配置:重构周期比较长,难以做到每天都盯着,关键位置加入技术打点,系统中配置告警,设置好条件,这样在出现问题时,会及时通知你
    • 单元测试:单测是保证重构的必要手段,在框架、SDK 等核心代码,都加入了单测
    • UI 自动化测试:如果有完整的验证用例,可以一定程度上帮助发现问题

    排查问题

    稳定复现的问题比较容易定位解决,两类问题比较头疼,详细讲一下:

    • ABTest 指标负向
    • 概率性出现的问题

    ABTest 指标负向

    ABTest 核心指标负向,是无法放量的,甚至要关掉实验排查问题。

    有个分享例子,分享总量、人均分享量都明显负向,大体经过这样几个排查过程:

    排查 ABTest 指标和排查 bug 类似,都是找到差异,缩小范围,最终定位代码。

    • 对比功能:从用户使用角度找差异,交互设计师、测试、开发自测都没有发现有差异
    • 对比代码:对比新老两套打点代码逻辑,尤其是进入打点的条件逻辑,没有发现差异
    • 拆分指标:很多功能都可以分享,打点平台可以按分享页面来源拆分指标,发现长按弹出面板中的分享减少,其他来源相差不大,进一步排查弹出面板出现的概率发现明显变低了,大体定位问题范围。另外值得一提的是,不喜欢不是很核心的指标,并且不喜欢变少,意味着视频质量更高,所以这点是从 ABTest 数据中难以发现的
    • 定位代码:排查面板出现条件发现,老代码中是在长按手势中,排除了个别的点赞、评论等按钮,其他位置(如果没有添加事件)都是可点的,比如点赞、评论按钮之间的空白位置,而新代码中是将右侧按钮区域、底部统一排除了,这样空白区域就不能点了,点击区域变小了,因此出现概率变小了
    • 解决问题:定位问题后,修复比较简单,还原了旧代码实现方式

    这个问题能思考的点是比较多的,重构时,看到了不好的代码,到底要不要改?

    比如上面的问题,增加了功能后,不知道是否应该排除点击,很容易被忽略,长按属于底层逻辑,具体按钮属于业务细节,底层逻辑依赖了细节是不好的,可维护性很差,但是修改后,很可能影响交互体验和产品指标,尤其是核心指标,一旦影响,没有太多探讨空间。

    具体情况具体评估,如果预估到影响了功能、交互,尽量不要改,大重构尽可能先解决核心问题,局部问题可以后续单独解决。

    下面是长按面板中的分享数据截图,明显降低,其他来源基本保持一致,就不贴图了。

    长按蒙层出现率降低 10%左右,比较自然的猜测蒙层出现率降低。

    对比 View 视图差异确认问题。



    类似的问题很多,ABTest 放量、全量过程要有充足的估时和耐心,这个过程会大大超过预期。抖音核心指标几乎都和交互区相关,众多分析师和产品都要关注,因此先理解一下分析师、产品和开发同学对 ABTest 指标负向的认知差别。

    大部分指标是正向,个别指标负向,那么会被判断为负向。

    开发同学可能想的是设计的合理性、代码的合理性,或者从整体的收益、损失角度的差值考虑,但分析师会优先考虑不出问题、别有隐患。两种方式是站在不同角度、目标考虑的,没有对错之分,事实上分析师帮忙发现了非常多的问题。目前的分析师、产品众多,每个指标都有分析师、产品负责,如果某个核心指标明显负向,找相应的分析师、产品讨论,是非常难达成一致的,即使是先放量再排查的方案也很难接受,建议自己学会看指标,尽早跟进,关键时找人帮忙推进。

    概率性出现的问题

    概率性出现的问题难点在于,很难复现,无法调试定位问题,修改后无法测试验证,需要上线后才能确定是否修复,举一个实际的例子的 iOS9 上 crash 例子,发现过程:

    • 通过 slardar=>AB 实验=>指定实验=>监控类型=>崩溃 发现的,可以看到实验组和对照组的 crash 率,其他的 OOM 等指标也可以用这个功能查看

    下面是 crash 的堆栈,crash 率比较高,大约 50%的 iOS 9 的用户会出现:



    crash 堆栈在系统库中,无法看到源码,堆栈中也无法找到相关的问题代码,无法定位问题 ,整个解决过程比较长,尝试用过的方式,供大家参考:

    • 手动复现,尝试修改,可以复现,但刷一天也复现不了几次,效率太低,对部分问题来说,判断准的话,可以比较快的解决
    • swizzle 系统崩溃的方法,日志记录最后崩溃的 View、相关 View 的层次结构,缩小排查范围
    • 自动化测试复现,可以用来验证是否修复问题,无法定位问题
    • 逆向看 UIKit 系统实现,分析崩溃原因

    逆向大体过程:

    • 下载 iOS9 Xcode & 模拟器文件
    • 提取 UIKit 动态库
    • 分析 crash 堆栈,通过 crash 最后所在的_layoutEngine、_addOrRemoveConstraints、_withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists 3 个关键方法,找到调用路径,如下图所示:

    • _withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists 中调用了 deactivateConstraints 方法,deactivateConstraints 中又调用了_addOrRemoveConstraints 方法,和 crash 堆栈中第 3 行匹配,那么问题就出在此处,为方便排查,逆向出相关方法的具体实现,大体如下:
    @implementation UIView
    - (void)_withUnsatisfiableConstraintsLoggingSuspendedIfEngineDelegateExists:(Block)action {
        id engine = [self _layoutEngine];
        id delegate = [engine delegate];
        BOOL suspended = [delegate _isUnsatisfiableConstraintsLoggingSuspended];
        [delegate _setUnsatisfiableConstraintsLoggingSuspended:YES];
        action();
        [delegate _setUnsatisfiableConstraintsLoggingSuspended:suspended];
        if (suspended == YES) {
            return;
        }
        NSArray *constraints = [self _constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended];
        if (constraints.count != 0) {
            NSMutableArray *array = [[NSMutableArray alloc] init];
            for (NSLayoutConstraint *_cons : constraints) {
                if ([_cons isActive]) {
                    [array addObject:_cons];
                }
            }
            if (array.count != 0)  {
                [NSLayoutConstraint deactivateConstraints:array]; // NSLayoutConstraint 入口
                [NSLayoutConstraint activateConstraints:array];
            }
        }
        objc_setAssociatedObject(
                    self,
                    @selector(_constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended),
                    nil,
                    OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }

    @end

    @implementation NSLayoutConstraint
    + (void)activateConstraints:(NSArray *)_array {
        [self _addOrRemoveConstraints:_array activate:YES]; // crash堆栈中倒数第3个调用
    }
    + (void)deactivateConstraints:(NSArray *)_array {
        [self _addOrRemoveConstraints:_array activate:NO];
    }
    @end
    • 从代码逻辑和_constraintsBrokenWhileUnsatisfiableConstraintsLoggingSuspended 方法的命名语义上看,此处代码主要是用来处理无法满足约束日志的,应该不会影响功能逻辑
    • 另外,分析时如果无法准确判断 crash 位置,则需要逆向真机文件,相比模拟器,真机的堆栈是准确的,通过原始 crash 堆栈偏移量找到最后的代码调用

    拿到结果

    • 开发效率:将之前 VIPER 结构的 5 个文件,拆分了大约 50 个文件,每个功能的职责都在业务线内部,添加、修改不再需要看所有的代码了,调研问卷显示开发效率提升在 20%以上
    • 开发质量:从 bug、线上故障来看,新页面问题是比较少的,而且出问题一般的都是框架的问题,修复后是可以避免批量的问题的
    • 产品收益:虽然功能一致,但因为重构设计的性能是有改进的,核心指标正向收益明显,实验开启多次,核心指标结论一致

    勇气

    最后这部分是思考良久后加上的,重构本身就是开发的一部分,再正常不过,但重构总是难以进行,有的浅尝辄止,甚至半途而废。公司严格的招聘下,能进来的都是聪明人,不缺少解决问题的智慧,缺少的是勇气,回顾这次重构和上面提到过的“曾经尝试过的方式”,也正是如此。

    代码难以维护时是比较容易发现的,优化、重构的想法也很自然,但是有两点让重构无法有效开展:

    • 什么时候开始
    • 局部重构试试

    在讨论什么时候开始前,可以先看个词,工作中有个流行词叫 ROI,大意是投入和收益比率,投入越少、收益越高越好,最好是空手套白狼,这个词指导了很多决策。

    重构无疑是个费力的事情,需要投入非常大的心力、时间,而能看到的直接收益不明显,一旦改出问题,还要承担风险,重构也很难获得其他人认可,比如在产品看来,功能完全没变,代码还能跑,为什么要现在重构,新需求还等着开发呢,有问题的代码就是这样不断的拖着,越来越严重。

    诚然,有足够的痛点时重构是收益最高的,但只是看起来,真实的收益是不变的,在这之前需要大量额外的维护成本,以及劣化后的重构成本,从长期收益看,既然要改就趁早改。决定要做比较难,说服大家更难,每个人的理解可能都不一样,对长期收益的判断也不一样,很难达成一致。

    思者众、行者寡,未知的事情大家偏向谨慎,支持继续前行的是对技术追求的勇气。

    重构最好的时间就是当下。

    局部重构,积少成多,最终整体完成,即便出问题,影响也是局部的,这是自下向上的方式,本身是没问题的,也经常使用,与之对应的是自上向下的整体重构,这里想强调的是,局部重构、整体重构只是手段,选择什么手段要看解决什么问题,如果根本问题是整体结构、架构的问题,局部重构是无法解决的。

    比如这次重构时,非常多的人都提出,能否改动小一点、谨慎一点,但是设计方案是经过分析梳理的,已经明确是结构性问题,局部重构是无法解决的,曾经那些尝试过的方式也证明了这一点。

    不能因为怕扯到蛋而忘记奔跑。



    摘自字节抖音技术团队:https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247488646&idx=1&sn=ae046434bf98c5c8cbc0d567e133206c&chksm=e9d0df64dea7567236ffb907d984f45ddb6cce10618601e10683d545ef0b1a55512df4d249ba&scene=178&cur_album_id=1590407423234719749#rd



    收起阅读 »

    抖音研发效能建设 - CocoaPods 优化实践

    背景抖音很早就接入 CocoaPods 进行依赖管理了,项目前期抖音只有几十个组件,业务代码也基本在壳工程内,CocoaPods 可以满足业务研发的需求,但是随着业务的不断迭代,代码急剧膨胀,同时抖音工程也在进行架构优化,比如工程组件化改造,组件的数量和复杂度...
    继续阅读 »

    背景

    抖音很早就接入 CocoaPods 进行依赖管理了,项目前期抖音只有几十个组件,业务代码也基本在壳工程内,CocoaPods 可以满足业务研发的需求,但是随着业务的不断迭代,代码急剧膨胀,同时抖音工程也在进行架构优化,比如工程组件化改造,组件的数量和复杂度不断增加:组件(Pod)数量增加到 400+ ,子组件(Subspec)数量增加到 1500+ ,部分复杂组件的描述文件(podspec)膨胀到 1000+ 行,这导致了依赖管理流程(主要是 Pod Install)的效率不断下降,同时也导致了 Xcode 检索和构建效率下降。

    除了效率下降外,我们也开始遇到一些 CocoaPods 潜在的稳定性问题,比如在 CI/CD 任务并发执行的环境下 Pod Install 出现大量失败,这些问题已经严重影响了我们的研发效率。在超大工程、复杂依赖、快速迭代的背景下,CocoaPods 已经不能很好地支撑我们的研发流程了。

    1. 反馈最多就是 Pod Install 慢,经常会有同学反馈 Pod Install 流程慢,涉及到决议流程慢,依赖下载慢、Pods 工程生成慢等
    2. 本地 Source 仓库没更新,经常导致找不到 Specification,Pod Install 失败
    3. 依赖组件多,循环依赖报错,但是难以找到循环链路
    4. 依赖组件多,User 工程复杂度,导致 Pod Install 后 Xcode 工程索引慢,卡顿严重
    5. 依赖组件多,工程构建出现不符合预期的失败问题,比如 Arguments Too Long
    6. 研发流程上,有部分研发同学本地误清理了 CocoaPods 缓存,导致工程编译或者链接失败
    7. 组件拆分后,新添加文件必须 Pod Install 后才可以被其他组件访问,这拖慢了研发效率

    我们开始尝试在 0 侵入、不影响现有研发流程的前提下,改造 CocoaPods 做来解决我们遇到的问题,并且取得了一些收益。在介绍我们的优化前,我们会先对 CocoaPods 做一些介绍, 我们以 CocoaPods 1.7.5 为例来做说明依赖管理的核心流程「Pod Install」

    Pod Install

    我们以一个 MVP 工程「iOSPlayground」为例子来说明,iOSPlayground 工程是怎么组织的:

    iOSPlayground.xcodeproj壳工程,包含 App Target:iOSPlayground
    iOSPlayground壳工程文件目录,包含资源、代码、Info.plist
    Podfile声明 User Target 的依赖
    Gemfile声明 CocoaPods 的版本,这里是 1.7.5

    我们在 Podfile 中为 Target「iOSPlayground」引入 SDWebImage 以及 SDWebImage 的两个 Coder,并声明这些组件的版本约束

    platform :ios, '11.0'
    project 'iOSPlayground.xcodeproj'
    target 'iOSPlayground' do
      pod 'SDWebImage''~> 5.6.0'
      pod 'SDWebImageLottieCoder''~> 0.1.0'
      pod 'SDWebImageWebPCoder''~> 0.6.1'
    end

    然后执行 Pod install 命令 bundle exec pod install,CocoaPods 开始为你构建多依赖的开发环境;整个 Pod Install 流程最核心的就是 ::Pod::Installer 类,Pod Install 命令会初始化并配置 Installer,然后执行 install! 流程,install! 流程主要包括 6 个环节


    def install!
      prepare
      resolve_dependencies # 依赖决议
      download_dependencies # 依赖下载
      validate_targets # Pods 校验
      generate_pods_project # Pods Project 生成
      if installation_options.integrate_targets?
        integrate_user_project # User Project 整合
      else
        UI.section 'Skipping User Project Integration'
      end
      perform_post_install_actions # 收尾
    end

    下面会对这 5 个流程做一些简单分析,为了简单起见,我们会忽略一些细节。


    准备阶段

    这个流程主要是在 Pod Install 前做一些环境检查,并且初始化 Pod Install 的执行环境。


    依赖分析

    这个流程的主要目标是分析决议出所有依赖的版本,这里的依赖包括 Podfile 中引入的依赖,以及依赖本身引入的依赖,为 Downloader 和 Generator 流程做准备。


    这个过程的核心是构建 Molinillo 决议的环境:准备好 Specs 仓库,分析 Podfile 和 Podfile.lock,然后进行 Molinillo 决议,决议过程是基于 DAG(有向无环图)的,可以参考下图,按照最优顺序依次进行决议直到最后决议出所有节点上依赖的版本和来源。





    Version一般是用点分割的可以比较的序列,组件会以版本的形式对外发布
    Requirement一个或者多个版本限制的组合
    SourceSpecs 仓库,组件发版的位置,用于管理多个组件多个版本的一组描述文件
    DependencyUser Target 的依赖或者依赖的依赖,由依赖名称、版本、约束、来源构成
    PodfileRuby DSL 文件,用于描述 Xcode 工程中 Targets 的依赖列表
    Podfile.lockYAML 文件,Pod Install 后生成的依赖决议结果文件
    PodspecRuby DSL 文件,用于描述 Pod,包括名称、版本、子组件、依赖列表等
    Pod Target一个组件对应一个 Pod Target
    Aggregate Target用来聚合一组 Pod Target,User Target 会依赖对应的 Aggragate Target
    $HOME/.cocoapods/repos/本地存储需要使用的 Specs 仓库

    依赖下载

    这个流程的目标是下载依赖,下载前会根据依赖分析的结果 specifications 和 sandbox_state 生成需要下载的 Pods 列表,然后串行下载所有依赖。这里只描述 Cache 开启的情况,具体流程可以参考下图:



    CocoaPods 会根据 Pod 来源选择合适的下载器,如果是 HTTP 地址,使用 CURL 进行下载;如果是 Git 地址,使用 Git 进行拉取;CocoaPods 也支持 SVN/HG/SCP 等方式。

    iOSPlayground 工程的下载流程:



    Pods 工程生成

    这个流程的目标是生成 Pods 工程,根据依赖决议的结果 Pod Targets 和 Aggregate Targets,生成 Pods 工程,并生成工程中 Pod Targets 和 Aggregate Targets 对应的 Native Targets。


    CocoaPods 提供两种 Project 的生成策略:Single Project Generator 和 Multiple Project Generator,Single Project Generator 是指只生成 Pods/Pods.xcodeproj,Native Pod Target 属于 Pods.xcodeproj;Multiple Project 是 CocoaPods 1.7.0 引入的新功能,不只会生成 Pods/Pods.xcodeproj,并且会为每一个 Pod 单独生成 Xcode Project,Pod Native Target 属于独立的 Pod Xcode Project,Pod Xcode Project 是 Pods.xcodeproj 的子工程,相比 Single Project Generator,会有性能优势。这里我们以 Single Project 为例,来说明 Pods.xcodeproj 生成的一般流程。




    Pods/
    沙盒目录
    Pods/Pods.xcodeprojPod Target、Aggregate Target 的容器工程
    Pods/Manifest.lockPodfile.lock 的备份,项目构建前会和 Podfile.lock 比较,以判断当前的沙盒和工程对应
    Pods/Headers/管理 Pod 头文件的目录,支持基于 HEADER_SEARCH_PATHS 的头文件检索
    Pods/Target Support Files/CocoaPods 为 Pod Target、Aggregate Target 生成的文件,包括:xcconfig、modulemap、resouce copy script、framework copy scrpt 等

    User 工程整合

    这个流程的目标是将 Pods.xcodeproj 整合到 User.xcodeproj 上,将 User Target 整合到 CocoaPods 的依赖环境中,从而在后续的构建流程生效:



    User.xcodeproj壳工程,用于生成 App 等产品,名字一般自定义
    User Target壳工程中用于生成指定产品的 Target
    User.xcworkspaceCocoaPods 生成,合并 User.xcodeproj 和 Pods/Pods.xcodeproj

    User 工程构建

    Pod Install 执行完成后,就将 User Target 整合到了 CocoaPods 环境中。User Target 依赖 Aggregate Target,Aggregate Target 依赖所有 Pod Targets,Pod Targets 按照 Pod 描述文件(Podspec)中的依赖关系进行依赖,这些依赖关系保证了编译顺序

    iOSPlayground 工程中 User Target: iOSPlayground 依赖了 Aggregate Target 的产物 libPods-iOSPlayground.a


    编译完成后,就开始进行链接、资源整合、动态库整合、APP 签名等操作,直到最后生成完整 APP。Xcode 提供了 Build Phases 方便我们查看和编辑构建流程配置,同时我们也可以通过构建日志查看整个 APP 的构建流程:



    如何评估

    我们需要建立一些数据指标来进行衡量我们的优化结果,CocoaPods 内置了 ruby-prof(https://ruby-prof.github.io/) 工具。ruby-prof 是一个 Ruby 程序性能分析工具,可以用于测量程序耗时、对象分配以及内存占用等多种数据指标,提供了 TXT、HTML、CallGrind 三种格式。首先安装 ruby-prof,然后设置环境变量 COCOAPODS_PROFILE 为性能测试文件的地址,Pod Install 执行完成后会输出性能指标文件

    ruby-prof 提供的数据是我们进行 CocoaPods 效能优化的重要参考,结合这部分数据我们可以很方便地分析方法堆栈的耗时以及其他性能指标。


    但是 Ruby-prof 工具是 Ruby 方法级别,难以细粒度地查看实际 Pod Instal 过程中各个具体流程的耗时,可以作为数据参考,但是难以作为效率优化结果的标准。同时我们也需要一套体系来衡量 Pod Install 各个流程的耗时,基于这个诉求,我们自研了 CocoaPods 的 Profiler,并且在远端搭建了数据监控体系:

    1. Profiler 可以在本地打印各阶段耗时,也可以下钻到详细的流程

    install! consume : 5.376132s prepare consume : 0.002049s resolve_dependencies consume : 4.065177s download_dependencies consume : 0.001196s validate_targets consume : 0.037846s generate_pods_project consume : 0.697412s integrate_user_project consume : 0.009258s

    1. Profiler 会把数据上传到平台,方便进行数据可视化



    Profiler 除了上传 Pod Install 各个耗时指标以外,也会上传失败情况和错误日志,这些数据会被用于衡量稳定性优化的效果。

    优化实践

    对 Pod Install 的执行流程有了一定的了解后,基于 Ruby 语言的提供的动态性,我们开始尝试在 0 侵入、不影响现有研发流程的前提下,改造 CocoaPods 做来解决我们遇到的问题,并且取得了一些收益。

    Source 更新

    按需更新

    我们知道 CocoaPods 在进行依赖版本决议的时候,会从本地 Source 仓库(一般是多个 Git 仓库)中查找符合版本约束的 Podspecs,如果本地仓库中没有符合要求的,决议会失败。仓库中没有 Podspec 分为几种情况:

    1. 本地 Source 仓库没有更新,和远程 Source 仓库不同步
    2. 远程 Source 仓库没有发布符合版本约束的 Podspec

    原因 2 是符合预期的;原因 1 是因为研发同学没有主动更新本地 source repo 仓库,可以在 pod install 后添加 --repo-update 参数来强制更新本地仓库,但是每次都加上这个参数会导致 Pod Install 执行效率下降,尤其是对包含多个 source repo 的工程。

    UI.section 'Updating local specs repositories' do
      analyzer.update_repositories
    end if repo_update?


    怎么做可以避免这个问题,同时保证研发效率?

    1. 不主动更新仓库,如果找不到 Podspec,再自动更新仓库
    2. 不更新所有仓库,按需更新部分仓库
    3. 如果有新增组件,找不到 Podspec 后,自动更新所有仓库
    4. 如果部分更新后依然失败,自动更新所有仓库;这种情况出现在隐式依赖新增的情况


    仓库按需更新,是指基于 Podfile.lock 查找哪些依赖的版本不在所属的仓库内,标记该依赖所属的仓库为需要更新,循环执行,检查所有依赖,获取到所有需要更新的仓库,更新所有标记为需要更新的仓库。

    这样研发同学不需要关心本地 Source 仓库是否更新,仓库会按照最佳方式自动和远程同步。

    更新同步

    在仓库更新流程中也会出现并发问题,比如在抖音的 CI 环境上构建任务是并发执行的,在某些情况下多个任务会同时更新本地 source 仓库,Git 仓库会通过锁同步机制强制并发更新失败,这就导致了 CI 任务难以并发执行。如何解决并发导致的失败问题?

    1. 最简单的方式就是避免并发,一个机器同时只能执行一个任务,但是这会导致 CI 执行效率下降。
    2. 不同任务间进行 source 仓库隔离,CocoaPods 默认提供了这种机制,可以通过环境变量 CP_REPOS_DIR 的设置来自定义 source 仓库的根目录,但是 source 仓库隔离后,会导致同一个仓库占用多份磁盘,同时在需要更新的场景下,需要更新两次,这会影响到 CI 执行效率。

    方案 1 和方案 2 一定程度保证了任务的稳定性,但是影响了研发效率,更好的方式是只在需要同步的地方串行,不需要同步的地方并发执行。一个自然而然的想法就是使用锁,不同 CocoaPods 任务是不同的 Ruby 进程,在进程间做同步可以使用文件锁。通过文件锁机制,我们保证了只有一个任务在更新仓库。

    CocoaPods 仓库更新流程流程遇到的问题,本质是由于使用了本地的 Git 仓库来管理导致,在 CocoaPods 1.9.0 + ,引入 CDN Source 的概念,抖音也在尝试向 CDN Source 做迁移。

    依赖决议

    简化决议

    CocoaPods 的依赖版本决议流程是基于 Molinillo 的,Molinillo 是基于 DAG 来进行依赖解析的,通过构建图可以方便的进行依赖关系查找、依赖环查找、版本降级等。但是使用图来进行解析是有成本的,实际上大部分的本地依赖决议场景并不需要这么复杂,Podfile.lock 中的版本就是决议后的版本,大部分的研发流程直接使用 Podfile.lock 进行线性决议就可以,这可以大幅加快决议速度。

    Specification 缓存

    依赖分析流程中,CocoaPods 需要获取满足约束的 Specifications,1.7.5 上的流程是获取一个组件的所有版本的 Specifications 并缓存,然后从 Specifications 中筛选出满足约束的 Specifications。对于复杂的项目来说,往往对一个依赖的约束来自于多个组件,比如 A 依赖 F(>=0),B 依赖 F (>=0),在分析完 A 对 F 的依赖后,在处理 B 对 F 的依赖时,还是需要进行一次全量比较。通过优化 Specification 缓存层可以减少这部分耗时,直接返回。

    module Pod::Resolver
      def specifications_for_dependency(dependency, additional_requirements = [])
        requirement = Requirement.new(dependency.requirement.as_list + additional_requirements.flat_map(&:as_list))
        find_cached_set(dependency).
          all_specifications(warn_for_multiple_pod_sources).
     select { |s| requirement.satisfied_by? s.version }.
          map { |s| s.subspec_by_name(dependency.name, falsetrue) }.
          compact
      end
    end

    module Pod::Specification::Set
      def all_specifications(warn_for_multiple_pod_sources)
         @all_specifications ||= begin
          #...
        end
      end
    end

    优化后:


    module Pod::Resolver
      def specifications_for_dependency(dependency, additional_requirements = [])
        requirement_list = dependency.requirement.as_list + additional_requirements.flat_map(&:as_list)
        requirement_list.uniq!
        requirement = Requirement.new(requirement_list)
        find_cached_set(dependency).
          all_specifications(warn_for_multiple_pod_sources, requirement) .
          map { |s| s.subspec_by_name(dependency.name, falsetrue) }.
          compact
      end
    end

    module Pod::Specification::Set
      def all_specifications(warn_for_multiple_pod_sources, requirement)
        @all_specifications ||= {}
        @all_specifications[requirement]  ||= begin
          #...
        end
      end
    end

    CocoaPods 1.8.0 开始也引入了这个优化,但是 1.8.0 中并没有重载 Pod::Requirement 的 eql? 方法,这会导致使用 Pod::Requirement 对象做 Key 的情况下,没有办法命中缓存,导致缓存失效了,我们重载 eql? 生效决议缓存,加速了 Molinillo 决议流程,获得了很大的性能提升:

    module Pod::Requirement
      def eql?(other)
        @requirements.eql? other.requirements
      end
    end

    循环依赖发现

    当出现循环依赖时,CocoaPods 会报错,但报错信息只有谁和谁之间存在循环依赖,比如:

    There is a circular dependency between A/S1 and D/S1

    随着工程的复杂度提高,对于复杂的循环依赖关系,比如 A/S1 -> B -> C-> D/S2 -> D/S1 -> A/S1, 基于上面的信息我们很难找到真正的链路,而且循环依赖往往不止一条,subspec、default spec 等设置也提高了问题定位的复杂度。我们优化了循环依赖的报错,当出现循环依赖的时候,比如 A 和 D 之间有环,我们会查找 A -> D/S1 之前所有的路径,并打印出来:

    There is a circular dependency between A/S1 and D/S1 Possible Paths:A/S1 -> B -> C-> D/S2 -> D/S1 -> A/S1 A/S1 -> B -> C -> C2 -> D/S2 -> D/S1 -> A/S1 A/S1 -> B -> C -> C3 -> C2 -> D/S2 -> D/S1 -> A/S1

    沙盒分析缓存

    SandboxAnalyzer 主要用于分析沙盒,通过决议结果和沙盒内容判断哪些 Pods 需要删除哪些 Pods 需要重装,但是在分析过程中,存在大量的重复计算,我们缓存了 sandbox analyzer 计算的中间结果,使 sandbox analyzer 流程耗时减少 60%。

    依赖下载

    大型项目往往要引入几百个组件,一旦组件发布新版本或者没有命中缓存就会触发组件下载,依赖下载慢也成为大型项目反馈比较集中的问题。

    依赖并发下载

    CocoaPods 一个很明显的问题就是依赖是串行下载的,串行下载难以达到带宽峰值,而且下载过程除了网络访问,还会进行解压缩、文件准备等,这些过程中没有进行网络访问,如果把下载并行是可以提高依赖下载效率的。我们将抖音的下载过程优化为并发操作,下载流程总时间减少了 60%以上。

    HTTP API 下载

    CocoaPods 支持多种下载方式的,比如 Git、Http 等。一般组件以源码发布,会使用 Git 地址作为代码来源,但是 Git 下载是比 Http 下载慢的,一是 Git 下载需要做额外的处理和校验,速度和稳定性要低于 HTTP 下载,二是在组件是通过 Git 和 Commit 指明 source 发布的情况下,Git 下载页会克隆仓库的日志 GitLog, 对于开发比较频繁的项目,日志大小要远大于仓库实际大小,这会导致组件下载时间变长。我们基于 Gitlab API 将 Git 地址转化为 HTTP 地址进行下载,就可以加快这部分组件的下载速度了。

    沙盒软连接

    CocoaPods 在安装依赖的时候,会在沙箱 Pods 目录下查找对应依赖,如果对应依赖不存在,则会将缓存中的依赖文件拷贝到沙箱 Pods 目录下。对于本地有多个工程的情况,Pods 目录占用磁盘就会更多。同时,将缓存拷贝到沙箱也会耗时,对于抖音工程,如果所有的内容都要从缓存拷贝到沙箱,大概需要 60s 左右。我们使用软连接替换拷贝,直接通过链接缓存中的 Pod 内容来使用依赖,而不是将缓存拷贝到 Pods 沙箱目录中,从而减少这部分磁盘占用,同时减少拷贝的时间。

    缓存有效检查

    在抖音使用 CocoaPods 的过程中,尤其是 CI 并发环境,存在缓存中文件不全的情况,缺少部分文件或者整个文件夹,这会导致编译失败或者运行存在问题。CocoaPods 本身有保证 Pods 缓存有效的机制:



    def path_for_spec(request, slug_opts = {})
      path = root + 'Specs' + request.slug(slug_opts)
      path.sub_ext('.podspec.json')
    end

    但是在 依赖Podspec写入缓存 中,CoocoPods 存在 BUG。path.sub_ext('.podspec.json')会导致部分版本信息被错误地识别为后缀名,比如 XXX 0.1.8-5cd57.podspec.json 版本写入到缓存中变为 0.1.podspec.json, 丢失了小版本和内容标示信息,会导致了整个 Pod 缓存有效性校验失效。比如 XXX 0.1.8 缓存执行成功,XXX 0.1.9 在缓存 copy、prepare 的流程被取消,实际上很大概率上 XXX 0.1.9 的缓存是不完整的,但是下次执行的时候,缓存目录存在,Podspec 存在(0.1.podspec.json),不完整的缓存被判定为有效,使用了错误的缓存,导致了编译失败。

    修改 path_for_spec 逻辑,保证依赖 Podspec 缓存写入到正确的文件 0.1.8-5cd57.podspec.json,而不是 0.1.podspec.json。

    def path_for_spec(request, slug_opts = {})
      path = root + 'Specs' + request.slug(slug_opts)
      Pathname.new(path.to_path + '.podspec.json')
    end

    依赖下载同步

    在缓存下载的环境,依然会出现并发问题,我们通过对 Pod 下载流程加文件锁的机制来保证并发下下载任务的稳定。

    Pods 工程生成

    增量安装

    CocoaPods 在 1.7.0+ 提供了新的 Pods Project 的生成策略:Multiple Project Generator。通过开启多 Project「generate_multiple_pod_projects」,可以提高 Xcode 工程的检索速度。在开启多 Project 的基础上,我们可以开启增量安装「incremental_installation」,这样在 Pods 工程生成的时候,会基于上次 Pod Install 的缓存按需生成部分 Pod Target 而不会全量生成所有 Pod Target,对二次 Pod Install 的执行效率改善很明显,以抖音为例,二次 Pod Install (增量)是首次 Pod Install (全量)的 40%左右。这个是 CocoaPods 的 Feature,就不展开说明了。

    单 Target/Configuration 安装

    大部分工程会包含多个业务 Target 和 Build Configuration,Pod Install 会对所有的 Target 进行安装,对所有的 Build Configuration 进行配置。但是实际本地开发过程中一般只会使用一个 Build Configuration 下的一个 Target,其他 Target 和 Configuratioins 的依赖安装实际上是冗余操作。比如有些依赖只有某几个 Target 有,如果全量安装,即使不使用这些 Target,也要下载和集成这些依赖。抖音工程包括多个业务 Target 和多个构建 Build Configuration,不同业务 Target 之间依赖的差集有几十个,只对特定 Target 和特定的 Configuration 进行集成能够获得比较明显的优化,这个方案落地后:

    1. Pod Install 安装依赖数量减少,决议时间、Pod 工程生成时间减少;
    2. 单 Target/Configuration 下 Pod 工程复杂度减少, Xcode 索引速度改善明显,以抖音为例子,索引耗时减少了 60%;
    3. 可以为每个 Target、每个 Configuration 配置独立的依赖版本;
    4. 每个 Target 的编译隔离,避免了其他 Target 的依赖影响当前 Target 的编译。

    Pod 是全量 Target 安装,在编译的时候并没有对非当前 Target 的依赖做完整的隔离,而是在链接的时候做了隔离,但是 OC 的方法调用是消息转发机制的,因此没有链接指定库的问题被延迟到了运行时才能发现 (unrecognized selector)。使用单 Target 的方式可以提前发现这个类问题。

    缓存 FileAccessors

    在 Pods 工程生成流程中有三个流程会比较耗时,这些数据每次 Pod Install 都需要重新生成:

    • Pod 目录下的文件和目录列表,需要对目录下的所有节点做遍历;
    • Pod 目录下的动态库列表,需要分析二进制格式,判断是否为动态库;
    • Pod 文件的访问策略缓存 glob_cache,这个 glob_cache 是用于访问组件仓库中不同类型文件的,比如 source files、headers、frameworks、bundles 等。

    但其实这些数据对固定版本的依赖都是唯一的,如果可以缓存一份就可以避免二次生成导致的额外耗时,我们补充了这个缓存层,以抖音为例子,使 Pod Clean Install 减少了 36%,Pod No-clean Install 减少了 42%

    添加 FileAccessors 缓存层后,在效率上获得提升的同时,在稳定性上也获得了提升。因为在本地记录了 Pod 完整的文件结构,因此我们可以对 Pod 的内容做检查,避免 Pod 内容被删除导致构建失败。比如研发同学误删了缓存中的二进制库,CocoaPods 默认是难以发现的,需要延迟到链接阶段报 Symbol Not Found 的错误,但是基于 FileAccessors 缓存层,我们可以在 Pod Install 流程对 Pod 内容做检查,提前暴露出二进制库缺失,触发重新下载。

    提高编译并发度

    Pod Target 的依赖关系会保证 Target 按顺序编译,但是会导致 Target 编译的并发度下降,一定程度上降低了编译效率。其实生成静态库的 Pod Target 不需要按顺序进行编译,因为静态库编译不依赖产物,只是在最后进行链接。通过移除静态库的 Pod Target 对其他 Target 的依赖,可以提高整体的编译效率。

    在 Multi Project 下,「Dependency Subproject」会导致索引混乱,移除静态库的 Pod Target 对其他 Target 的依赖后,我们也可以删除 Dependent Pod Subproject,减少 Xcode 检索问题。

    Arguments Too Long

    超大型工程在编译时稳定性降低,往往会因为工程放置的目录长产生一些未定义错误,其中错误比较大的来源就是 Arguments Too Long,表现为:

    Build operation failed without specifying any errors ;Verify final result code for completed build operation

    根本原因是依赖数目过多导致编译/链接/打包流程的环境变量总数过多,从而导致命令长度超过 Unix 的限制,在构建流程中表现为各种不符合预期的错误,具体可以见https://github.com/CocoaPods/CocoaPods/issues/7383。

    其实整个构建流程的环境变量主要来源于系统 和 Build Settings,系统环境一般是固定的,影响比较大的就是 Build Settings 里的配置,其中影响最大的是:

    • 编译参数

      • GCC_PREPROCESSOR_MACRO 预编译宏
      • HEADER_SEARCH_PATHS 头文件查找路径
    • 链接参数

      • FRAMEWORK_SEARCH_PATHS FRAMEWORK 查找路径
      • LIBRARY_SEARCH_PATHS LIBRARY 查找路径
      • OTHER_LDFLAGS 用于声明连接参数,包括静态库名称

    一个比较直接的解决方案就是缩短工程目录路径长度来临时解决这个问题,但如果要彻底解决,还是要彻底优化 Build Setting 参数的复杂度,减少依赖数量可能会比较难,一个比较好的思路就是优化参数的组织方式。

    • GCC_PREPROCESSOR_MACRO,在壳工程拆分掉业务代码后,注入到 User Target 的预编译宏可以逐步废弃;
    • HEADER_SEARCH_PATHS 会引入所有头文件的目录作为 Search Path,这部分长度会随着 Pod 数目的增加不断增长,导致构建流程变量过长,从而让阻塞打包。我们基于 HMAP 将 Header Search Path 合并成一个来减少 Header Search Path 的复杂度。除了用于优化参数长度外,这个优化的主要用途是可以减少 header 的查找复杂度,从而提高编译速度,我们在后续的系列文章会介绍。
    HEADER_SEARCH_PATHS = $(inherited) "${PODS_ROOT}/Headers/hmap/37727fabd99bae1061668ae04cfc4123/Compile_Public.hmap"
    • 链接参数:FRAMEWORK_SEARCH_PATHS、LIBRARY_SEARCH_PATHS、OTHER_LDFLAGS 声明是为了给链接器提供可以查找的静态库列表。OTHER_LDFLAG S 提供 filelist 的方式来声明二进制路径列表,filelist 中是实际要参与链接的静态库路径,这样我们就可以三个参数简化为 filelist 声明,从而减少了链接参数长度。除了用于优化参数长度外,这个优化的主要用途是可以减少静态库的查找复杂度,从而提高链接速度,我们在后续的系列文章会介绍。
    OTHER_LDFLAGS[arch=*] = $(inherited) -filelist "xx-relative.filelist,${PODS_CONFIGURATION_BUILD_DIR}"


    研发流程

    新增文件

    组件化的一个目标是业务代码按架构设计拆分成组件 Pod。但如果在一个组件中新增文件,比如在组件 A 中新增文件,依赖组件 A 的组件 B 是不能直接访问新增文件的头文件的,需要重新执行 Pod Install,这样会影响整体的研发效率。

    为什么组件 B 不能够访问组件 A 的新增文件?在 Pod Install 后,组件 A 公共访问的头文件被索引在 Pods/Headers/Public/A/ 目录下,组件 B 的 HEADER_SEARCH_PATH 中配置了 Pods/Headers/Public/A/,因此就可以在组件 B 的代码里引入组件 A 的头文件。新增头文件的头文件没有在目录中索引,所以组件 B 就访问不到了。只需要在添加文件后,建立新增头文件的索引到 Pods/Headers/Public/A/目录下,就可以为组件 B 提供组件 A 新增文件的访问能力,这样就不需要重新 Pod Install 了。

    Lockfile 生成

    在依赖管理的部分场景中,我们只需要进行依赖决议,重新生成 Podfile.lock,但通过 Pod Install 生成是需要执行依赖下载及后续流程的,这些流程是比较耗时的,为了支持 Podfile.lock 的快速生成,可以对 install 命令做了简化,在依赖决议后就可以直接生成 Podfile.lock:

    class Pod::Installer
      def quick_generate_lockfile!
        # 初始化 sandbox 环境
        quick_prepare_env
        quick_resolve_dependencies
        quick_write_lockfiles
      end
    end

    总结

    CocoaPods 的整体优化方案以 RubyGem 「seer-optimize」 的方式输出,对 CocoaPods 代码 0 侵入,只要接入 seer-optimize 就可以生效,目前在字节内部已经被十几个产品线使用了:抖音、头条、西瓜、火山、多闪、瓜瓜龙等,执行效率和稳定性上都获得了明显的效果。比如抖音接入 optimize 开启相关优化后,全量 Pod Install 耗时减少 50%,增量 Pod Install 平均耗时减少 65%。

    seer-optimize 是抖音 iOS 工程化解决方案 Seer 的的一部分,Seer 致力于解决客户端在依赖管理和研发流程中遇到的问题,改善研发效率和稳定性,后续会逐步开源,以改善 iOS 的研发体验。


    摘自字节跳动技术团队:https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247489409&idx=1&sn=4f46332921d1f45594670d35bfa7d19a&chksm=e9d0dc63dea75575c526ef8f0e118b7e95d1cd3242de93e54d1db4e577dfe6406a7191a13b94&scene=178&cur_album_id=1590407423234719749#rd


    收起阅读 »

    从精准化测试看ASM在Android中的强势插入-字节码

    从精准化测试看ASM在Android中的强势插入-字节码字节码是ASM的基础,要想熟练的使用ASM,那么了解字节码就是必备基础。Class的文件格式Class文件作为Java虚拟机所执行的直接文件,内部结构设计有着固定的协议,每一个Class文件只对应一个类或...
    继续阅读 »

    从精准化测试看ASM在Android中的强势插入-字节码

    字节码是ASM的基础,要想熟练的使用ASM,那么了解字节码就是必备基础。

    Class的文件格式

    Class文件作为Java虚拟机所执行的直接文件,内部结构设计有着固定的协议,每一个Class文件只对应一个类或接口的定义信息。

    每个Class文件都以8位为单位的字节流组成,下面是一个Class文件中所包括的内容,在Class文件中,各项内容按照严格顺序连续存放,Java虚拟机只要按照协议顺序来读取即可。

    ClassFile { 
    u4 magic;
    u2 minor_version;
    u2 major_version;
    u2 constant_pool_count;
    cp_info constant_pool[constant_pool_count-1];
    u2 access_flags;
    u2 this_class;
    u2 super_class;
    u2 interfaces_count;
    u2 interfaces[interfaces_count];
    u2 fields_count;
    field_info fields[fields_count];
    u2 methods_count;
    method_info methods[methods_count];
    u2 attributes_count;
    attribute_info attributes[attributes_count];
    }

    在Class文件结构中,上面各项的含义如下。

    Name含义
    magic作为一个魔数,确定这个文件是否是一个能被虚拟机接受的class文件,值固定为0xCAFEBABE。
    minor_version,major_version分别表示class文件的副,主版本号,不同版本的虚拟机实现支持的Class文件版本号不同。
    constant_pool_count常量池计数器,constant_pool_count的值等于常量池表中的成员数加1。
    constant_pool常量池,constant_pool是一种表结构,包含class文件结构及其子结构中引用的所有字符常量、类或接口名、字段名和其他常量。
    access_flagsaccess_flags是一种访问标志,表示这个类或者接口的访问权限及属性,包括有ACC_PUBLIC,ACC_FINAL,ACC_SUPER等等。
    this_class类索引,指向常量池表中项的一个索引。
    super_class父类索引,这个值必须为0或者是对常量池中项的一个有效索引值,如果为0,表示这个class只能是Object类,只有它是唯一没有父类的类。
    interfaces_count接口计算器,表示当前类或者接口的直接父接口数量。
    interfaces[]接口表,里面的每个成员的值必须是一个对常量池表中项的一个有效索引值。
    fields_count字段计算器,表示当前class文件中fields表的成员个数,每个成员都是一个field_info。
    fields字段表,每个成员都是一个完整的fields_info结构,表示当前类或接口中某个字段的完整描述,不包括父类或父接口的部分。
    methods_count方法计数器,表示当前class文件methos表的成员个数。
    methods方法表,每个成员都是一个完整的method_info结构,可以表示类或接口中定义的所有方法,包括实例方法,类方法,以及类或接口初始化方法。
    attributes_count属性表,其中是每一个attribute_info,包含以下这些属性,InnerClasses,EnclosingMethod,Synthetic,Signature,Annonation等。

    以上内容来自网络,我也不知道从哪copy来的。

    字节码和Java代码还是有很大区别的。

    • 一个字节码文件只能描述一个类,而一个Java文件中可以则包含多个类。当一个Java文件是描述一个包含内部类的类,那么该Java文件则会被编译为两个类文件,文件名上通过「$」来区分,主类文件中包含对其内部类的引用,定义了内部方法的内部类会包含外部引用
    • 字节码文件中不包含注释,只有有效的可执行代码,例如类、字段、方法和属性
    • 字节码文件中不包含package和import部分, 所有类型名字都必须是完全限定的
    • 字节码文件还包含常量池(constant pool),这些内容是编译时生成的,常量池本质上就是一个数组存储了类中出现的所有数值、字符串和类型常量,这些常量仅需要在这个常量池部分中定义一次,就可以利用其索引,在类文件中的所有其他各部分进行引用

    字节码的执行过程

    字节码在Java虚拟机中是以堆栈的方式进行运算的,类似CPU中的寄存器,在Java虚拟机中,它使用堆栈来完成运算,例如实现「a+b」的加法操作,在Java虚拟机中,首先会将「a」push到堆栈中,然后再将「b」push到堆栈中,最后执行「ADD」指令,取出用于计算的两个变量,完成计算后,将返回值「a+b」push到堆栈中,完成指令。

    类型描述符

    我们在Java代码中的类型,在字节码中,有相应的表示协议。

    Java TypeType description
    booleanZ
    charC
    byteB
    shortS
    intI
    floatF
    longJ
    doubleD
    objectLjava/lang/Object;
    int[][I
    Object[][][[Ljava/lang/Object;
    voidV
    引用类型L
    • Java基本类型的描述符是单个字符,例如Z表示boolean、C表示char
    • 类的类型的描述符是这个类的全限定名,前面加上字符L , 后面跟上一个「;」,例如String的类型描述符为Ljava/lang/String;
    • 数组类型的描述符是一个方括号后面跟有该数组元素类型的描述符,多维数组则使用多个方括号

    借助上面的协议分析,想要看到字节码中参数的类型,就比较简单了。

    方法描述符

    方法描述符(方法签名)是一个类型描述符列表,它用一个字符串描述一个方法的参数类型和返回类型。

    方法描述符以左括号开头,然后是每个形参的类型描述符,然后是是右括号,接下来是返回类型的类型描述符,例如,该方法返回void,则是V,要注意的是,方法描述符中不包含方法的名字或参数名。

    Java方法声明方法描述符说明
    void m(int i, float f)(IF)V接收一个int和float型参数且无返回值
    int m(Object o)(Ljava/lang/Object;)I接收Object型参数返回int
    int[] m(int i, String s)(ILjava/lang/String;)[I接受int和String返回一个int[]
    Object m(int[] i)([I)Ljava/lang/Object;接受一个int[]返回Object

    字节码示例

    我们来看下这段简单的代码,在字节码下是怎样的。

    image-20210623103259980

    通过ASMPlugin,我们看下生成的字节码,如下所示。

    image-20210623103419893

    可以发现,这里主要分成了两个部分——init和onCreate。

    Java中的每一个方法在执行的时候,Java虚拟机都会为其分配一个「栈帧」,栈帧是用来存储方法中计算所需要的所有数据的。

    其中第0个元素就是「this」,如果方法有参数传入会排在它的后面。

    字节码中有很多指令,下面对一些比较常用的指令进行下讲解。

    • ALOAD 0:这个指令是LOAD系列指令中的一个,它的意思表示push当前第0个元素到堆栈中。代码上相当于使用「this」,A表示这个数据元素的类型是一个引用类型。类似的指令还有:ALOAD,ILOAD,LLOAD,FLOAD,DLOAD,它们的作用就是针对不用数据类型而准备的LOAD指令
    • INVOKESPECIAL:这个指令是调用系列指令中的一个。其目的是调用对象类的方法。后面需要给上父类的方法完整签
    • INVOKEVIRTUAL:这个指令区别于INVOKESPECIAL的是,它是根据引用调用对象类的方法
    • INVOKESTATIC:调用类的静态方法

    大家不用完全掌握这些指令,结合代码来看的话,还是能看懂的,我们需要的是修改字节码,而不是从0开始。

    对于Java源文件:如果只有一个方法,编译生成时,也会有两个方法,其中一个是默认构造函数 对于Kotlin源文件:如果只有一个方法,编译生成时,会产生四个方法,一个是默认构造函数,还有两个是kotlin合成的方法,以及退出时清除内存的默认函数

    ASM Code

    再结合ASM Code来看,还是上面的例子。

    默认的构造函数。

    image-20210623105109646

    onCreate:

    image-20210623105143214

    这里面有些生成的代码,例如:

    Label label0 = new Label();
    methodVisitor.visitLabel(label0);
    methodVisitor.visitLineNumber(9, label0);
    methodVisitor.visitLocalVariable("this", "Lcom/yw/asmtest/MainActivity;", null, label0, label4, 0);

    这些都是调试代码和写入变量表的方法,我们不必关心。

    剩下的代码,就是我们可以在ASM中所需要的代码。

    收起阅读 »

    Android基础到进阶UI CheckedTextView 使用+实例

    CheckedTextView是什么 CheckedTextView继承自TextView且实现了Checkable接口,对TextView界面和显示进行了扩展的控件,支持Checkable。可以实现 单选或多选功能,在你懒得使用两者结合的时候,这就是不二选...
    继续阅读 »

    CheckedTextView是什么


    CheckedTextView继承自TextView且实现了Checkable接口,对TextView界面和显示进行了扩展的控件,支持Checkable。可以实现 单选多选功能,在你懒得使用两者结合的时候,这就是不二选择。


    主要XML属性如下:


    android:checkMark 按钮样式。



    • 默认单选框样式:android:checkMark="?android:attr/listChoiceIndicatorSingle"

    • 默认复选框样式:android:checkMark="?android:attr/listChoiceIndicatorMultiple"

    • 当然也可以使用drawable自定义样式


    android:checkMarkTint 按钮的颜色。


    android:checkMarkTintMode 混合模式按钮的颜色。


    android:checked 初始选中状态,默认false。


    在点击事件里判断状态设置状态


    CheckedTextView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            CheckedTextView.toggle();//切换选中与非选中状态
        }
    });

    咱们看看CheckedTextView.toggle()是干嘛的


    public void toggle() {
        setChecked(!mChecked);
    }

    就是实现这个控件的状态反设置。


    第一次点击无效


    android:focusableInTouchMode="true",这个属性加上会导致第一次点击触发不了选择事件。


    实例


    官方文档指出,「结合ListView使用更佳」,咱下面通过一个栗子了解一下,下面是效果图:



    1.主界面CheckedTextViewActivity.java


    public class CheckedTextViewActivity extends AppCompatActivity {
        private ListView lv_ctv_multiple,lv_ctv_single;
        private CtvMultipleAdapter ctvAdapter;
        private TextView tv_multiple_title,tv_single_title;
        private CtvSingleAdapter ctvSingleAdapter;
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_textview_ctv);//加载布局文件
            initView();
        }
        private void initView() {
            ArrayList<String> ctvString = new ArrayList<>();
            ctvString.add("秦始皇嬴政");
            ctvString.add("汉高祖刘邦");
            ctvString.add("唐太宗李世民");
            ctvString.add("宋太祖赵匡胤");
            //复选
            lv_ctv_multiple = findViewById(R.id.lv_ctv_multiple);
            tv_multiple_title = findViewById(R.id.tv_multiple_title);
            ctvAdapter = new CtvMultipleAdapter(this,ctvString,tv_multiple_title);
            lv_ctv_multiple.setAdapter(ctvAdapter);
            //设置Item间距
            lv_ctv_multiple.setDividerHeight(0);

            //单选
            lv_ctv_single = findViewById(R.id.lv_ctv_single);
            tv_single_title = findViewById(R.id.tv_single_title);
            ctvSingleAdapter = new CtvSingleAdapter(this,ctvString,tv_single_title);
            lv_ctv_single.setAdapter(ctvSingleAdapter);
            //设置Item间距
            lv_ctv_single.setDividerHeight(0);

        }
    }

    2.主布局activity_textview_ctv.xml


    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:padding="@dimen/dimen_20">

        <TextView
            android:id="@+id/tv_multiple_title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="@dimen/dimen_10"
            android:textColor="@color/black"
            android:text="复选"
            android:textSize="@dimen/text_size_16" />

        <ListView
            android:id="@+id/lv_ctv_multiple"
            android:layout_width="match_parent"
            android:layout_height="180dp" />


        <TextView
            android:id="@+id/tv_single_title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginLeft="@dimen/dimen_10"
            android:text="单选"
            android:textColor="@color/color_188FFF"
            android:layout_marginTop="@dimen/dimen_10"
            android:textSize="@dimen/text_size_20" />

        <ListView
            android:id="@+id/lv_ctv_single"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </LinearLayout>

    3.复选框Adapter


    public class CtvMultipleAdapter extends BaseAdapter {
        private LayoutInflater mInflater;//得到一个LayoutInfalter对象用来导入布局
        private List<String> list;
        private TextView tvTitle;
        private List<String> selectList = new ArrayList<>();
        public CtvMultipleAdapter(Context context, List<String> list, TextView tv) {
            this.mInflater = LayoutInflater.from(context);
            this.list = list;
            tvTitle = tv;
        }

        @Override
        public int getCount() {
            return list.size();
        }

        @Override
        public Object getItem(int position) {
            return null;
        }

        @Override
        public long getItemId(int position) {
            return 0;
        }

        @Override
        public View getView(final int position, View convertView, ViewGroup parent) {
            final CtvViewHolder holder;
            final String string = list.get(position);
            //观察convertView随ListView滚动情况
            if (convertView == null) {
                convertView = mInflater.inflate(R.layout.item_ctv_multiple, null);
                holder = new CtvViewHolder();
                /*得到各个控件的对象*/
                holder.ctv_top = (CheckedTextView) convertView.findViewById(R.id.ctv_top);
                convertView.setTag(holder);//绑定ViewHolder对象
            } else {
                holder = (CtvViewHolder) convertView.getTag();//取出ViewHolder对象
            }
            holder.ctv_top.setText(string);
            //默认选中状态
            if(holder.ctv_top.isChecked()){
                //list未包含选中string;
                if(!selectList.contains(string)){
                    selectList.add(string);
                }
            }
            if (selectList.size() == 0) {
                tvTitle.setText("");
            } else {
                tvTitle.setText(selectList.toString());
            }

            holder.ctv_top.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    holder.ctv_top.toggle();//切换选中与非选中状态
                    //单选
                    if(holder.ctv_top.isChecked()){//
                        //list未包含选中string;
                        if(!selectList.contains(string)){
                            selectList.add(string);
                        }
                    }else{
                        //list未包含选中string;
                        if(selectList.contains(string)){
                            selectList.remove(string);
                        }
                    }
                    if (selectList.size() == 0) {
                        tvTitle.setText("");
                    } else {
                        tvTitle.setText(selectList.toString());
                    }

                }
            });
            return convertView;
        }
        /*存放控件*/
        public class CtvViewHolder {
            public CheckedTextView ctv_top;
        }

        @Override
        public boolean areAllItemsEnabled() {
            return false;//Item不可点击
        }

        @Override
        public boolean isEnabled(int position) {
            return false;//Item不可点击
            // 拦截事件交给上一级处理
            //return super.isEnabled(position);
        }

    }

    4.复选框adapter对应布局


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

        <CheckedTextView
            android:id="@+id/ctv_top"
            android:checked="true"
            android:checkMark="?android:attr/listChoiceIndicatorMultiple"
            android:checkMarkTint="@color/color_FF773D"
            android:padding="10dp"
            android:textSize="16sp"
            android:layout_marginTop="3dp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

    </LinearLayout>

    这里用到checkMark(默认复选框样式)、checkMarkTint(复选框颜色设为黄色)、和checked(true默认选中)几个属性,可以更好的理解他们。


    5.单选框adapter


      private String selectStr="";//全局变量
            holder.ctv_top.setText(string);
            holder.ctv_top.setChecked(selectStr.equals(string));
            holder.ctv_top.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    holder.ctv_top.toggle();//切换选中与非选中状态
                    //单选
                    if(holder.ctv_top.isChecked()){
                        selectStr=string;
                    }else{
                        selectStr="";
                    }
                    tvTitle.setText(selectStr);
                    notifyDataSetChanged();
                }
            });

    大部分与复选框CtvMultipleAdapter设置相同,仅部分不同就不做多重复了。


    6.单选框adapter对应布局


     <CheckedTextView
            android:id="@+id/ctv_top"
            android:checkMark="?android:attr/listChoiceIndicatorSingle"
            android:padding="10dp"
            android:textSize="16sp"
            android:layout_marginTop="3dp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />


    仅使用单选默认样式。


    7.逻辑处理从adapter放在主界面处理


      ListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
                @Override
                public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                    //在这里进行单选复选的逻辑处理
                }
            });

    使用CheckedTextView配合ListView实现单选与多选的功能我们实现了。到这里,关于CheckedTextView我们也就介绍完了,嘿嘿。



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

    Binder概述,快速了解Binder体系

    前言 众所周知,Binder是Android系统中最主要的进程间通信套件,更具体一点,很多文章称之为Binder驱动,那为什么说它是一个驱动呢,驱动又是何物,让我们自底向上,从内核中的Binder来一步步揭开它的面纱。本文重点在帮助读者对于Binder系统有...
    继续阅读 »

    前言


    众所周知,Binder是Android系统中最主要的进程间通信套件,更具体一点,很多文章称之为Binder驱动,那为什么说它是一个驱动呢,驱动又是何物,让我们自底向上,从内核中的Binder来一步步揭开它的面纱。本文重点在帮助读者对于Binder系统有一个简略的了解,所以写得比较笼统,后续文章会详细分析。


    Binder到底是什么


    Android系统内核是Linux,每个进程有自己的虚拟地址空间,在32位系统下最大是4GB,其中3GB为用户空间,1GB为内核空间;每个进程用户空间相对独立,而内核空间是一样的,可以共享,如下图


    地址空间.png


    Linux驱动运行在内核空间,狭义上讲是系统用于控制硬件的中间程序,但是归根结底它只是一个程序一段代码,所以具体实现并不一定要和硬件有关。Binder就是将自己注册为一个misc类型的驱动,不涉及硬件操作,同时自身运行于内核中,所以可以当作不同进程间的桥梁实现IPC功能。


    Linux最大的特点就是一切皆文件,驱动也不例外,所有驱动都会被挂载在文件系统dev目录下,Binder对应的目录是/dev/binder,注册驱动时将open release mmap等系统调用注册到Binder自己的函数,这样的话在用户空间就可以通过系统调用以访问文件的方式使用Binder。下面来粗略看一下相关代码。


    device_initcall函数用于注册驱动,由系统调用


    binder_init中调用misc_register注册一个名为binder的misc驱动,同时指定函数映射,将binder_open映射到系统调用open,这样就可以通过open("/dev/binder")来调用binder_open函数了


    drivers/android/binder.c


    // 驱动函数映射
    static const struct file_operations binder_fops = {
    .owner = THIS_MODULE,
    .poll = binder_poll,
    .unlocked_ioctl = binder_ioctl,
    .compat_ioctl = binder_ioctl,
    .mmap = binder_mmap,
    .open = binder_open,
    .flush = binder_flush,
    .release = binder_release,
    };

    // 注册驱动参数结构体
    static struct miscdevice binder_miscdev = {
    .minor = MISC_DYNAMIC_MINOR,
    // 驱动名称
    .name = "binder",
    .fops = &binder_fops
    };

    static int binder_open(struct inode *nodp, struct file *filp){......}
    static int binder_mmap(struct file *filp, struct vm_area_struct *vma){......}

    static int __init binder_init(void)
    {
    int ret;
    // 创建名为binder的单线程的工作队列
    binder_deferred_workqueue = create_singlethread_workqueue("binder");
    if (!binder_deferred_workqueue)
    return -ENOMEM;
    ......
    // 注册驱动,misc设备其实也就是特殊的字符设备
    ret = misc_register(&binder_miscdev);
    ......
    return ret;
    }
    // 驱动注册函数
    device_initcall(binder_init);

    Binder的简略通讯过程


    一个进程如何通过binder和另一个进程通讯?最简单的流程如下



    1. 接收端进程开启一个专门的线程,通过系统调用在binder驱动(内核)中先注册此进程(创建保存一个bidner_proc),驱动为接收端进程创建一个任务队列(biner_proc.todo)

    2. 接收端线程开始无限循环,通过系统调用不停访问binder驱动,如果该进程对应的任务队列有任务则返回处理,否则阻塞该线程直到有新任务入队

    3. 发送端也通过系统调用访问,找到目标进程,将任务丢到目标进程的队列中,然后唤醒目标进程中休眠的线程处理该任务,即完成通讯


    在Binder驱动中以binder_proc结构体代表一个进程,binder_thread代表一个线程,binder_proc.todo即为进程需要处理的来自其他进程的任务队列


    struct binder_proc {
    // 存储所有binder_proc的链表
    struct hlist_node proc_node;
    // binder_thread红黑树
    struct rb_root threads;
    // binder_proc进程内的binder实体组成的红黑树
    struct rb_root nodes;
    ......
    }

    Binder的一次拷贝


    众所周知Binder的优势在于一次拷贝效率高,众多博客已经说烂了,那么什么是一次拷贝,如何实现,发生在哪里,这里尽量简单地解释一下。


    上面已经说过,不同进程通过在内核中的Binder驱动来进行通讯,但是用户空间和内核空间是隔离开的,无法互相访问,他们之间传递数据需要借助copy_from_user和copy_to_user两个系统调用,把用户/内核空间内存中的数据拷贝到内核/用户空间的内存中,这样的话,如果两个进程需要进行一次单向通信则需要进行两次拷贝,如下图。


    2copy.png


    Binder单次通信只需要进行一次拷贝,因为它使用了内存映射,将一块物理内存(若干个物理页)分别映射到接收端用户空间和内核空间,达到用户空间和内核空间共享数据的目的。


    发送端要向接收端发送数据时,内核直接通过copy_from_user将数据拷贝到内核空间映射区,此时由于共享物理内存,接收进程的内存映射区也就能拿到该数据了,如下图。


    binder_mmap.png


    代码实现部分


    用户空间通过mmap系统调用,调用到Binder驱动中binder_mmap函数进行内存映射,这部分代码比较难读,感兴趣的可以看一下。


    drivers/android/binder.c


    binder_mmap创建binder_buffer,记录进程内存映射相关信息(用户空间映射地址,内核空间映射地址等)


    static int binder_mmap(struct file *filp, struct vm_area_struct *vma)
    {
    int ret;
    //内核虚拟空间
    struct vm_struct *area;
    struct binder_proc *proc = filp->private_data;
    const char *failure_string;
    // 每一次Binder传输数据时,都会先从Binder内存缓存区中分配一个binder_buffer来存储传输数据
    struct binder_buffer *buffer;

    if (proc->tsk != current)
    return -EINVAL;
    // 保证内存映射大小不超过4M
    if ((vma->vm_end - vma->vm_start) > SZ_4M)
    vma->vm_end = vma->vm_start + SZ_4M;
    ......
    // 采用IOREMAP方式,分配一个连续的内核虚拟空间,与用户进程虚拟空间大小一致
    // vma是从用户空间传过来的虚拟空间结构体
    area = get_vm_area(vma->vm_end - vma->vm_start, VM_IOREMAP);
    if (area == NULL) {
    ret = -ENOMEM;
    failure_string = "get_vm_area";
    goto err_get_vm_area_failed;
    }
    // 指向内核虚拟空间的地址
    proc->buffer = area->addr;
    // 用户虚拟空间起始地址 - 内核虚拟空间起始地址
    proc->user_buffer_offset = vma->vm_start - (uintptr_t)proc->buffer;
    ......
    // 分配物理页的指针数组,数组大小为vma的等效page个数
    proc->pages = kzalloc(sizeof(proc->pages[0]) * ((vma->vm_end - vma->vm_start) / PAGE_SIZE), GFP_KERNEL);
    if (proc->pages == NULL) {
    ret = -ENOMEM;
    failure_string = "alloc page array";
    goto err_alloc_pages_failed;
    }
    proc->buffer_size = vma->vm_end - vma->vm_start;

    vma->vm_ops = &binder_vm_ops;
    vma->vm_private_data = proc;
    // 分配物理页面,同时映射到内核空间和进程空间,先分配1个物理页
    if (binder_update_page_range(proc, 1, proc->buffer, proc->buffer + PAGE_SIZE, vma)) {
    ret = -ENOMEM;
    failure_string = "alloc small buf";
    goto err_alloc_small_buf_failed;
    }
    buffer = proc->buffer;
    // buffer插入链表
    INIT_LIST_HEAD(&proc->buffers);
    list_add(&buffer->entry, &proc->buffers);
    buffer->free = 1;
    binder_insert_free_buffer(proc, buffer);
    // oneway异步可用大小为总空间的一半
    proc->free_async_space = proc->buffer_size / 2;
    barrier();
    proc->files = get_files_struct(current);
    proc->vma = vma;
    proc->vma_vm_mm = vma->vm_mm;

    /*pr_info("binder_mmap: %d %lx-%lx maps %p\n",
    proc->pid, vma->vm_start, vma->vm_end, proc->buffer);*/

    return 0;
    }

    binder_update_page_range 函数为映射地址分配物理页,这里先分配一个物理页(4KB),然后将这个物理页同时映射到用户空间地址和内存空间地址


    static int binder_update_page_range(struct binder_proc *proc, int allocate,
    void *start, void *end,
    struct vm_area_struct *vma)

    {
    // 内核映射区起始地址
    void *page_addr;
    // 用户映射区起始地址
    unsigned long user_page_addr;
    struct page **page;
    // 内存结构体
    struct mm_struct *mm;

    if (end <= start)
    return 0;
    ......
    // 循环分配所有物理页,并分别建立用户空间和内核空间对该物理页的映射
    for (page_addr = start; page_addr < end; page_addr += PAGE_SIZE) {
    int ret;
    page = &proc->pages[(page_addr - proc->buffer) / PAGE_SIZE];

    BUG_ON(*page);
    // 分配一页物理内存
    *page = alloc_page(GFP_KERNEL | __GFP_HIGHMEM | __GFP_ZERO);
    if (*page == NULL) {
    pr_err("%d: binder_alloc_buf failed for page at %p\n",
    proc->pid, page_addr);
    goto err_alloc_page_failed;
    }
    // 物理内存映射到内核虚拟空间
    ret = map_kernel_range_noflush((unsigned long)page_addr,
    PAGE_SIZE, PAGE_KERNEL, page);
    flush_cache_vmap((unsigned long)page_addr,
    // 用户空间地址 = 内核地址+偏移
    user_page_addr =
    (uintptr_t)page_addr + proc->user_buffer_offset;
    // 物理空间映射到用户虚拟空间
    ret = vm_insert_page(vma, user_page_addr, page[0]);
    }
    }

    binder_mmap函数中调用binder_update_page_range只为映射区分配了一个物理页的空间,在Binder开始通讯时,会再通过binder_alloc_buf函数分配更多物理页,这是后话了。


    Binder套件架构


    内核层的Binder驱动已经提供了IPC功能,不过还需要在framework native层提供一些对于驱动层的调用封装,使framework开发者更易于使用,由此封装出了native Binder;同时,由于framework native层是c/c++语言实现,对于应用开发者,需要更加方便的Java层的封装,衍生出Java Binder;最后在此之上,为了减少重复代码的编写和规范接口,在Java Binder的基础上又封装出了AIDL。经过层层封装,在使用者使用AIDL时对于Binder基本上是无感知的。


    这里贴一张架构图。


    binder架构.png


    Native层


    BpBinder代表服务端Binder的一个代理,内部有一个成员mHandle就是服务端Binder在驱动层的句柄,客户端通过调用BpBinder::transact传入该句柄,经过驱动层和服务端BBinder产生会话,最后服务端会调用到BBinder::onTransact。在这里两者之间通过约定好的code来标识会话内容。


    前面提到过,需要用Binder进行通信的进程都需要在驱动中先注册该进程,并且每次通讯时需要一个线程死循环读写binder驱动。驱动层中一个进程对应一个binder_proc,一个线程对应binder_thread;而在framework native层中,进程对应一个ProcessState,线程对应IPCThreadStateBpBinder::transact发起通讯最终也是通过IPCThreadState.transact调用驱动进行。


    实际上Android中每个应用进程都打开了Binder驱动(在驱动中注册),Zygote进程在fork出应用进程后,调用app_main.cpp中onZygoteInit函数初始化,此函数中就创建了该进程的ProcessState实例,打开Binder驱动然后分配映射区,驱动中也创建并保存一个该进程的binder_proc实例。这里借一张图来描述。


    Java层


    Java层是对native层相关类的封装,BBinder对应Binder,BpBinder对应BinderProxy,java层最后还是会调用到native层对应函数


    AIDL


    AIDL生成的代码对于Binder进行了进一步封装,<接口>.Stub对应服务端Binder,<接口>.Stub.Proxy标识客户端,内部持有一个mRemote实例(BinderProxy),aidl根据定义的接口方法生成若干个TRANSACTION_<函数名> code常量,两端Binder通过这些code标识解析参数,调用相应接口方法。换言之AIDL就是对BinderProxy.transactBinder.onTransact进行了封装,使用者不必再自己定义每次通讯的code以及参数解析


    后记


    本篇文章主要为不了解Binder体系的读者提供一个笼统的认识,接下来的文章会从AIDL远程服务开始层层向下分析整个IPC过程,所以如果想要更深一步了解Binder,本文作为前置知识也比较重要。


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

    手把手教你 Debug — iOS 14 ImageIO Crash 分析

    背景去年 9 月份开始,许多用户升级到 iOS 14 之后,线上出现很多 ImageIO 相关堆栈的 Crash 问题,而且公司内几乎所有的 APP 上都有出现,在部分 APP上甚至达到了 Top 3  Crash。得益于 APM 平台精准数据采集机...
    继续阅读 »

    背景

    去年 9 月份开始,许多用户升级到 iOS 14 之后,线上出现很多 ImageIO 相关堆栈的 Crash 问题,而且公司内几乎所有的 APP 上都有出现,在部分 APP上甚至达到了 Top 3  Crash。

    得益于 APM 平台精准数据采集机制和丰富的异常信息现场,我们通过收集到详细的 Crash 日志信息进行分析解决。

    问题定位

    堆栈信息

    从堆栈信息看,是在 ImageIO 解析图片信息的时候 Crash ,并且最后调用的方法都是看起来都是和 INameSpacePrefixMap 相关,推测 Crash 应该是和这个方法 CGImageSourceCopyPropertiesAtIndex 的实现有关。




    • 从堆栈信息看,这段代码是图片库在子线程通过 CGImageSourceCopyPropertiesAtIndex 解析 imageSource 中的图片相关信息,然后发生了野指针的 Crash。

    • CGImageSourceCopyPropertiesAtIndex 的输入只有一个 imageSourceimageSource 由图片的 data 生成,调用栈并没有多线程操作,可以排除是多线程操作 imageSource、data 导致的 Crash。

    • 看堆栈是在解析 PNG 图片,通过将下发的图片格式换成 JPG 格式,发现量级并没有降低。推测 Crash 不是某种特定图片格式引起的。

    反汇编分析

    反汇编准备

    • iOS 14.3 的 iPhone 8
    • ImageIO 系统库:~/Library/Developer/Xcode/iOS DeviceSupport目录下找到对应 iOS 14.3 的 ImageIO
    • 一份 iOS 14.3、iPhone 8 上发生的  CrashLog
    • Hopper

    反汇编

    1、从 CrashLog 上找到 Crash 对应的指令偏移地址 2555072


    2、通过 Hopper 打开 ImageIO,跳转到指令偏移地址 2555072

    Navigate => Go To File Offset 2555072


    3、Crash 对应的指令应该是0000000181b09cc0 ldr x8, [x8, #0x10],可以看到应该是访问 [x8, #0x10]指向的内存出错


    5、向上回溯查看 x8 的来源

    • 0000000181b09cbc ldr x8, [x20] x8 是存在 x20 指向的内存中(即 x8 = *x20

    • 0000000181b09c98 ldr x20, [x21, #0x8] x20 又存在[x21, #0x8] 指向的内存中

    • 0000000181b09c8c adrp x21, #0x1da0ed0000000000181b09c90 add x21, x21, #0xe10 x21 指向的是一个 data 段,推测 x21 应该是一个全局变量,所以,可能是这个全局变量野了,或者是这个全局变量引用的某些内存(x20)野了

    6、运行时 debug 查看 x8、x20、x21 对应寄存器的值是什么

    • x21 从内存地址的名字看,应该是一个全局的 Map


    8、经过在运行时反复调试,这个

    AdobeXMPCore_Int::ManageDefaultNameSpacePrefixMap(bool) 会在多个方法中调用(并且调用时都加了锁,不太可能会出现 data race):

    • AdobeXMPCore_Int::INameSpacePrefixMap_I::CreateDefaultNameSpacePrefixMap()

    • AdobeXMPCore_Int::INameSpacePrefixMap_I::InsertInDefaultNameSpacePrefixMap(char const*, unsigned long long, char const*, unsigned long long)

    • AdobeXMPCore_Int::INameSpacePrefixMap_I::DestroyDefaultNameSapcePrefixMap()



    9、在后台线程访问访问全局变量 sDefaultNameSpacePrefixMap 时 Crash,推测可能是用户手动杀进程后,全局变量在主线程已经被析构,后台线程还会继续访问这个全局变量,从而出现野指针访问异常。发现 Crash 日志的主线程堆栈也出现 _exit 的调用,可以确定是全局变量析构导致。


    Crash 发生的原因:

    在用户手动杀进程后,主线程将这个全局变量析构了,这时候子线程再访问这个全局变量就出现了野指针。


    复现问题

    尝试在子线程不断调用 CFDictionaryRef CGImageSourceCopyPropertiesAtIndex(CGImageSourceRef isrc, size_t index, CFDictionaryRef options);,并且手动杀掉进程触发这个 crash



    可以证明上述的推理是正确的。

    总结

    • CFDictionaryRef CGImageSourceCopyPropertiesAtIndex(CGImageSourceRef isrc, size_t index, CFDictionaryRef options); 这个方法在解析部分图片的时候最终会访问全局变量

    收起阅读 »

    WKWebView音视频媒体播放处理

    1. 对WKWebViewConfiguration进行设置。实现媒体文件可以自动播放、使用内嵌HTML5播放等功能使用这个测试网址// 初始化配置对象 WKWebViewConfiguration *configuration = [[WKWebViewCo...
    继续阅读 »

    1. 对WKWebViewConfiguration进行设置。

    实现媒体文件可以自动播放、使用内嵌HTML5播放等功能
    使用这个测试网址

    // 初始化配置对象
    WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
    // 默认是NO,这个值决定了用内嵌HTML5播放视频还是用本地的全屏控制
    configuration.allowsInlineMediaPlayback = YES;
    // 自动播放, 不需要用户采取任何手势开启播放
    // WKAudiovisualMediaTypeNone 音视频的播放不需要用户手势触发, 即为自动播放
    configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone;
    configuration.allowsAirPlayForMediaPlayback = YES;
    configuration.allowsPictureInPictureMediaPlayback = YES;

    self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
    self.webView.navigationDelegate = self;
    NSURL *url =[NSURL URLWithString:@"测试网址"];
    [self.webView loadRequest:[NSURLRequest requestWithURL:url]];
    [self.view addSubview:self.webView];

    由于H5的video未设置autoplay、playsinline属性。我们需自己注入,才能实现效果。

    NSString *jSString = @"document.getElementsByTagName('video')[0].setAttribute('playsinline','');";
    NSString *jSString2 = @"document.getElementsByTagName('video')[0].autoplay=true;";
    //用于进行JavaScript注入
    WKUserScript *wkUScript = [[WKUserScript alloc] initWithSource:jSString injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    WKUserScript *wkUScript2 = [[WKUserScript alloc] initWithSource:jSString2 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript];
    [configuration.userContentController addUserScript:wkUScript2];


    2. 监听网页内播放器的回调

    可以使用两种办法。

    2.1 利用HTML5 Audio/Video 事件

    HTML5 Audio/Video 事件代码可以由H5同事完成,也可以由App端注入。
    注入代码如下:


    NSString *jSString3 = @"document.getElementsByTagName('video')[0].addEventListener('canplay', function(e) {window.webkit.messageHandlers.readytoplay.postMessage(\"canplay\");})";
    NSString *jSString4 = @"document.getElementsByTagName('video')[0].addEventListener('pause', function(e) {window.webkit.messageHandlers.pause.postMessage(\"pause\");})";
    NSString *jSString5 = @"document.getElementsByTagName('video')[0].addEventListener('play', function(e) {window.webkit.messageHandlers.play.postMessage(\"play\");})";
    NSString *jSString6 = @"document.getElementsByTagName('video')[0].addEventListener('ended', function(e) {window.webkit.messageHandlers.ended.postMessage(\"ended\");})";
    WKUserScript *wkUScript3 = [[WKUserScript alloc] initWithSource:jSString3 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript3];
    WKUserScript *wkUScript4 = [[WKUserScript alloc] initWithSource:jSString4 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript4];
    WKUserScript *wkUScript5 = [[WKUserScript alloc] initWithSource:jSString5 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript5];
    WKUserScript *wkUScript6 = [[WKUserScript alloc] initWithSource:jSString6 injectionTime:WKUserScriptInjectionTimeAtDocumentEnd forMainFrameOnly:YES];
    [configuration.userContentController addUserScript:wkUScript6];
    App端接收js的代码如下:
    需遵守WKScriptMessageHandler协议

    @interface ViewController () <WKNavigationDelegate,WKScriptMessageHandler>
    @end

    再为WKWebViewConfiguration添加协议

    //添加一个协议
    [configuration.userContentController addScriptMessageHandler:self name:@"readytoplay"];
    [configuration.userContentController addScriptMessageHandler:self name:@"play"];
    [configuration.userContentController addScriptMessageHandler:self name:@"pause"];
    [configuration.userContentController addScriptMessageHandler:self name:@"ended"];

    使用以下方法即可获取播放器事件

    #pragma mark - WKScriptMessageHandler

    //! WKWebView收到ScriptMessage时回调此方法
    - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.name caseInsensitiveCompare:@"readytoplay"] == NSOrderedSame) {
    NSLog(@"video is readytoplay");
    }
    if ([message.name caseInsensitiveCompare:@"play"] == NSOrderedSame) {
    NSLog(@"video is play");
    }
    if ([message.name caseInsensitiveCompare:@"pause"] == NSOrderedSame) {
    NSLog(@"video is pause");
    }
    if ([message.name caseInsensitiveCompare:@"ended"] == NSOrderedSame) {
    NSLog(@"video is ended");
    }
    }
    2.2 还有一种是App可自己实现的,使用AVAudioSession进行监听:

    使用AVAudioSession监听,必须用到AVAudioSessionCategoryOptionMixWithOthers。这样会导致切换别的音视频App不会打断播放器。例如网易云音乐、bilibili。
    手机来电会打断播放器。


    NSError *sessionError = nil;
    [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback
    withOptions:AVAudioSessionCategoryOptionMixWithOthers
    error:&sessionError];
    [[AVAudioSession sharedInstance] setActive:YES error:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(audioSessionSilenceSecondaryAudioHint:)
    name:AVAudioSessionSilenceSecondaryAudioHintNotification
    object:[AVAudioSession sharedInstance]];
    - (void)audioSessionSilenceSecondaryAudioHint:(NSNotification *)notification
    {
    NSDictionary *userInfo = notification.userInfo;
    NSLog(@"audioSessionSilenceSecondaryAudioHint %@",userInfo);
    }

    开始播放输出:

    2021-04-01 15:22:31.302248+0800 webViewPlayMedia[18078:2811391] audioSessionSilenceSecondaryAudioHint  {
    AVAudioSessionSilenceSecondaryAudioHintTypeKey = 1;

    结束播放输出:

    2021-04-01 15:22:31.382646+0800 webViewPlayMedia[18078:2811391] audioSessionSilenceSecondaryAudioHint  {
    AVAudioSessionSilenceSecondaryAudioHintTypeKey = 0;

    3. 获取视频播放地址,使用自定义播放器进行播放

    - (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation {
    NSLog(@"WKPhoneWebView didFinishNavigation");

    NSString *JsStr = @"(document.getElementsByTagName(\"video\")[0]).src";
    [self.webView evaluateJavaScript:JsStr completionHandler:^(id _Nullable response, NSError * _Nullable error) {
    if(![response isEqual:[NSNull null]] && response != nil){
    //截获到视频地址了
    NSLog(@"response == %@",response);
    }else{
    //没有视频链接
    }
    }];
    }

    4. 坑

    4.1 播放视频,会有ERROR提示:
    2021-04-01 09:34:57.361477+0800 webViewPlayMedia[17109:2655981] [assertion] Error acquiring assertion: <Error Domain=RBSAssertionErrorDomain Code=3 "Required client entitlement is missing" UserInfo={RBSAssertionAttribute=<RBSDomainAttribute| domain:"com.apple.webkit" name:"MediaPlayback" sourceEnvironment:"(null)">, NSLocalizedFailureReason=Required client entitlement is missing}>
    2021-04-01 09:34:57.361610+0800 webViewPlayMedia[17109:2655981] [ProcessSuspension] 0x1043dc990 - ProcessAssertion: Failed to acquire RBS MediaPlayback assertion 'WebKit Media Playback' for process with PID 17110, error: Error Domain=RBSAssertionErrorDomain Code=3 "Required client entitlement is missing" UserInfo={RBSAssertionAttribute=<RBSDomainAttribute| domain:"com.apple.webkit" name:"MediaPlayback" sourceEnvironment:"(null)">, NSLocalizedFailureReason=Required client entitlement is missing}


    但是设置了background属性了,依然无法解除,但是不影响播放。
    这个问题在https://stackoverflow.com/questions/66493177/required-client-entitlement-is-missing-in-wkwebview亦有提出,但是没有解决方案。

    4.2 iOS13.2 13.3系统手机会在加载WKWebView时会连续报错:
    2021-04-01 15:55:11.083253+0800 webViewPlayMedia[342:59346] [Process] kill() returned unexpected error 1

    在该系统版本下,WKWebView使用配置WKWebViewConfiguration,会无法播放。

    资料:收到控制台警告:当我在iOS13.2中加载WKWebView时,[Process] kill() returned unexpected error 1
    该错误已在13.4版本中修复。



    作者:左方
    链接:https://www.jianshu.com/p/a77a33063755


    收起阅读 »

    Flutter中的异步

    同步与异步 程序的运行是出于满足人们对某种逻辑需求的处理,在计算机上表现为可执行指令,正常情况下我们期望的指令是按逻辑的顺序依次执行的,而实际情况由于某些指令是耗时操作,不能立即返回结果而造成了阻塞,导致程序无法继续执行。这种情况多见于一些io操作。这时,对...
    继续阅读 »

    同步与异步


    程序的运行是出于满足人们对某种逻辑需求的处理,在计算机上表现为可执行指令,正常情况下我们期望的指令是按逻辑的顺序依次执行的,而实际情况由于某些指令是耗时操作,不能立即返回结果而造成了阻塞,导致程序无法继续执行。这种情况多见于一些io操作。这时,对于用户层面来说,我们可以选择stop the world,等待操作完成返回结果后再继续操作,也可以选择继续去执行其他操作,等事件返回结果后再通知回来。这就是从用户角度来看的同步与异步。


    从操作系统的角度,同步异步,与任务调度,进程间切换,中断,系统调用之间有着更为复杂的关系。


    同步I/O 与 异步I/O的区别


    img

    为什么使用异步


    用户可以阻塞式的等待,因为人的操作和计算机相比是非常慢的,计算机如果阻塞那就是很大的性能浪费了,异步操作让您的程序在等待另一个操作的同时完成工作。三种异步操作的场景:



    • I/O操作:例如:发起一个网络请求,读写数据库、读写文件、打印文档等,一个同步的程序去执行这些操作,将导致程序的停止,直到操作完成。更有效的程序会改为在操作挂起时去执行其他操作,假设您有一个程序读取一些用户输入,进行一些计算,然后通过电子邮件发送结果。发送电子邮件时,您必须向网络发送一些数据,然后等待接收服务器响应。等待服务器响应所投入的时间是浪费的时间,如果程序继续计算,这将得到更好的利用

    • 并行执行多个操作:当您需要并行执行不同的操作时,例如进行数据库调用、Web 服务调用以及任何计算,那么我们可以使用异步

    • 长时间运行的基于事件驱动的请求:这就是您有一个请求进来的想法,并且该请求进入休眠状态一段时间等待其他一些事件的发生。当该事件发生时,您希望请求继续,然后向客户端发送响应。所以在这种情况下,当请求进来时,线程被分配给该请求,当请求进入睡眠状态时,线程被发送回线程池,当任务完成时,它生成事件并从线程池中选择一个线程发送响应


    计算机中异步的实现方式就是任务调度,也就是进程的切换


    任务调度采用的是时间片轮转的抢占式调度方式,进程是任务调度的最小单位。


    计算机系统分为用户空间内核空间,用户进程在用户空间,操作系统运行在内核空间,内核空间的数据访问修改拥有高于普通进程的权限,用户进程之间相互独立,内存不共享,保证操作系统的运行安全。如何最大化的利用CPU,确定某一时刻哪个进程拥有CPU资源就是任务调度的过程。内核负责调度管理用户进程,以下为进程调度过程


    img

    在任意时刻, 一个 CPU 核心上(processor)只可能运行一个进程



    每一个进程可以包含多个线程,线程是执行操作的最小单元,因此进程的切换落实到具体细节就是正在执行线程的切换


    Future


    Future<T> 表示一个异步的操作结果,用来表示一个延迟的计算,返回一个结果或者error,使用代码实例:


    Future<int> future = getFuture();
    future.then((value) => handleValue(value))
    .catchError((error) => handleError(error))
    .whenComplete(func);

    future可以是三种状态:未完成的返回结果值返回异常


    当一个返回future对象被调用时,会发生两件事:



    • 将函数操作入队列等待执行结果并返回一个未完成的Future对象

    • 函数操作完成时,Future对象变为完成并携带一个值或一个错误


    首先,Flutter事件处理模型为先执行main函数,完成后检查执行微任务队列Microtask Queue中事件,最后执行事件队列Event Queue中的事件,示例:


    void main(){
    Future(() => print(10));
    Future.microtask(() => print(9));
    print("main");
    }
    /// 打印结果为:
    /// main
    /// 9
    /// 10

    基于以上事件模型的基础上,看下Future提供的几种构造函数,其中最基本的为直接传入一个Function


    factory Future(FutureOr<T> computation()) {
    _Future<T> result = new _Future<T>();
    Timer.run(() {
    try {
    result._complete(computation());
    } catch (e, s) {
    _completeWithErrorCallback(result, e, s);
    }
    });
    return result;
    }

    Function有多种写法:


    //简单操作,单步
    Future(() => print(5));
    //稍复杂,匿名函数
    Future((){
    print(6);
    });
    //更多操作,方法名
    Future(printSeven);

    printSeven(){
    print(7);
    }


    Future.microtask


    此工程方法创建的事件将发送到微任务队列Microtask Queue,具有相比事件队列Event Queue优先执行的特点


    factory Future.microtask(FutureOr<T> computation()) {
    _Future<T> result = new _Future<T>();
    //
    scheduleMicrotask(() {
    try {
    result._complete(computation());
    } catch (e, s) {
    _completeWithErrorCallback(result, e, s);
    }
    });
    return result;
    }

    Future.sync


    返回一个立即执行传入参数的Future,可理解为同步调用


    factory Future.sync(FutureOr<T> computation()) {
    try {
    var result = computation();
    if (result is Future<T>) {
    return result;
    } else {
    // TODO(40014): Remove cast when type promotion works.
    return new _Future<T>.value(result as dynamic);
    }
    } catch (error, stackTrace) {
    /// ...
    }
    }

    	Future.microtask(() => print(9));
    Future(() => print(10));
    Future.sync(() => print(11));

    /// 打印结果: 11、9、10

    Future.value


    创建一个将来包含value的future


    factory Future.value([FutureOr<T>? value]) {
    return new _Future<T>.immediate(value == null ? value as T : value);
    }

    参数FutureOr含义为T value 和 Future value 的合集,因为对于一个Future参数来说,他的结果可能为value或者是Future,所以对于以下两种写法均合法:


    	Future.value(12).then((value) => print(value));
    Future.value(Future<int>((){
    return 13;
    }));


    这里需要注意即使value接收的是12,仍然会将事件发送到Event队列等待执行,但是相对其他Future事件执行顺序会提前



    Future.error


    创建一个执行结果为error的future


    factory Future.error(Object error, [StackTrace? stackTrace]) {
    /// ...
    return new _Future<T>.immediateError(error, stackTrace);
    }

    _Future.immediateError(var error, StackTrace stackTrace)
    : _zone = Zone._current {
    _asyncCompleteError(error, stackTrace);
    }

     Future.error(new Exception("err msg"))
    .then((value) => print("err value: $value"))
    .catchError((e) => print(e));

    /// 执行结果为:Exception: err msg

    Future.delayed


    创建一个延迟执行回调的future,内部实现为Timer加延时执行一个Future


    factory Future.delayed(Duration duration, [FutureOr<T> computation()?]) {
    /// ...
    new Timer(duration, () {
    if (computation == null) {
    result._complete(null as T);
    } else {
    try {
    result._complete(computation());
    } catch (e, s) {
    _completeWithErrorCallback(result, e, s);
    }
    }
    });
    return result;
    }

    Future.wait


    等待多个Future并收集返回结果


    static Future<List<T>> wait<T>(Iterable<Future<T>> futures,
    {bool eagerError = false, void cleanUp(T successValue)?}) {
    /// ...
    }

    FutureBuilder结合使用:


    child: FutureBuilder(
    future: Future.wait([
    firstFuture(),
    secondFuture()
    ]),
    builder: (context,snapshot){
    if(!snapshot.hasData){
    return CircularProgressIndicator();
    }
    final first = snapshot.data[0];
    final second = snapshot.data[1];
    return Text("data $first $second");
    },
    ),

    Future.any


    返回futures集合中第一个返回结果的值


    static Future<T> any<T>(Iterable<Future<T>> futures) {
    var completer = new Completer<T>.sync();
    void onValue(T value) {
    if (!completer.isCompleted) completer.complete(value);
    }
    void onError(Object error, StackTrace stack) {
    if (!completer.isCompleted) completer.completeError(error, stack);
    }
    for (var future in futures) {
    future.then(onValue, onError: onError);
    }
    return completer.future;
    }

    对上述例子来说,Future.any snapshot.data 将返回firstFuturesecondFuture中第一个返回结果的值


    Future.forEach


    为传入的每一个元素,顺序执行一个action


    static Future forEach<T>(Iterable<T> elements, FutureOr action(T element)) {
    var iterator = elements.iterator;
    return doWhile(() {
    if (!iterator.moveNext()) return false;
    var result = action(iterator.current);
    if (result is Future) return result.then(_kTrue);
    return true;
    });
    }

    这里边action是方法作为参数,头一次见这种形式语法还是在js中,当时就迷惑了很大一会儿,使用示例:


    Future.forEach(["one","two","three"], (element) {
    print(element);
    });

    Future.doWhile


    执行一个操作直到返回false


    Future.doWhile((){
    for(var i=0;i<5;i++){
    print("i => $i");
    if(i >= 3){
    return false;
    }
    }
    return true;
    });
    /// 结果打印到 3

    以上为Future中常用构造函数和方法


    在Widget中使用Future


    Flutter提供了配合Future显示的组件FutureBuilder,使用也很简单,伪代码如下:


    child: FutureBuilder(
    future: getFuture(),
    builder: (context, snapshot){
    if(!snapshot.hasData){
    return CircularProgressIndicator();
    } else if(snapshot.hasError){
    return _ErrorWidget("Error: ${snapshot.error}");
    } else {
    return _ContentWidget("Result: ${snapshot.data}")
    }
    }
    )

    Async-await


    使用


    这两个关键字提供了异步方法的同步书写方式,Future提供了方便的链式调用使用方式,但是不太直观,而且大量的回调嵌套造成可阅读性差。因此,现在很多语言都引入了await-async语法,学习他们的使用方式是很有必要的。


    两条基本原则:



    • 定义一个异步方法,必须在方法体前声明 async

    • await关键字必须在async方法中使用


    首先,在要执行耗时操作的方法体前增加async:


    void main() async { ··· }

    然后,根据方法的返回类型添加Future修饰


    Future<void> main() async { ··· }

    现在就可以使用await关键字来等待这个future执行完毕


    print(await createOrderMessage());

    例如实现一个由一级分类获取二级分类,二级分类获取详情的需求,使用链式调用的代码如下:


    var list = getCategoryList();
    list.then((value) => value[0].getCategorySubList(value[0].id))
    .then((subCategoryList){
    var courseList = subCategoryList[0].getCourseListByCategoryId(subCategoryList[0].id);
    print(courseList);
    }).catchError((e) => (){
    print(e);
    });

    现在来看下使用async/await,事情变得简单了多少


    Future<void> main() async {
    await getCourses().catchError((e){
    print(e);
    });
    }
    Future<void> getCourses() async {
    var list = await getCategoryList();
    var subCategoryList = await list[0].getCategorySubList(list[0].id);
    var courseList = subCategoryList[0].getCourseListByCategoryId(subCategoryList[0].id);
    print(courseList);
    }

    可以看到这样更加直观


    缺陷


    async/await 非常方便,但是还是有一些缺点需要注意


    因为它的代码看起来是同步的,所以是会阻塞后面的代码执行,直到await返回结果,就像执行同步操作一样。它确实可以允许其他任务在此期间继续运行,但后边自己的代码被阻塞。


    这意味着代码可能会由于有大量await代码相继执行而阻塞,本来用Future编写表示并行的操作,现在使用await变成了串行,例如,首页有一个同时获取轮播接口,tab列表接口,msg列表接口的需求


    Future<String> getBannerList() async {
    return await Future.delayed(Duration(seconds: 3),(){
    return "banner list";
    });
    }

    Future<String> getHomeTabList() async {
    return await Future.delayed(Duration(seconds: 3),(){
    return "tab list";
    });
    }

    Future<String> getHomeMsgList() async {
    return await Future.delayed(Duration(seconds: 3),(){
    return "msg list";
    });
    }

    使用await编写很可能会写成这样,打印执行操作的时间


    Future<void> main2() async {
    var startTime = DateTime.now().second;
    await getBannerList();
    await getHomeTabList();
    await getHomeMsgList();
    var endTime = DateTime.now().second;
    print(endTime - startTime); // 9
    }

    在这里,我们直接等待所有三个模拟接口的调用,使每个调用3s。后续的每一个都被迫等到上一个完成, 最后会看到总运行时间为9s,而实际我们想三个请求同时执行,代码可以改成如下这种:


    Future<void> main() async {
    var startTime = DateTime.now().second;
    var bannerList = getBannerList();
    var homeTabList = getHomeTabList();
    var homeMsgList = getHomeMsgList();

    await bannerList;
    await homeTabList;
    await homeMsgList;
    var endTime = DateTime.now().second;
    print(endTime - startTime); // 3
    }

    将三个Future存储在变量中,这样可以同时启动,最后打印时间仅为3s,所以在编写代码时,我们必须牢记这点,避免性能损耗。


    原理


    线程模型


    当一个Flutter应用或者Flutter Engine启动时,它会启动(或者从池中选择)另外三个线程,这些线程有些时候会有重合的工作点,但是通常,它们被称为UI线程GPU线程IO线程。需要注意一点这个UI线程并不是程序运行的主线程,或者说和其他平台上的主线程理解不同,通常的,Flutter将平台的主线程叫做"Platform thread"


    img


    UI线程是所有的Dard代码运行的地方,例如framework和你的应用,除非你启动自己的isolates,否则Dart将永远不会运行在其他线程。平台线程是所有依赖插件的代码运行的地方。该线程也是native frameworks为其他任务提供服务的地方,一般来说,一个Flutter应用启动的时候会创建一个Engine实例,Engine创建的时候会创建一个Platform thread为其提供服务。跟Flutter Engine的所有交互(接口调用)必须发生在Platform Thread,试图在其它线程中调用Flutter Engine会导致无法预期的异常。这跟Android/iOS UI相关的操作都必须在主线程进行相类似。


    Isolates是Dart中概念,本意是隔离,它的实现功能和thread类似,但是他们之间的实现又有着本质的区别,Isolote是独立的工作者,它们之间不共享内存,而是通过channel传递消息。Dart是单线程执行代码,Isolate提供了Dart应用可以更好的利用多核硬件的解决方案。


    事件循环


    单线程模型中主要就是在维护着一个事件循环(Event Loop) 与 两个队列(event queue和microtask queue)当Flutter项目程序触发如点击事件IO事件网络事件时,它们就会被加入到eventLoop中,eventLoop一直在循环之中,当主线程发现事件队列不为空时发现,就会取出事件,并且执行。


    microtask queue中事件优先于event queue执行,当有任务发送到microtask队列时,会在当前event执行完成后,阻塞当前event queue转而去执行microtask queue中的事件,这样为Dart提供了任务插队的解决方案。


    event queue的阻塞意味着app无法进行UI绘制,响应鼠标和I/O等事件,所以要谨慎使用,如下为流程图:


    event queue和microtask queue


    这两个任务队列中的任务切换在某些方面就相当于是协程调度机制


    协程


    协程是一种协作式的任务调度机制,区别于操作系统的抢占式任务调度机制,它是用户态下面的,避免线程切换的内核态、用户态转换的性能开销。它让调用者自己来决定什么时候让出cpu,比操作系统的抢占式调度所需要的时间代价要小很多,后者为了恢复现场会保存相当多的状态(不仅包括进程上下文的虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态),并且会频繁的切换,以现在流行的大多数Linux机器来说,每一次的上下文切换要消耗大约1.2-1.5μs的时间,这是仅考虑直接成本,固定在单个核心以避免迁移的成本,未固定情况下,切换时间可达2.2μs


    img

    对cpu来说这算一个很长的时间吗,一个很好的比较是memcpy,在相同的机器上,完成一个64KiB数据的拷贝需要3μs的时间,上下文的切换比这个操作稍微快一些


    Plot of thread/process launch and context switch

    协程和线程非常相似,是从异步执行任务的角度来看,而并不是从设计的实体角度像进程->线程->协程这样类似于细胞->原子核->质子中子这样的关系。可以理解为线程上执行的一段函数,用yield完成异步请求、注册回调/通知器、保存状态,挂起控制流、收到回调/通知、恢复状态、恢复控制流的所有过程


    多线程执行任务模型如图:



    线程的阻塞要靠系统间进程的切换,完成逻辑流的执行,频繁的切换耗费大量资源,而且逻辑流的执行数量严重依赖于程序申请到的线程的数量。


    协程是协同多任务的,这意味着协程提供并发性但不提供并行性,执行流模型图如下:



    协程可以用逻辑流的顺序去写控制流,协程的等待会主动释放cpu,避免了线程切换之间的等待时间,有更好的性能,逻辑流的代码编写和理解上也简单的很多


    但是线程并不是一无是处,抢占式线程调度器事实上提供了准实时的体验。例如Timer,虽然不能确保在时间到达的时候一定能够分到时间片运行,但不会像协程一样万一没有人让出时间片就永远得不到运行……


    总结



    • 同步与异步

    • Future提供了Flutter中异步代码链式编写方式

    • async-wait提供了异步代码的同步书写方式

    • Future的常用方法和FutureBuilder编写UI

    • Flutter中线程模型,四个线程

    • 单线程语言的事件驱动模型

    • 进程间切换和协程对比



    收起阅读 »

    Protobuf 和 JSON对比分析

    Protocol Buffers (a.k.a., protobuf) are Google's language-neutral, platform-neutral, extensible mechanism for serializing structur...
    继续阅读 »

    Protocol Buffers (a.k.a., protobuf) are Google's language-neutral, platform-neutral, extensible mechanism for serializing structured data.


    Protobuf是Google公司开发的一种语言中立 平台中立 可扩展 的 对结构化数据 序列化的机制。


    本文主要对Protobuf和JSON序列化&反序列化的性能做横向对比分析。 JSON序列化使用Google官方的Gson框架。


    ProtobufGsonLanguagePlatform
    3.17.32.8.7KotlinmacOS IntelliJ IDEA


    测试序列化内容,高效作业25分钟的训练数据(mock)


    数据结构


    syntax = "proto3";
    package me.sunnyxibei.data;
    option java_package = "me.sunnyxibei.data";
    option java_outer_classname = "TaskProto";

    message Eeg{
    repeated double alphaData = 1;
    repeated double betaData = 2;
    repeated double attentionData = 3;
    repeated int64 timestampData = 4;
    int64 startTimestamp = 5;
    int64 endTimestamp = 6;
    }
    message TaskRecord{
    string localId = 1;
    int64 localCreated = 2;
    int64 localUpdated = 3;
    int32 score = 4;
    int64 originDuration = 5;
    string subject = 6;
    string content = 7;
    Eeg eeg = 8;
    }

    对比结果 repeat = 1


    Gson序列化大小 = 30518 bytes
    Gson序列化时间 = 113 ms
    protobuf序列化大小 = 13590 bytes
    protobuf序列化时间 = 39 ms
    *************************
    Gson反序列化时间 = 15 ms
    protobuf反序列化时间 = 3 ms

    repeat = 10


    Gson序列化时间 = 137 ms
    protobuf序列化时间 = 41 ms
    *************************
    Gson反序列化时间 = 50 ms
    protobuf反序列化时间 = 5 ms

    repeat = 100


    Gson序列化时间 = 347 ms
    protobuf序列化时间 = 47 ms
    *************************
    Gson反序列化时间 = 212 ms
    protobuf反序列化时间 = 22 ms

    repeat = 1000


    Gson序列化时间 = 984 ms
    protobuf序列化时间 = 97 ms
    *************************
    Gson反序列化时间 = 817 ms
    protobuf反序列化时间 = 105 ms

    repeat = 10000


    Gson序列化时间 = 7034 ms
    protobuf序列化时间 = 225 ms
    *************************
    Gson反序列化时间 = 5544 ms
    protobuf反序列化时间 = 300 ms

    repeat = 100000


    Gson序列化时间 = 65560 ms
    protobuf序列化时间 = 1469 ms
    *************************
    Gson反序列化时间 = 49984 ms
    protobuf反序列化时间 = 2409 ms

    结论:



    1. 空间对比,Protobuf序列化后的数据大小,为JSON序列化后的44.5%

    2. 时间对比


    次数序列化(Protobuf/JSON)反序列化(Protobuf/JSON)
    134.5%20%
    1029.9%10%
    10013.5%9.43%
    10009.9%12.9%
    100003.2%5.41%
    1000002.24%4.82%


    收起阅读 »

    CoordinatorLayout 嵌套Recycleview 卡顿问题

    1.问题场景 伪代码: <CoordinatorLayout> <AppBarLayout> <RecycleView> </RecycleView> </AppBa...
    继续阅读 »

    1.问题场景


    伪代码:
    <CoordinatorLayout>
    <AppBarLayout>
    <RecycleView>
    </RecycleView>
    </AppBarLayout>
    </ConstraintLayout>

    一般这种做法是,底部view的相应滑动,滑动联动,但是同时会出现RecycleView ViewHoder复用失败,造成cpu 的消耗,item到达一定数量后会造成oom页面出现卡顿


    2. 问题原理


    RecycleView ViewHoder 复用问题第一时间我们应想到是; ViewGrop/onMeasureChild
    测量问题,重写 onMeasureChild ,避免中间MeasureSpec.UNSPECIFIED模式 的赋值造成RecycleView的item复用,但是是失败的!


     @Override
    protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
    child.measure(parentWidthMeasureSpec, parentHeightMeasureSpec);
    }


    原因是: parentHeightMeasureSpec 已经被设置 MeasureSpec.UNSPECIFIED 测量模式 看下源码CoordinatorLayout onMeasure 局部关键代码:


    prepareChildren();

    final Behavior b = lp.getBehavior();
    if (b == null || !b.onMeasureChild(this, child, childWidthMeasureSpec, keylineWidthUsed,
    childHeightMeasureSpec, 0)) {
    onMeasureChild(child, childWidthMeasureSpec, keylineWidthUsed,
    childHeightMeasureSpec, 0);
    }

    通过prepareChildren()结合LayoutParams


            R.styleable.CoordinatorLayout_Layout_layout_behavior);
    if (mBehaviorResolved) {
    mBehavior = parseBehavior(context, attrs, a.getString(
    R.styleable.CoordinatorLayout_Layout_layout_behavior));
    }

    我们可以得到 Behavior b 就是我们再布局内设置的 AppBarLayout. layout_behavior, 可以看到 Behavior/onMeasureChild 做了一层测量, ,我们继续看 Behavior/onMeasureChild 源码:


    @Override
    public boolean onMeasureChild(
    @NonNull CoordinatorLayout parent,
    @NonNull T child,
    int parentWidthMeasureSpec,
    int widthUsed,
    int parentHeightMeasureSpec,
    int heightUsed) {
    final CoordinatorLayout.LayoutParams lp =
    (CoordinatorLayout.LayoutParams) child.getLayoutParams();
    if (lp.height == CoordinatorLayout.LayoutParams.WRAP_CONTENT) {
    // If the view is set to wrap on it's height, CoordinatorLayout by default will
    // cap the view at the CoL's height. Since the AppBarLayout can scroll, this isn't
    // what we actually want, so we measure it ourselves with an unspecified spec to
    // allow the child to be larger than it's parent
    parent.onMeasureChild(
    child,
    parentWidthMeasureSpec,
    widthUsed,
    MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
    heightUsed);
    return true;
    }

    // Let the parent handle it as normal
    return super.onMeasureChild(
    parent, child, parentWidthMeasureSpec, widthUsed, parentHeightMeasureSpec, heightUsed);
    }

    问题找到了问题关键 if (lp.height == CoordinatorLayout.LayoutParams.WRAP_CONTENT) ,造成了MeasureSpec.UNSPECIFIED 的使用, 而这个模式又会造成Recycleview.LayoutManager加载所有的item,导致复用失败; 看到这 AppBarLayout给固定值或者match_parent 不就解决问题了吗, 是能解决问题,但是这样 我们的layout ui就不符合我们绘制ui的布局了,也会造成页面空白显示问题,所以这样使用recycleview 嵌套是非法使用,矛盾使用!


    解决问题



    • 同一使用RecycleView 使用,作为RecycleView item 的一部分,但是也会造成滑动冲突问题,然后通过 NestedScrollingParent3 外部拦截法,来解决内外层的滑动冲突,问题顺利解决


    override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
    if (e!!.action == MotionEvent.ACTION_DOWN) {
    val childRecyclerView = findCurrentChildRecyclerView()

    // 1. 是否禁止拦截
    doNotInterceptTouchEvent = doNotInterceptTouch(e.rawY, childRecyclerView)

    // 2. 停止Fling
    this.stopFling()
    childRecyclerView?.stopFling()
    }

    return if (doNotInterceptTouchEvent) {
    false
    } else {
    super.onInterceptTouchEvent(e)
    }
    }


    • 根据业务场景,也可使用baserecyclerviewadapterhelper,一个优秀的Adapter 框架, 


    addHeaderView来添加itemView,通过 notifyItemInserted(position) 添加ReceiveView 的item


    @JvmOverloads
    fun addHeaderView(view: View, index: Int = -1, orientation: Int = LinearLayout.VERTICAL): Int {
    if (!this::mHeaderLayout.isInitialized) {
    mHeaderLayout = LinearLayout(view.context)
    mHeaderLayout.orientation = orientation
    mHeaderLayout.layoutParams = if (orientation == LinearLayout.VERTICAL) {
    RecyclerView.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
    } else {
    RecyclerView.LayoutParams(WRAP_CONTENT, MATCH_PARENT)
    }
    }

    val childCount = mHeaderLayout.childCount
    var mIndex = index
    if (index < 0 || index > childCount) {
    mIndex = childCount
    }
    mHeaderLayout.addView(view, mIndex)
    if (mHeaderLayout.childCount == 1) {
    val position = headerViewPosition
    if (position != -1) {
    notifyItemInserted(position)
    }
    }
    return mIndex
    }
    收起阅读 »

    优雅地封装 Activity Result API,完美地替代 startActivityForResult()

    前言 Activity Result API。这是官方用于替代 startActivityForResult() 和 onActivityResult() 的。虽然出了有大半年了,但是个人到现在没看到比较好用的封装。最初大多数人会用拓展函数进行封装,而在 a...
    继续阅读 »

    前言


    Activity Result API。这是官方用于替代 startActivityForResult()onActivityResult() 的。虽然出了有大半年了,但是个人到现在没看到比较好用的封装。最初大多数人会用拓展函数进行封装,而在 activity-ktx:1.2.0-beta02 版本之后,调用注册方法的时机必须在 onStart() 之前,原来的拓展函数就不适用了,在这之后就没看到有人进行封装了。


    个人对 Activity Result API 的封装思考了很久,已经尽量做到在 Kotlin 和 Java 都足够地好用,可以完美替代 startActivityForResult() 了。下面带着大家一起来封装 Activity Result API。


    基础用法


    首先要先了解基础的用法,在 ComponentActivity 或 Fragment 中调用 Activity Result API 提供的 registerForActivityResult() 方法注册结果回调(在 onStart() 之前调用)。该方法接收 ActivityResultContract 和 ActivityResultCallback 参数,返回可以启动另一个 activity 的 ActivityResultLauncher 对象。


    ActivityResultContract 协议类定义生成结果所需的输入类型以及结果的输出类型,Activity Result API 已经提供了很多默认的协议类,方便大家实现请求权限、拍照等常见操作。


    val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
     // Handle the returned Uri
    }

    只是注册回调并不会启动另一个 activity ,还要调用 ActivityResultLauncher#launch() 方法才会启动。传入协议类定义的输入参数,当用户完成后续 activity 的操作并返回时,将执行 ActivityResultCallback 中的 onActivityResult()回调方法。


    getContent.launch("image/*")

    完整的使用代码:


    val getContent = registerForActivityResult(GetContent()) { uri: Uri? ->
      // Handle the returned Uri
    }

    override fun onCreate(savedInstanceState: Bundle?) {
      // ...
      selectButton.setOnClickListener {
        getContent.launch("image/*")
      }
    }

    ActivityResultContracts 提供了许多默认的协议类:


    协议类作用
    RequestPermission()请求单个权限
    RequestMultiplePermissions()请求多个权限
    TakePicturePreview()拍照预览,返回 Bitmap
    TakePicture()拍照,返回 Uri
    TakeVideo()录像,返回 Uri
    GetContent()获取单个内容文件
    GetMultipleContents()获取多个内容文件
    CreateDocument()创建文档
    OpenDocument()打开单个文档
    OpenMultipleDocuments()打开多个文档
    OpenDocumentTree()打开文档目录
    PickContact()选择联系人
    StartActivityForResult()通用协议


    我们还可以自定义协议类,继承 ActivityResultContract,定义输入和输出类。如果不需要任何输入,可使用 Void 或 Unit 作为输入类型。需要实现两个方法,用于创建与 startActivityForResult() 配合使用的 Intent 和解析输出的结果。


    class PickRingtone : ActivityResultContract<Int, Uri?>() {
      override fun createIntent(context: Context, ringtoneType: Int) =
        Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
          putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, ringtoneType)
        }

      override fun parseResult(resultCode: Int, result: Intent?) : Uri? {
        if (resultCode != Activity.RESULT_OK) {
          return null
        }
        return result?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
      }
    }

    自定义协议类实现后,就能调用注册方法和 launch() 方法进行使用。


    val pickRingtone = registerForActivityResult(PickRingtone()) { uri: Uri? ->
      // Handle the returned Uri
    }

    pickRingtone.launch(ringtoneType)

    不想自定义协议类的话,可以使用通用的协议 ActivityResultContracts.StartActivityForResult(),实现类似于之前 startActivityForResult() 的功能。


    val startForResult = registerForActivityResult(StartActivityForResult()) { result: ActivityResult ->
      if (result.resultCode == Activity.RESULT_OK) {
          val intent = result.intent
          // Handle the Intent
      }
    }

    startForResult.launch(Intent(this, InputTextActivity::class.java))

    封装思路


    为什么要封装?


    看完上面的用法,不知道大家会不会和我初次了解的时候一样,感觉比原来复杂很多。


    主要是引入的新概念比较多,原来只需要了解 startActivityForResult()onActivityResult() 的用法,现在要了解一大堆类是做什么的,学习成本高了不少。


    用法也有些奇怪,比如官方示例用注册方法得到一个叫 getContent 对象,这更像是函数的命名,还要用这个对象去调用 launch() 方法,代码阅读起来总感觉怪怪的。


    而且有个地方个人觉得不是很好,callback 居然在 registerForActivityResult() 方法里传。个人觉得 callback 在 launch() 方法里传更符合习惯,逻辑也更加连贯,代码阅读性更好。最好改成下面的用法,启动后就接着处理结果的逻辑。


    getContent.launch("image/*") { uri: Uri? ->
     // Handle the returned Uri
    }

    所以还是有必要对 Activity Result API 进行封装的。


    怎么封装?


    首先是修改 callback 传参的位置,实现思路也比较简单,重载 launch() 方法加一个 callback 参数,用个变量缓存起来。在回调的时候拿缓存的 callback 对象去执行。


    private var callback: ActivityResultCallback? = null

    fun launch(input: I?, callback: ActivityResultCallback<O>) {
     this.callback = callback
     launcher.launch(input)
    }

    由于需要缓存 callback 对象,还要写一个类来持有该缓存变量。


    有一个不好处理的问题是 registerForActivityResult() 需要的 onStart() 之前调用。可以通过 lifecycle 在 onCreate() 的时候自动注册,但是个人思考了好久并没有想到更优的实现方式。就是获取 lifecycleOwner 观察声明周期自动注册,也是需要在 onStart() 之前调用,那为什么不直接执行注册方法呢?所以个人改变了思路,不纠结于自动注册,而是简化注册的代码。


    前面说了需要再写一个类缓存 callback 对象,使用一个类的时候有个方法基本会用到,就是构造函数。我们可以在创建对象的时候进行注册。


    注册方法需要 callback 和协议类对象两个参数,callback 是从 launch() 方法得到,而协议类对象就需要传了。这样用起来个人觉得还不够友好,综合考虑后决定用继承的方式把协议类对象给“隐藏”了。


    最终得到以下的基类。


    public class BaseActivityResultLauncher<I, O> {

     private final ActivityResultLauncher launcher;
     private ActivityResultCallback callback;

     public BaseActivityResultLauncher(ActivityResultCaller caller, ActivityResultContract contract) {
       launcher = caller.registerForActivityResult(contract, (result) -> {
         if (callback != null) {
           callback.onActivityResult(result);
           callback = null;
        }
      });
    }

     public void launch(@SuppressLint("UnknownNullness") I input, @NonNull ActivityResultCallback callback) {
       this.callback = callback;
       launcher.launch(input);
    }
    }

    改用了 Java 代码来实现,返回的结果可以判空也可以不判空,比如返回数组的时候一定不为空,只是数组大小为 0 。用 Kotlin 实现的话要写两个不同名的方法来应对这个情况,使用起来并不是很方便。


    这是多增加一个封装的步骤来简化后续的使用,原本只是继承 ActivityResultContract 实现协议类,现在还需要再写一个启动器类继承 BaseActivityResultLauncher


    比如用前面获取图片的示例,我们再封装一个 GetContentLauncher 类。


    class GetContentLauncher(caller: ActivityResultCaller) :
    BaseActivityResultLauncher(caller, GetContent())

    只需这么简单的继承封装,后续使用就更加简洁易用了。


    val getContentLauncher = GetContentLauncher(this)

    override fun onCreate(savedInstanceState: Bundle?) {
      // ...
      selectButton.setOnClickListener {
        getContentLauncher.launch("image/*") { uri: Uri? ->
      // Handle the returned Uri
    }
      }
    }

    再封装一个 Launcher 类的好处是,能更方便地重载 launch() 方法,比如在类里增加一个方法在获取图片之前会先授权读取权限。如果改用 Kotlin 拓展函数来实现,在 Java 会更加难用。Launcher 类能对 Java 用法进行兼顾。


    最后总结一下,对比原本 Activity Result API 的用法,改善了什么问题:



    • 简化冗长的注册代码,改成简单地创建一个对象;

    • 改善对象的命名,比如官方示例命名为 getContent 对象就很奇怪,这通常是函数的命名。优化后很自然地用类名来命名为 getContentLauncher,使用一个启动器对象调用 launch() 方法会更加合理;

    • 改变回调的位置,使其更加符合使用习惯,逻辑更加连贯,代码阅读性更好;

    • 输入参数和输出参数不会限制为一个对象,可以重载方法简化用法;

    • 能更方便地整合多个启动器的功能,比如获取读取权限后再跳转相册选择图片;


    最终用法


    由于 Activity Result API 已有很多的协议类,如果每一个协议都去封装一个启动器类会有点麻烦,所以个人已经写好一个库 ActivityResultLauncher 方便大家使用。还新增和完善了一些功能,有以下特点:



    • 完美替代 startActivityForResult()

    • 支持 Kotlin 和 Java 用法

    • 支持请求权限

    • 支持拍照

    • 支持录像

    • 支持选择图片或视频(已适配 Android 10)

    • 支持裁剪图片(已适配 Android11)

    • 支持打开蓝牙

    • 支持打开定位

    • 支持使用存储访问框架 SAF

    • 支持选择联系人


    个人写了个 Demo 给大家来演示有什么功能,完整的代码在 Github 里。


    demo-qr-code.png


    screenshot


    下面来介绍 Kotlin 的用法,Java 的用法可以查看 Wiki 文档


    在根目录的 build.gradle 添加:


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

    添加依赖:


    dependencies {
       implementation 'com.github.DylanCaiCoding:ActivityResultLauncher:1.0.0'
    }

    用法也只有简单的两步:


    第一步,在 ComponentActivityFragment 创建对应的对象,需要注意创建对象的时机要在 onStart() 之前。例如创建通用的启动器:


    private val startActivityLauncher = StartActivityLauncher(this)

    提供以下默认的启动器类:

    启动器作用
    StartActivityLauncher完美替代 startActivityForResult()
    TakePicturePreviewLauncher调用系统相机拍照预览,只返回 Bitmap
    TakePictureLauncher调用系统相机拍照
    TakeVideoLauncher调用系统相机录像
    PickContentLauncher, GetContentLauncher选择单个图片或视频,已适配 Android 10
    GetMultipleContentsLauncher选择多个图片或视频,已适配 Android 10
    CropPictureLauncher裁剪图片,已适配 Android 11
    RequestPermissionLauncher请求单个权限
    RequestMultiplePermissionsLauncher请求多个权限
    AppDetailsSettingsLauncher打开系统设置的 App 详情页
    EnableBluetoothLauncher打开蓝牙
    EnableLocationLauncher打开定位
    CreateDocumentLauncher创建文档
    OpenDocumentLauncher打开单个文档
    OpenMultipleDocumentsLauncher打开多个文档
    OpenDocumentTreeLauncher访问目录内容
    PickContactLauncher选择联系人
    StartIntentSenderLauncher替代 startIntentSender()


    第二步,调用启动器对象的 launch() 方法。


    比如跳转一个输入文字的页面,点击保存按钮回调结果。我们替换掉原来 startActivityForResult() 的写法。


    val intent = Intent(this, InputTextActivity::class.java)
    intent.putExtra(KEY_NAME, "nickname")
    startActivityLauncher.launch(intent) { activityResult ->
    if (activityResult.resultCode == RESULT_OK) {
    data?.getStringExtra(KEY_VALUE)?.let { toast(it) }
    }
    }

    为了方便使用,有些启动器会增加一些更易用的 launch() 方法。比如这个例子能改成下面更简洁的写法。


    startActivityLauncher.launch(KEY_NAME to "nickname") { resultCode, data ->
    if (resultCode == RESULT_OK) {
    data?.getStringExtra(KEY_VALUE)?.let { toast(it) }
    }
    }

    由于输入文字页面可能有多个地方需要跳转复用,我们可以用前面的封装思路,自定义实现一个 InputTextLauncher 类,进一步简化调用的代码,只关心输入值和输出值,不用再处理跳转和解析过程。


    inputTextLauncher.launch("nickname") { value ->
    if (value != null) {
    toast(value)
    }
    }

    通常要对返回值进行判断,因为可能会有取消操作,要判断是不是被取消了。比如返回的 Boolean 要为 true,返回的 Uri 不为 null,返回的数组不为空数组等。


    还有一些常用的功能,比如调用系统相机拍照和跳转系统相册选择图片,已适配 Android 10,可以直接得到 uri 来加载图片和用 file 进行上传等操作。


    takePictureLauncher.launch { uri, file ->
    if (uri != null && file != null) {
    // 上传或取消等操作后建议把缓存文件删除,调用 file.delete()
    }
    }

    pickContentLauncher.launchForImage(
    onActivityResult = { uri, file ->
    if (uri != null && file != null) {
    // 上传或取消等操作后建议把缓存文件删除,调用 file.delete()
    }
    },
    onPermissionDenied = {
    // 拒绝了读取权限且不再询问,可引导用户到设置里授权该权限
    },
    onExplainRequestPermission = {
    // 拒绝了一次读取权限,可弹框解释为什么要获取该权限
    }
    )

    个人也新增了些功能,比如裁剪图片,通常上传头像要裁剪成 1:1 比例,已适配 Android 11。


    cropPictureLauncher.launch(inputUri) { uri, file ->
    if (uri != null && file != null) {
    // 上传或取消等操作后建议把缓存文件删除,调用 file.delete()
    }
    }

    还有开启蓝牙功能,能更容易地开启蓝牙和确保蓝牙功能是可用的(需要授权定位权限和确保定位已打开)。


    enableBluetoothLauncher.launchAndEnableLocation(
    "为保证蓝牙正常使用,请开启定位", // 已授权权限但未开启定位,会跳转对应设置页面,并吐司该字符串
    onLocationEnabled= { enabled ->
    if (enabled) {
    // 已开启了蓝牙,并且授权了位置权限和打开了定位
    }
    },
    onPermissionDenied = {
    // 拒绝了位置权限且不再询问,可引导用户到设置里授权该权限
    },
    onExplainRequestPermission = {
    // 拒绝了一次位置权限,可弹框解释为什么要获取该权限
    }
    )

    更多的用法请查看 Wiki 文档


    原本 Activity Result API 已经有很多默认的协议类,都封装了对应的启动器类。大家可能不会用到所有类,开了混淆会自动移除没使用到的类。


    彩蛋


    个人之前封装过一个 startActivityForResult() 拓展函数,可以直接在后面写回调逻辑。


    startActivityForResult(intent, requestCode) { resultCode, data ->
    // Handle result
    }

    下面是实现的代码,使用一个 Fragment 来分发 onActivityResult 的结果。代码量不多,逻辑应该比较清晰,感兴趣的可以了解一下,Activity Result API 的实现原理应该也是类似的。


    inline fun FragmentActivity.startActivityForResult(
    intent:
    Intent,
    requestCode:
    Int,
    noinline callback: (resultCode: Int, data: Intent?) -> Unit
    )
    =
    DispatchResultFragment.getInstance(this).startActivityForResult(intent, requestCode, callback)

    class DispatchResultFragment : Fragment() {
    private val callbacks = SparseArray<(resultCode: Int, data: Intent?) -> Unit>()

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    retainInstance = true
    }

    fun startActivityForResult(
    intent:
    Intent,
    requestCode:
    Int,
    callback: (
    resultCode: Int, data: Intent?) -> Unit
    )
    {
    callbacks.put(requestCode, callback)
    startActivityForResult(intent, requestCode)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    val callback = callbacks.get(requestCode)
    if (callback != null) {
    callback.invoke(resultCode, data)
    callbacks.remove(requestCode)
    }
    }

    companion object {
    private const val TAG = "dispatch_result"

    fun getInstance(activity: FragmentActivity): DispatchResultFragment =
    activity.run {
    val fragmentManager = supportFragmentManager
    var fragment = fragmentManager.findFragmentByTag(TAG) as DispatchResultFragment?
    if (fragment == null) {
    fragment = DispatchResultFragment()
    fragmentManager.beginTransaction().add(fragment, TAG).commitAllowingStateLoss()
    fragmentManager.executePendingTransactions()
    }
    fragment
    }
    }
    }

    如果觉得 Activity Result API 比较复杂,也可以拷贝这个去用。不过 requestCode 处理得不够好,而且很多功能需要自己额外去实现,用起来可能没那么方便。



    收起阅读 »

    ios--离屏渲染详解

    目录:1.图像显示原理2.图像显示原理2.1 图像到屏幕的流程2.2 显示器显示的流程3.卡顿、掉帧3.1 垂直同步 Vsync + 双缓冲机制 Double Buffering2.3 掉帧和屏幕卡顿的本质4.离屏渲染4.1 什么事离屏渲染、离屏渲染的过程4....
    继续阅读 »

    目录:

    • 1.图像显示原理
    • 2.图像显示原理
      • 2.1 图像到屏幕的流程
      • 2.2 显示器显示的流程
    • 3.卡顿、掉帧
      • 3.1 垂直同步 Vsync + 双缓冲机制 Double Buffering
      • 2.3 掉帧和屏幕卡顿的本质
    • 4.离屏渲染
      • 4.1 什么事离屏渲染、离屏渲染的过程
      • 4.2 既然离屏渲染影响界面,为什么还要用
    • 5.触发离屏渲染
    • 6.如何优化
    1.引言

    先来聊聊为什么要了解离屏渲染?
    看看现在app开发的大环境,14年的时候在深圳,基本上每个公司都要做一个app。不做一个app你都不一定能拉倒更多的投资。再看看现在,死了一大半,现在的用户也不想去下载太多的app。一般手机上只留一些常用的,基本全是大厂的app。然后ios这行问的也就越来越难。性能优化这个绝对会问,在网上也有许多性能优化的总结,但是你不能不知道为什么这么做能优化,要知道其为什么。那么,这时候你就需要知道界面是怎么渲染的,什么时候会掉帧,什么时候会卡顿,这些都使得我们非常有必要去了解离屏渲染。
    离屏渲染过程

    2.图像显示原理
    2.1 图像到屏幕的流程

    先来看一张图,我们结合这张图来说


    首先要明白的一个东西是Render Server 进程,app本身其实并不负责渲染,渲染是有独立的进程负责的,它就是Render Server 。

    当我们在代码里设置修改了UI界面的时候,其实它本质是通过Core Animation修改CALayer。在后续的核心动画总结中 我们会说到UIView和CALayer的关系,以及核心动画的设置等等,这个知识点有点多,需要单独详细的总结出来。所以最后按照图片中的流程显示。

    • 首先,有app处理事件(Handle Events),例如:用户点击了一个按钮,它会触发其他的视图的一个动画等
    • 其次,app通过CPU完成对显示内容的计算 例如:视图的创建,视图的布局 图片文本的绘制等。在完成了对显示内容的计算之后,app对图层进行打包,并在下一次Runloop时,将其发送至Render Server
    • 上面我们提到,Render Server负责渲染。Render Server通过执行Open GL、Core Graphics Metal相关程序。 调用GPU
    • GPU在物理层完成了对图像的渲染。

    说到这我们就要停一下,我们来看下一个图




    上面的流程图 细化了GPU到控制器的这一个过程。
    GPU 拿到位图后执行顶点着色、图元装配、光栅化、片段着色等,最后将渲染的结果交到了Frame Buffer(帧缓存区当中)
    然后视频控制器从帧缓存区中拿到要显示的对象,显示到屏幕上
    图片中的黄色虚线暂时不用管,下面在说垂直同步信号的时候,就明白了。
    这是从我们代码中设置UI,然后到屏幕的一个过程。

    2.2 显示器显示的过程

    现在从帧缓存中拿到了渲染的视图,又该怎么显示到显示器上面呢?

    先来看一张图



    从图中我们也能大致的明白显示的一个过程。

    显示器的电子束从屏幕的左上方开始逐行显示,当第一行扫描完之后接着第二行 又是从左到右,就这样一直到屏幕的最下面扫描完成。我们都知道。手机它是有屏幕的刷新次数的。安卓的现在好多是120的,ios是60。1秒刷新60次,当我们扫描完成以后,屏幕刷新,然后视图就会显示出来。

    3.UI卡顿 掉帧
    3.1垂直同步 Vsync + 双缓冲机制 Double Buffering

    首先我们了解了上面渲染的过程以后,需要考虑遇到一些特别的情况下,该怎么办?在我们代码里写了一个很复杂的UI视图,然后CPU计算布局、GPU渲染,最后放到缓存区。如果在电子束开始扫描新的一帧时,位图还没有渲染好,而是在扫描到屏幕中间时才渲染完成,被放入帧缓冲器中 。
    那么已扫描的部分就是上一帧的画面,而未扫描的部分就是新一帧的图像,这样是不是就造成了屏幕撕裂了。

    但是,在我们平常开发的过程遇到过屏幕撕裂的问题吗?没有吧,这是为什么呢?
    显然是苹果做了优化操作了。也就是垂直同步 Vsync + 双缓冲机制 Double Buffering。

    垂直同步 Vsync
    垂直同步 Vsync相当于给帧缓存加了锁,还记得上面说到的那个黄色虚线嘛,在我们扫描完一帧以后,就会发出一个垂直同步的信号,通知开始扫描下一帧的图像了。他就像一个位置秩序的,你得给我排队一个一个来,别插队。插队的后果就是屏幕撕裂。
    双缓冲机制 Double Buffering
    扫描显示排队进行了,这样在进行下一帧的位图传入的时候,也就意味着我要立刻拿到位图。不能等CPU+GPU计算渲染后再给位图,这样就影响性能。要怎么解决这个问题呢?肯定是 在你快要渲染之前你就要把这些都完成了。你就像排队打针一样,为了节省时间肯定事先都会挽起袖子,到医生那时,直接一针下去了事。扯远了 哈哈。想预先渲染好,就需要另外一个缓存来放下一帧的位图,在它需要扫描的时候,再把渲染好的位图给了帧缓存,帧缓存拿到以后 开始快乐的扫描 显示。
    一个图解释




    3.2 掉帧卡顿

    垂直同步和双缓存机制完美的解决了屏幕撕裂的问题,但是又引出一个新的问题:掉帧。
    掉帧是什么意思呢?从网上copy了一份图



    其实很好理解,上面我们说了ios的屏幕刷新是60次,那么在一次刷新的过程中,我们CPU+GPU它没有把新渲染的位图放到帧缓存区,这时候是不是还是显示的原来的图像。当下刷新下一帧的时候,拿到了新的位图,这里是不是就丢失了一帧。

    卡顿的根本原因:
    CPU和GPU渲染流水线耗时过长 掉帧
    我们平常写界面的时候,通过一些开源的库或者自己使用runloop写的库来检测界面卡顿的时候,屏幕刷新率在50以上就很可以了。一般人哪能体验到掉了10帧。你要刷新率是30,那卡顿想过就很明显了。

    4 离屏渲染
    4.1什么是离屏渲染 离屏渲染的过程

    是指在GPU在当前屏幕缓冲区以外开辟一个缓冲区进行渲染操作.
    过程:首先会创建一个当前屏幕缓冲区以外的新缓存区,屏幕渲染会有一个上下文环境,离屏渲染的过程就是切花上下文环境,现充当前屏幕切换到离屏,等结束以后又将上下文切换回来。所以需要更长的时间来处理。时间一长就可能造成掉帧。
    并且 Offscreen Buffer离屏缓存 本身就需要额外的空间,大量的离屏渲染可能造成内存过大的压力。而且离屏缓存区并不是没有限制大小的,它是不能超过屏幕总像素的2.5倍。

    4.2为什么要使用离屏渲染

    1.一些特殊效果需要使用额外的 Offscreen Buffer 来保存渲染的中间状态,所以不得不使用离屏渲染。
    2.处于效率目的,可以将内容提前渲染保存在 Offscreen Buffer 中,达到复用的目的。
    当使用圆角,阴影,遮罩的时候,图层属性的混合体被指定为在未预合成之前(下一个VSync信号开始前)不能直接在屏幕中绘制,所以就需要屏幕外渲染。

    5.触发离屏渲染
    1. 为图层设置遮罩(layer.mask)
    2. 图层的layer. masksToBounds/view.clipsToBounds属性设置为true
    3. 将图层layer. allowsGroupOpacity设置为yes和layer. opacity<1.0
    4. 为图层设置阴影(layer.shadow)
    5. 为图层设置shouldRasterize光栅化
      6 复杂形状设置圆角等
      7 渐变
      8 文本(任何种类,包括UILabel,CATextLayer,Core Text等)
      9 使用CGContext在drawRect :方法中绘制大部分情况下会导致离屏渲染,甚至仅仅是一个空的实现。
    6 离屏渲染的优化
    1 圆角优化

    方法一

    iv.layer.cornerRadius = 30;
    iv.layer.masksToBounds = YES;
    方法二
    利用mask设置圆角,利用贝塞斯曲线和CAShapeLayer来完成

    CAShapeLayer *mask1 = [[CAShapeLayer alloc] init];
    mask1.opacity = 0.5;
    mask1.path = [UIBezierPath bezierPathWithOvalInRect:iv.bounds].CGPath;
    iv.layer.mask = mask1;
    方法三
    利用CoreGraphics画一个圆形上下文,然后把图片绘制上去

    - (void)setCircleImage
    {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    UIImage * circleImage = [image imageWithCircle];
    dispatch_async(dispatch_get_main_queue(), ^{
    imageView.image = circleImage;
    });
    });
    }


    #import "UIImage+Addtions.h"
    @implementation UIImage (Addtions)
    //返回一张圆形图片
    - (instancetype)imageWithCircle
    {
    UIGraphicsBeginImageContextWithOptions(self.size, NO, 0);
    UIBezierPath *path = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0, 0, self.size.width, self.size.height)];
    [path addClip];
    [self drawAtPoint:CGPointZero];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image;
    }
    }
    shadows(阴影)

    设置阴影后,设置CALayer的shadowPath

    view.layer.shadowPath = [UIBezierPath pathWithCGRect:view.bounds].CGPath;

    mask(遮罩)

    不使用mask
    使用混合图层 使用混合图层,在layer上方叠加相应mask形状的半透明layer

    sublayer.contents = (id)[UIImage imageNamed:@"xxx"].CGImage;
    [view.layer addSublayer:sublayer];
    allowsGroupOpacity(组不透明)

    关闭 allowsGroupOpacity 属性,按产品需求自己控制layer透明度

    edge antialiasing(抗锯齿)

    不设置 allowsEdgeAntialiasing 属性为YES(默认为NO)

    当视图内容是静态不变时,设置 shouldRasterize(光栅化)为YES,此方案最为实用方便


    view.layer.shouldRasterize = true;
    view.layer.rasterizationScale = view.layer.contentsScale;

    如果视图内容是动态变化的,例如cell中的图片,这个时候使用光栅化会增加系统负荷。

    作者:Harry__Li
    链接:https://www.jianshu.com/p/3c3383bdeb71


    收起阅读 »

    12个出现频率最高的iOS技术面试题及答案

    这篇文章给大家总结了在iOS面试的时候可能会遇到的12个技术面试题,以及这些面试题但答案,这些答案只是给大家一些参考,大家可以再结合自己理解进行回答,有需要的朋友们下面来一起看看吧。前言随着移动互联网科技不断的发展和创新,如今无论是公司还是开发者或设计师个人而...
    继续阅读 »

    这篇文章给大家总结了在iOS面试的时候可能会遇到的12个技术面试题,以及这些面试题但答案,这些答案只是给大家一些参考,大家可以再结合自己理解进行回答,有需要的朋友们下面来一起看看吧。

    前言

    随着移动互联网科技不断的发展和创新,如今无论是公司还是开发者或设计师个人而言,面试都是一项耗时耗钱的项目,而面对iOS开发者及设计师在面试时可能会遇到的问题进行了筛选与汇总。下面我们一起来一下看看吧。

    一、如何绘制UIView?

    绘制一个UIView最灵活的方法就是由它自己完成绘制。实际上你不是绘制一个UIView,而是子类化一个UIView并赋予绘制自己的能力。当一个UIView需要执行绘制操作时,drawRect:方法就会被调用,覆盖此方法让你获得绘图操作的机会。当drawRect:方法被调用,当前图形的上下文也被设置为属于视图的图形上下文,你可以使用Core Graphic或者UIKit提供的方法将图形画在该上下文中。

    二、什么是MVVM?主要目的是什么?优点有哪些?

    MVVM即 Model-View-ViewModel

    1.View主要用于界面呈现,与用户输入设备进行交互、

    2.ViewModel是MVVM架构中最重要的部分,ViewModel中包含属性,方法,事件,属性验证等逻辑,负责ViewModel之间的通讯

    3.Model就是我们常说的数据模型,用于数据的构造,数据的驱动,主要提供基础实体的属性。

    MVVM主要目的是分离视图和模型

    MVVM优点:低耦合,可重用性,独立开发,可测试

    三、get请求与post请求的区别

    1.get是向服务器发索取数据的一种请求,而post是向服务器提交数据的一种请求

    2.get没有请求体,post有请求体

    3.get请求的数据会暴露在地址栏中,而post请求不会,所以post请求的安全性比get请求号

    4.get请求对url长度有限制,而post请求对url长度理论上是不会收限制的,但是实际上各个服务器会规定对post提交数据大小进行限制。

    四、谈谈你对多线程开发的理解?ios中有几种实现多线程的方法?

    好处:

    1.使用多线程可以把程序中占据时间长的任务放到后台去处理,如图片,视频的下载;

    2.发挥多核处理器的优势,并发执行让系统运行的更快,更流畅,用户体验更好;

    缺点:

    1.大量的线程降低代码的可读性;

    2.更多的线程需要更多的内存空间;

    3当多个线程对同一个资源出现争夺的时候要注意线程安全的问题。

    ios有3种多线程编程的技术:1.NSThread,2.NSOperationQueue,3.gcd;

    五、XMPP工作原理;xmpp系统特点

    原理:

    1.所有从一个client到另一个clientjabber消息和数据都要通过xmpp server

    2.client链接到server

    3.server利用本地目录系统的证书对其认证

    4.server查找,连接并进行相互认证

    5.client间进行交互

    特点:1)客户机/服务器通信模式;2)分布式网络;3)简单的客户端;4)XML的数据格式

    六、地图的定位是怎么实现的?

    1.导入了CoreLocation.framework

    2.ios8以后,如果需要使用定位功能,就需要请求用户授权,在首次运行时会弹框提示

    3.通过本机自带的gps获取位置信息(即经纬度)

    七、苹果内购实现流程

    程序通过bundle存储的plist文件得到产品标识符的列表。

    程序向App Store发送请求,得到产品的信息。

    App Store返回产品信息。

    程序把返回的产品信息显示给用户(App的store界面)

    用户选择某个产品

    程序向App Store发送支付请求

    App Store处理支付请求并返回交易完成信息。

    App获取信息并提供内容给用户。

    八、支付宝,微信等相关类型的sdk的集成

    1.在支付宝开发平台创建应用并获取APPID

    2.配置密钥

    3.集成并配置SDK

    4.调用接口(如交易查询接口,交易退款接口)

    九、 gcd产生死锁的原因及解锁的方法

    产生死锁的必要条件:1.互斥条件,2.请求与保持条件,3.不剥夺条件,4.循环等待条件。

    解决办法:采用异步执行block。

    十、生成二维码的步骤

    1.使用CIFilter滤镜类生成二维码

    2.对生成的二维码进行加工,使其更清晰

    3.自定义二维码背景色、填充色

    4.自定义定位角标

    5.在二维码中心插入小图片


    十一、在使用XMPP的时候有没有什么困难

    发送附件(图片,语音,文档...)时比较麻烦

    XMPP框架没有提供发送附件的功能,需要自己实现

    实现方法,把文件上传到文件服务器,上传成功后获取文件保存路径,再把附件的路径发送给好友



    十二、是否使用过环信,简单的说下环信的实现原理

    环信是一个即时通讯的服务提供商

    环信使用的是XMPP协议,它是再XMPP的基础上进行二次开发,对服务器Openfire和客户端进行功能模型的添加和客户端SDK的封装,环信的本质还是使用XMPP,基于Socket的网络通信

    环信内部实现了数据缓存,会把聊天记录添加到数据库,把附件(如音频文件,图片文件)下载到本地,使程序员更多时间是花到用户体验体验上。



    作者:iOS鑫
    链接:https://www.jianshu.com/p/d95967869aed
    收起阅读 »

    最新iOS开发常见面试题-基础篇

    1.iOS线程与进程的区别和联系?进程和线程都是由操作系统所体会的程序运行的基本单元,系统利用该基本单元实现系统对应用的并发性。程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而...
    继续阅读 »

    1.iOS线程与进程的区别和联系?

    进程和线程都是由操作系统所体会的程序运行的基本单元,系统利用该基本单元实现系统对应用的并发性。

    程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

    2.iOS 如何找到最合适的控件来处理事件?

    自己是否能接收触摸事件?
    触摸点是否在自己身上?
    从后往前遍历子控件,重复前面的两个步骤
    如果没有符合条件的子控件,那么就自己最适合处理

    3.iOS static 关键字的作用?

    (1)函数体内 static 变量的作用范围为该函数体,不同于 auto 变量,该变量的内存只被分配一次,

    因此其值在下次调用时仍维持上次的值;

    (2)在模块内的 static 全局变量可以被模块内所用函数访问,但不能被模块外其它函数访问;

    (3)在模块内的 static 函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明

    它的模块内;

    (4)在类中的 static 成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;

    (5)在类中的 static 成员函数属于整个类所拥有,这个函数不接收 this 指针,因而只能访问类的static 成员变量。

    4.iOS UIEvent对象的作用与常见属性?

    每产生一个事件,就会产生一个UIEvent对象

    UIEvent : 称为事件对象,记录事件产生的时刻和类型
    常见属性 

      //事件类型
    //@property(nonatomic,readonly) UIEventType type;
    //@property(nonatomic,readonly) UIEventSubtype subtype;
    //事件产生的时间
    @property(nonatomic,readonly) NSTimeInterval timestamp;
    UIEvent还提供了相应的方法可以获得在某个view上面的触摸对象(UITouch)

    4.ViewController 的 loadView, viewDidLoad, viewDidUnload 分别是在什么时候调用的?在自定义ViewController的时候这几个函数里面应该做什么工作?

    viewDidLoad在view 从nib文件初始化时调用,loadView在controller的view为nil时调用。此方法在编程实现view时调用,view 控制器默认会注册memory warning notification,当view controller的任何view 没有用的时候,viewDidUnload会被调用,在这里实现将retain 的view release,如果是retain的IBOutlet view 属性则不要在这里release,IBOutlet会负责release 。

    5.object-c 的优缺点 ?

    objc优点:

    1. Cateogies

    2. Posing

    3. 动态识别

    4. 指标计算

    5)弹性讯息传递

    1. 不是一个过度复杂的 C 衍生语言

    2. Objective-C 与 C++ 可混合编程

    缺点:

    1. 不支援命名空间

    2. 不支持运算符重载

    3)不支持多重继承

    4)使用动态运行时类型,所有的方法都是函数调用,所以很多编译时优化方法都用不到。(如内联函数等),性能低劣。

    6.iOS引用与指针有什么区别?

    1.引用必须被初始化,指针不必。
    2.引用初始化以后不能被改变,指针可以改变所指的对象。
    3.不存在指向空值的引用,但是存在指向空值的指针。

    7.iOS堆和栈的区别 ?

    管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。

    申请大小:

    栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。

    堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

    碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出

    分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。

    分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的。

    8.什么时候用delegate,什么时候用Notification?

    delegate针对one-to-one关系,并且reciever可以返回值 给sender,notification 可以针对one-to-one/many/none,reciever无法返回值给sender.所以,delegate用于sender希望接受到 reciever的某个功能反馈值,notification用于通知多个object某个事件。

    9.iOS UITouch对象的作用与常见属性?

    当用户用一根手指触摸屏幕时,会创建一个与手指相关联的UITouch对象 一根手指对应一个UITouch对象

    UITouch的作用:

    保存着跟手指相关的信息,比如触摸的位置、时间、阶段
    当手指移动时,系统会更新同一个UITouch对象,使之能够一直保存该手指在的触摸位置
    当手指离开屏幕时,系统会销毁相应的UITouch对象
    UITouch的常见属性

    //触摸产生时所处的窗口
    @property(nonatomic,readonly,retain) UIWindow *window;

    //触摸产生时所处的视图
    @property(nonatomic,readonly,retain) UIView *view;

    //短时间内点按屏幕的次数,可以根据tapCount判断单击、双击或更多的点击
    @property(nonatomic,readonly) NSUInteger tapCount;

    //记录了触摸事件产生或变化时的时间,单位是秒
    @property(nonatomic,readonly) NSTimeInterval timestamp;

    //当前触摸事件所处的状态
    @property(nonatomic,readonly) UITouchPhase phase;

    UITouch的常见方法

       //返回值表示触摸在view上的位置
    //这里返回的位置是针对view的坐标系的(以view的左上角为原点(0, 0))
    //调用时传入的view参数为nil的话,返回的是触摸点在UIWindow的位置
    - (CGPoint)locationInView:(UIView *)view;
    // 该方法记录了前一个触摸点的位置
    - (CGPoint)previousLocationInView:(UIView *)view;


    10.object-c 的内存管理 ?

    如果您通过分配和初始化(比如[[MyClass alloc] init])的方式来创建对象,您就拥有这个对象,需要负责该对象的释放。这个规则在使用NSObject的便利方法new 时也同样适用。

    如果您拷贝一个对象,您也拥有拷贝得到的对象,需要负责该对象的释放。

    如果您保持一个对象,您就部分拥有这个对象,需要在不再使用时释放该对象。

    反过来,如果您从其它对象那里接收到一个对象,则您不拥有该对象,也不应该释放它(这个规则有少数的例外)

    11.iOS单件实例是什么 ?

    Foundation 和 Application Kit 框架中的一些类只允许创建单件对象,即这些类在当前进程中的唯一实例。举例来说,NSFileManager 和NSWorkspace 类在使用时都是基于进程进行单件对象的实例化。当向这些类请求实例的时候,它们会向您传递单一实例的一个引用,如果该实例还不存在,则首先进行实例的分配和初始化。单件对象充当控制中心的角色,负责指引或协调类的各种服务。如果类在概念上只有一个实例(比如NSWorkspace),就应该产生一个单件实例,而不是多个实例;如果将来某一天可能有多个实例,您可以使用单件实例机制,而不是工厂方法或函数。

    12.iOS类工厂方法是什么 ?

    类工厂方法的实现是为了向客户提供方便,它们将分配和初始化合在一个步骤中,返回被创建的对象,并进行自动释放处理。这些方法的形式是+ (type)className...(其中 className不包括任何前缀)。

    工厂方法可能不仅仅为了方便使用。它们不但可以将分配和初始化合在一起,还可以为初始化过程提供对象的分配信息,类工厂方法的另一个目的是使类(比如NSWorkspace)提供单件实例。虽然init...方法可以确认一个类在每次程序运行过程只存在一个实例,但它需要首先分配一个“生的”实例,然后还必须释放该实例,工厂方法则可以避免为可能没有用的对象盲目分配内存。

    13.一个指针可以是volatile 吗?解释为什么。

    是的。尽管这并不很常见。一个例子是当一个中服务子程序修该一个指向一个buffer的指针时。

    14.iOS 类别的局限性 ?

    有两方面局限性:

    (1)无法向类中添加新的实例变量,类别没有位置容纳实例变量。

    (2)名称冲突,即当类别中的方法与原始类方法名称冲突时,类别具有更高的优先级。类别方法将完全取代初始方法从而无法再使用初始方法。

    无法添加实例变量的局限可以使用字典对象解决

    15.什么是iOS键-值,键路径是什么 ?

    模型的性质是通过一个简单的键(通常是个字符串)来指定的。视图和控制器通过键来查找相应的属性值。在一个给定的实体中,同一个属性的所有值具有相同的数据类型。键-值编码技术用于进行这样的查找—它是一种间接访问对象属性的机制。

    键路径是一个由用点作分隔符的键组成的字符串,用于指定一个连接在一起的对象性质序列。第一个键的性质是由先前的性质决定的,接下来每个键的值也是相对于其前面的性质。键路径使您可以以独立于模型

    实现的方式指定相关对象的性质。通过键路径,您可以指定对象图中的一个任意深度的路径,使其指向相关对象的特定属性。

    16.iOS 类别的作用 ?

    类别主要有3个作用:

    (1)将类的实现分散到多个不同文件或多个不同框架中。

    (2)创建对私有方法的前向引用。

    (3)向对象添加非正式协议。

    17.sprintf,strcpy,memcpy使用上有什么要注意的地方 ?

    strcpy是一个字符串拷贝的函数,它的函数原型为strcpy(char dst, ct char *src);

    将 src开始的一段字符串拷贝到dst开始的内存中去,结束的标志符号为'\0',由于拷贝的长度不是由我们自己控制的,所以这个字符串拷贝很容易出错。具备字符串拷贝功能的函数有memcpy,这是一个内存拷贝函数,它的函数原型为memcpy(char dst, c*t char src, unsigned int len);

    将长度为len的一段内存,从src拷贝到dst中去,这个函数的长度可控。但是会有内存叠加的问题。

    sprintf是格式化函数。将一段数据通过特定的格式,格式化到一个字符串缓冲区中去。sprintf格式化的函数的长度不可控,有可能格式化后的字符串会超出缓冲区的大小,造成溢出。

    14答案是:

    a) int a; // An integer

    b) int *a; // A pointer to an integer

    c) int **a; // A pointer to a pointer to an integer

    d) int a[10]; // An array of 10 integers

    e) int *a[10]; // An array of 10 pointers to integers

    f) int (*a)[10]; // A pointer to an array of 10 integers

    g) int (*a)(int); // A pointer to a function a that takes an integer argument and returns an integer

    h) int (a[10])(int); // An array of 10 pointers to functi that take an integer argument and return an integer

    18.readwrite,readonly,assign,retain,copy,nonatomic属性的作用

    @property是一个属性访问声明,扩号内支持以下几个属性:

    1,getter=getterName,setter=setterName,设置setter与getter的方法名

    2,readwrite,readonly,设置可供访问级别

    2,assign,setter方法直接赋值,不进行任何retain操作,为了解决原类型与环循引用问题

    3,retain,setter方法对参数进行release旧值再retain新值,所有实现都是这个顺序(CC上有相关资料)

    4,copy,setter方法进行Copy操作,与retain处理流程一样,先旧值release,再Copy出新的对象,retainCount为1。这是为了减少对上下文的依赖而引入的机制。copy是在你不希望a和b共享一块内存时会使用到。a和b各自有自己的内存。

    5,nonatomic,非原子性访问,不加同步,多线程并发访问会提高性能。注意,如果不加此属性,则默认是两个访问方法都为原子型事务访问。锁被加到所属对象实例级(我是这么理解的...)。

    atomic和nonatomic用来决定编译器生成的getter和setter是否为原子操作。在多线程环境下,原子操作是必要的,否则有可能引起错误的结果。

    19"NSMutableString *"这个数据类型则是代表"NSMutableString"对象本身,这两者是有区别的。

    NSString只是对象的指针而已。

    面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了。

    面向对象是把构成问题事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个事物在整个解决问题的步骤中的行为。


    作者:iOS鑫
    链接:https://www.jianshu.com/p/48a5b53c63e8

    收起阅读 »

    iOS面试备战-网络篇

    计算机网络是计算机科学与技术专业的必修课,也是移动端,前端,后端都会涉及并用到的知识点,可想而知它的重要性。所以它也成为了iOS面试中经常被问及的问题。准备面试的话,网络相关的知识点一定不能错过。这里总结了一些我认为有用的和最近面试遇到的网络相关知识点。计算机...
    继续阅读 »
    计算机网络是计算机科学与技术专业的必修课,也是移动端,前端,后端都会涉及并用到的知识点,可想而知它的重要性。所以它也成为了iOS面试中经常被问及的问题。准备面试的话,网络相关的知识点一定不能错过。这里总结了一些我认为有用的和最近面试遇到的网络相关知识点。


    计算机网络是如何分层的

    网络有两种分层模型,一种是ISO(国际标准化组织)制定的OSI(Open System Interconnect)模型,它将网络分为七层。一种是TCP/IP的四层网络模型。OSI是一种学术上的国际标准,理想概念,TCP/IP是事实上的国际标准,被广泛应用于现实生活中。两者的关系可以看这个图:




    注:也有说五层模型的,它跟四层模型的区别就是,在OSI模型中的数据链路层和物理层,前者将其作为两层,后者将其合并为一层称为网络接口层。一般作为面试题的话都是需要讲出OSI七层模型的。

    各个分层的含义以及它们之间的关系用这张图表示:



    Http协议

    http协议特性

    • HTTP 协议构建于 TCP/IP 协议之上,是一个应用层协议,默认端口号是 80
    • 灵活:HTTP允许传输任意类型的数据对象。正在传输的类型由Content-Type加以标记。
    • 无状态:无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。
    • 无状态:HTTP协议是无状态协议。无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传。

    请求方法

    • GET:请求获取Request-URI标识的资源,请求参数附加在url上,明文展示。

    • POST:在Request-URI所标识的资源后附加新的数据,常用于修改服务器资源或者提交资源到服务器。POST请求体是放到body中的,可以指定编码方式,更加安全。

    • HEAD:请求获取由Request-URI所标识的资源的响应消息报头。

    • PUT:请求服务器存储一个资源,并用Request-URI作为其标识。

    • DELETE:请求服务器删除Request-URI所标识的资源。

    • TRACE:请求服务器回送收到的请求信息,主要用于测试或诊断。

    • OPTIONS:请求查询服务器的性能,或者查询与资源相关的选项和需求。

    请求和响应报文

    在Chrome查看其请求的Headers信息。

    General

    这里标记了请求的URL,请求方法为GET。状态码为304,代表文件未修改,可以直接使用缓存的文件。远程地址为185.199.111.153:443,此IP为Github 服务器地址,是因为我的博客是部署在GitHub上的。

    除了304还有别的状态码,分别是:

    • 200 OK 客户端请求成功
    • 301 Moved Permanently 请求永久重定向
    • 302 Moved Temporarily 请求临时重定向
    • 304 Not Modified 文件未修改,可以直接使用缓存的文件。
    • 400 Bad Request 由于客户端请求有语法错误,不能被服务器所理解。
    • 401 Unauthorized 请求未经授权。这个状态代码必须和WWW-Authenticate报头域一起使用
    • 403 Forbidden 服务器收到请求,但是拒绝提供服务。服务器通常会在响应正文中给出不提供服务的原因
    • 404 Not Found 请求的资源不存在,例如,输入了错误的URL
    • 500 Internal Server Error 服务器发生不可预期的错误,导致无法完成客户端的请求。
    • 503 Service Unavailable 服务器当前不能够处理客户端的请求,在一段时间之后,服务器可能会恢复正常。

    Response Headers


    content-encoding:用于指定压缩算法

    content-length:资源的大小,以十进制字节数表示。

    content-type:指示资源的媒体类型。图中所示内容类型为html的文本类型,文字编码方式为utf-8

    last-modified:上次内容修改的日期,为6月8号

    status:304 文件未修改状态码

    注:其中content-type在响应头中代表,需要解析的格式。在请求头中代表上传到服务器的内容格式。

    Request Headers




    method:GET请求

    :path:url路径

    :scheme:https请求

    accept:通知服务器可以返回的数据类型。

    accept-encoding:编码算法,通常是压缩算法,可用于发送回的资源

    accept-language:通知服务器预期发送回的语言类型。这是一个提示,并不一定由用户完全控制:服务器应该始终注意不要覆盖用户的显式选择(比如从下拉列表中选择语言)。

    cookie:浏览器cookie

    user-agent:用户代理,标记系统和浏览器内核

    TCP三次握手和四次挥手的过程以及为什么要有三次和四次

    在了解TCP握手之前我们先看下TCP的报文样式:

    TCP三次握手

    示意图如下:



    三次握手是指建立一个TCP连接时,需要客户端和服务器总共发送3个数据包。

    1、第一次握手(SYN=1, seq=x)

    客户端发送一个 TCP 的 SYN 标志位置1的包,指明客户端打算连接的服务器的端口,以及初始序号 X,保存在包头的序列号(Sequence Number)字段里。

    发送完毕后,客户端进入 SYN_SEND 状态。

    2、第二次握手(SYN=1, ACK=1, seq=y, ACKnum=x+1)

    服务器发回确认包(ACK)应答。即 SYN 标志位和 ACK 标志位均为1。服务器端选择自己 ISN 序列号,放到 Seq 域里,同时将确认序号(Acknowledgement Number)设置为客户的 ISN 加1,即X+1。 发送完毕后,服务器端进入 SYN_RCVD 状态。

    3、第三次握手(ACK=1, ACKnum=y+1)

    客户端再次发送确认包(ACK),SYN 标志位为0,ACK 标志位为1,并且把服务器发来 ACK 的序号字段+1,放在确定字段中发送给对方,并且在数据段放写ISN的+1

    发送完毕后,客户端进入 ESTABLISHED 状态,当服务器端接收到这个包时,也进入 ESTABLISHED 状态,TCP 握手结束。

    问题一:为什么需要三次握手呢?

    在谢希仁著的《计算机网络》里说,『为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误』。怎么理解呢,我们假设一种情况,有一个建立连接的第一次握手的报文段因为滞留到网络中过了较长时间才发送到服务端。这时服务器是要做ACK应答的,如果只有两次握手就代表连接建立,那服务器此时就要等待客户端发送建立连接之后的数据。而这只是一个因滞留而废弃的请求,是不是白白浪费了很多服务器资源。

    从另一个角度看这个问题,TCP是全双工的通信模式,需要保证两端都已经建立可靠有效的连接。在三次握手过程中,我们可以确认的状态是:

    第一次握手:服务器确认自己接收OK,服务端确认客户端发送OK。

    第二次握手:客户端确认自己发送OK,客户端确认自己接收OK,客户端确认服务器发送OK,客户端确认服务器接收OK。

    第三次握手:服务器确认自己发送OK,服务器确认客户端接收OK。

    只有握手三次才能达到全双工的目的:确认自己和对方都能够接收和发送消息。

    TCP四次挥手

    示意图如下:


    四次挥手表示要发送四个包,挥手的目的是断开连接。

    1、第一次挥手(FIN=1, seq=x)

    假设客户端想要关闭连接,客户端发送一个 FIN 标志位置为1的包,表示自己已经没有数据可以发送了,但是仍然可以接受数据。

    发送完毕后,客户端进入 FIN_WAIT_1 状态。

    2、第二次挥手(ACK=1,ACKnum=x+1)

    服务器端确认客户端的 FIN 包,发送一个确认包,表明自己接受到了客户端关闭连接的请求,但还没有准备好关闭连接。

    发送完毕后,服务器端进入 CLOSE_WAIT 状态,客户端接收到这个确认包之后,进入 FIN_WAIT_2 状态,等待服务器端关闭连接。

    3、第三次挥手(FIN=1,seq=y)

    服务器端准备好关闭连接时,向客户端发送结束连接请求,FIN 置为1。

    发送完毕后,服务器端进入 LAST_ACK 状态,等待来自客户端的最后一个ACK。

    4、第四次挥手(ACK=1,ACKnum=y+1)

    客户端接收到来自服务器端的关闭请求,发送一个确认包,并进入 TIME_WAIT状态,等待可能出现的要求重传的 ACK 包。

    服务器端接收到这个确认包之后,关闭连接,进入 CLOSED 状态。

    客户端等待了某个固定时间(两个最大段生命周期,2MSL,2 Maximum Segment Lifetime)之后,没有收到服务器端的 ACK ,认为服务器端已经正常关闭连接,于是自己也关闭连接,进入 CLOSED 状态。

    问题一:为什么挥手需要四次呢?为什么不能将ACK和FIN报文一起发送?

    当服务器收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉客户端『你发的FIN我收到了』。只有等到服务端所有的报文都发送完了,才能发FIN报文,所以要将ACK和FIN分开发送,这就导致需要四次挥手。

    问题二:为什么TIMED_WAIT之后要等2MSL才进入CLOSED状态?

    MSL是TCP报文的最大生命周期,因为TIME_WAIT持续在2MSL就可以保证在两个传输方向上的尚未接收到或者迟到的报文段已经消失,同时也是在理论上保证最后一个报文可靠到达。假设最后一个ACK丢失,那么服务器会再重发一个FIN,这是虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK。

    HTTPS的流程

    HTTPS = HTTP + TLS/SSL,它使用的端口默认为443,它的建立可以用下图表示:



    1、客户端首次请求服务器,告诉服务器自己支持的协议版本,支持的加密算法及压缩算法,并生成一个随机数(client random)告知服务器。

    2、服务器确认双方使用的加密方法,并返回给客户端证书以及一个服务器生成的随机数(server random)

    3、客户端收到证书后,首先验证证书的有效性,然后生成一个新的随机数(premaster secret),并使用数字证书中的公钥,加密这个随机数,发送给服务器。

    4、服务器接收到加密后的随机数后,使用私钥进行解密,获取这个随机数(premaster secret

    5、服务器和客户端根据约定的加密方法,使用前面的三个随机数(client random, server random, premaster secret),生成『对话密钥』(session key),用来加密接下来的整个对话过程(对称加密)。

    问题一:为什么握手过程需要三个随机数,而且安全性只取决于第三个随机数?

    前两个随机数是明文传输,存在被拦截的风险,第三个随机数是通过证书公钥加密的,只有它是经过加密的,所以它保证了整个流程的安全性。前两个随机数的目的是为了保证最终对话密钥的『更加随机性』。

    问题二:Charles如何实现HTTPS的拦截?

    Charles要实现对https的拦截,需要在客户端安装Charles的证书并信任它,然后Charles扮演中间人,在客户端面前充当服务器,在服务器面前充当客户端。

    问题三:为什么有些HTTPS请求(例如微信)抓包结果仍是加密的,如何实现的?


    我在聊天过程中并没有抓到会话的请求,在小程序启动的时候到是抓到了一个加密内容。我手动触发该链接会下载一个加密文件,我猜测这种加密是内容层面的加密,它的解密是由客户端完成的,而不是在HTTPS建立过程完成的。

    另外在研究这个问题的过程中,又发现了一些有趣的问题:

    1、图中所示的三个https请求分别对应三个不同类型的图标,它们分别代表什么意思呢?

    感谢iOS憨憨的回答。 第一个图标含义是HTTP/2.0,第二个图标含义是HTTP/1.1,第三个图标加锁是因为我用charles只抓取了443端口的请求,该请求端口为5228,所以不可访问。

    2、第三个请求https://mtalk.google.com:5228图标和请求内容都加了锁,这个加锁是在https之上又加了一层锁吗?

    这些问题暂时没有确切的答案,希望了解的小伙伴告知一下哈。

    DNS解析流程

    DNS(Domain name system)域名系统。DNS是因特网上作为域名和IP地址相互映射的一个分布式数据库,能够使用户通过域名访问到对应的服务器(IP地址)。具体的解析流程是这样的:

    1、浏览器中输入想要访问的网站域名,操作系统会检查本地hosts文件是否有这个网址的映射关系,如果有就调用这个IP地址映射,完成域名解析。没有的话就走第二步。

    2、客户端回向本地DNS服务器发起查询,如果本地DNS服务器收到请求,并可以在本地配置区域资源中查到该域名,就将对应结果返回为给客户端。如果没有就走第三步。

    3、根据本地DNS服务器的设置,采用递归或者迭代查询,直至解析完成。

    其中递归查询和迭代查询可以用如下两图表示。

    递归查询

    如图所示,递归查询是由DNS服务器一级一级查询传递的。


    迭代查询

    如果所示,迭代查询是找到指定DNS服务器,由客户端发起查询。



    DNS劫持

    DNS劫持发生在DNS服务器上,当客户端请求解析域名时将其导向错误的服务器(IP)地址。

    常见的解决办法是使用自己的解析服务器或者是将域名以IP地址的方式发出去以绕过DNS解析。

    Cookie和Session的区别

    HTTP 是无状态协议,说明它不能以状态来区分和管理请求和响应。也就是说,服务器单从网络连接上无从知道客户身份。

    可是怎么办呢?就给客户端们颁发一个通行证吧,每人一个,无论谁访问都必须携带自己通行证。这样服务器就能从通行证上确认客户身份了。这就是Cookie的工作原理。

    • Cookie:Cookie是客户端保存用户信息的一种机制,用来记录用户的一些信息,实际上Cookie是服务器在本地机器上存储的一小段文本,并随着每次请求发送到服务器。Cookie技术通过请求和响应报文中写入Cookie信息来控制客户端的状态。

    • Session:Session机制是一种服务器端的机制,服务器使用一种类似于散列表的结构来保存信息。当有用户请求创建一个session时,服务器会先检查这个客户端里是否已经包含了一个Session标识(session id),如果有就通过session id把session检索出来。如果没有就创建一个对应此Session的session id。这个session id会在本次响应中返回给客户端。

    两者有以下区别:

    1、存储位置:Cookie存放在客户端上,Session数据存放在服务器上。

    2、Session 的运行依赖 session id,而 session id 是存在 Cookie 中的,也就是说,如果浏览器禁用了 Cookie ,同时 Session 也会失效

    3、安全性:Cookie存在浏览器中,可能会被一些程序复制,篡改;而Session存在服务器相对安全很多。

    4、性能:Session会在一定时间内保存在服务器上,当访问增多,会对服务器造成一定的压力。考虑到减轻服务器压力,应当使用Cookie

    CDN是干什么用的

    CDN(Content Delivery Network),根本作用是将网站的内容发布到最接近用户的网络『边缘』,以提高用户访问速度。概括的来说:CDN = 镜像(Mirror) + 缓存(Cache) + 整体负载均衡(GSLB)。

    目前CDN都以缓存网站中的静态数据为主,如CSS、JS、图片和静态网页等数据。用户在从主站服务器请求到动态内容后再从CDN上下载这些静态数据,从而加速网页数据内容的下载速度,如淘宝有90%以上的数据都是由CDN来提供的。

    CDN工作流程

    一个用户访问某个静态文件(如CSS),这个静态文件的域名假如是http://www.baidu.com,而这个域名最终会被指向CDN全局中CDN负载均衡服务器,再由这个负载均衡服务器来最终分配是哪个地方的访问用户,返回给离这个访问用户最近的CDN节点。之后用户就直接去这个CDN节点访问这个静态文件了,如果这个节点中请求的文件不存在,就会再回到源站去获取这个文件,然后再返回给用户。


    Socket的作用

    socket位于应用层和传输层之间:


    它的作用是为了应用层能够更方便的将数据经由传输层来传输。所以它的本质就是对TCP/IP的封装,然后应用程序直接调用socket API即可进行通信。上文中说的三次握手和四次挥手即是通过socket完成的。

    我们可以从iOS中网络库分层找到BSD Sockets,它是位于CFNetwork之下。在CFNetwork中还有一个CFSocket,推测是对BSD Sockets的封装。


    WebRTC是干什么用的

    WebRTC

    是一个可以用在视频聊天,音频聊天或P2P文件分享等Web App中的 API。借助WebRTC,你可以在基于开放标准的应用程序中添加实时通信功能。它支持在同级之间发送视频,语音和通用数据,从而使开发人员能够构建功能强大的语音和视频通信解决方案。该技术可在所有现代浏览器以及所有主要平台的本机客户端上使用。WebRTC项目是开源的并得到Apple,Google,Microsoft和Mozilla等的支持。

    如果某一请求只在某一地特定时刻失败率较高,会有哪些原因

    这个是某公司二面时的问题,是一个开放性问题,我总结了以下几点可能:

    1、该时刻请求量过大

    2、该地的网络节点较不稳定

    3、用户行为习惯,比如该时刻为上班高峰期,或者某个群体的特定习惯



    作者:iOS鑫
    链接:https://www.jianshu.com/p/6b16f40d7354



    收起阅读 »

    Android基础到进阶UI爸爸级TextView介绍+实例

    TextView是什么 向用户显示文本,并可选择允许他们编辑文本。TextView是一个完整的文本编辑器,但是基类为不允许编辑;其子类EditText允许文本编辑。 咱们先上一个图看看TextView的继承关系: 从上图可以看出TxtView继承了Vi...
    继续阅读 »

    TextView是什么


    向用户显示文本,并可选择允许他们编辑文本。TextView是一个完整的文本编辑器,但是基类为不允许编辑;其子类EditText允许文本编辑。


    咱们先上一个图看看TextView的继承关系:


    从上图可以看出TxtView继承了View,它还是Button、EditText等多个组件类的父类。咱们看看这些子类是干嘛的。



    • Button:用户可以点击或单击以执行操作的用户界面元素。

    • CheckedTextView:TextView支持Checkable界面和显示的扩展。

    • Chronometer:实现简单计时器的类。

    • DigitalClock:API17已弃用可用TextClock替代。

    • EditText:用于输入和修改文本的用户界面元素。

    • TextClock:可以将当前日期和/或时间显示为格式化字符串。


    看看他的儿子都这么牛掰,何况是爸爸,今天咱就看看这个爸爸级组件:TextView


    使用TextView


    1.在xml中创建并设置属性



    咱们看上图说话。上图的文字显示多种多样,但是也仅包含TextView的部分功能,看看这多种多样的显示也是比较有意思的。


    下面咱看看代码实践:


    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="@dimen/dimen_20"
        android:orientation="vertical">

        <!--在Design中表示可从左侧控件展示处拖拽至布局文件上,创建简单一个TextView。-->
        <TextView
            android:id="@+id/textView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="TextView" />

        <!--修改颜色、大小-->
        <!--设置颜色 @color/color_ff0000位置:app/values/colors-->
        <!--设置大小 @dimen/text_size_18位置:app/values/dimens-->
        <!--设置内容 @string/str_setting_color_size位置:app/values/strings-->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="@string/str_setting_color_size"
            android:layout_marginTop="@dimen/dimen_10"
            android:textColor="@color/color_ff0000"
            android:textSize="@dimen/text_size_20" />

        <!--添加图片和使用阴影-->
        <!--添加图片:drawableTop、drawableBottom、drawableLeft(drawableStart)、drawableRight(drawableEnd)-->
        <!--使用阴影:shadowColor(阴影颜色)、shadowDx(tv_2位置为基准,数字越大越往右)、
        shadowDy(tv_2位置为基准,数字越大越往下)、shadowRadius(数字越大越模糊)-->

        <!--图片 @mipmap/ic_launcher 位置:app/mipmap/任意一个目录能找到即可-->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:drawableLeft="@mipmap/ic_launcher"
            android:layout_marginTop="@dimen/dimen_10"
            android:gravity="center_vertical"
            android:shadowColor="@color/color_FF773D"
            android:shadowDx="30"
            android:shadowDy="-20"
            android:shadowRadius="2"
            android:text="右侧添加图片和使用阴影"
            android:textColor="@color/color_188FFF"
            android:textSize="@dimen/text_size_20" />

        <!--对电话和邮件增加链接-->
        <!--autoLink对文本内容自动添加E-mail地址、电话号码添加超级链接-->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:autoLink="email|phone"
            android:gravity="center_vertical"
            android:layout_marginTop="@dimen/dimen_10"
            android:text="可点击跳转邮件:SCC5201314@qq.com\n可点击跳转电话:0215201314"
            android:textColor="@color/color_188FFF"
            android:textSize="@dimen/text_size_14" />

        <!--内容过多-->
        <!--maxLength最多显示几行,单行也可用android:singleline="true"-->
        <!--ellipsize,内容显示不下时,显示...(位置最前、中间、最后都可以),这里要加行数限制才行-->
        <!--lineSpacingMultiplier,行距-->
        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:ellipsize="end"
            android:gravity="center_vertical"
            android:lineSpacingMultiplier="1.2"
            android:layout_marginTop="@dimen/dimen_10"
            android:maxLength="2"
            android:text="TxtView继承了View,它还是Button、EditText两个UI组件类的父类。它的作用是在用户界面上显示文本素。从功能上来看TextView就是个文本编辑器,只不过Android关闭的它的可编辑功能。如果需要一个可编辑的文本框,就要使用到它的子类Editext了,Editext允许用户编辑文本框中的内容。TextView和Editext它俩最大的区别就在于TextView不允许用户编辑文本内容,Editext允许用户编辑文本内容。
    下面咱写几个实例来详细了解一下TextView的。"

            android:textColor="@color/color_188FFF"
            android:textSize="@dimen/text_size_14" />

        <!--background设置背景色-->
        <!--padding内边距(边到可用范围的距离)-->
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@color/color_ff0000"
            android:layout_marginTop="@dimen/dimen_10"
            android:padding="10dp"
            android:text="背景色红色的文本"
            android:textColor="@color/white" />


        <!--带边框的文本-->
        <!--layout_margin外边距(TextView到其他控件的距离)-->
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dimen_10"
            android:background="@drawable/bg_tv_frame_red"
            android:padding="10dp"
            android:text="带着红色边框的文本" />

        <!--带边框的文本背景色渐变-->
        <!--代码可实现文本的渐变-->
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/dimen_10"
            android:background="@drawable/bg_tv_frame_gradient"
            android:padding="10dp"
            android:textColor="@color/white"
            android:text="带着边框和背景色渐变的文本" />

        
    </LinearLayout>

    background设置边框的文件 android:background="@drawable/bg_tv_frame_red"


    <?xml version="1.0" encoding="utf-8"?>
    <shape xmlns:android="http://schemas.android.com/apk/res/android">
        <!--radius四个圆角统一设置,也可以单独对某一个圆角设置。例:topLeftRadius-->
        <corners android:radius="2dp"/>
        <!--边框宽度width、颜色color-->
        <stroke android:width="4px" android:color="@color/color_ff0000" />
    </shape>

    带着边框和背景色渐变 android:background="@drawable/bg_tv_frame_gradient"


    <?xml version="1.0" encoding="utf-8"?>
    <shape xmlns:android="http://schemas.android.com/apk/res/android">
        <!--radius四个圆角统一设置,也可以单独对某一个圆角设置。例:topLeftRadius-->
        <corners android:radius="8dp"/>
        <!--边框宽度width、颜色color-->
        <stroke android:width="1dp" android:color="@color/color_ff0000" />
        <!--渐变的颜色设置开始到结束-->
        <gradient
            android:startColor="@color/color_188FFF"
            android:centerColor="@color/color_FF773D"
            android:endColor="@color/color_ff0000"
            android:type="linear"
            />

    </shape>

    2.在xml中创建,在代码中设置属性




    • 布局文件


    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:layout_margin="@dimen/dimen_20"
       android:orientation="vertical">
       <TextView
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:text="下面是用代码实现效果"
           android:textSize="@dimen/text_size_18"
           android:layout_marginTop="@dimen/dimen_20"
           android:layout_marginBottom="@dimen/dimen_10"
           android:textColor="@color/black"
           android:textStyle="bold" />

       <TextView
           android:id="@+id/tv_flag"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:textColor="@color/color_188FFF"
           android:layout_marginTop="@dimen/dimen_10"
           android:text="给文本加划线"
           android:textSize="@dimen/text_size_18" />

       <TextView
           android:id="@+id/tv_gradient"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:layout_marginTop="@dimen/dimen_10"
           android:textColor="@color/white"
           android:text="文字渐变是不是很神奇"
           android:textSize="@dimen/text_size_18" />

       <TextView
           android:id="@+id/tv_bg"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:layout_marginTop="@dimen/dimen_10"
           android:padding="10dp"
           android:text="设置背景色"
           android:textColor="@color/white"
           android:textSize="@dimen/text_size_18" />

       <TextView
           android:id="@+id/tv_size"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           android:layout_marginTop="@dimen/dimen_10"
           android:textColor="@color/color_ff0000"
           android:text="文字特别大小不一致" />

       <TextView
           android:id="@+id/tv_onclick"
           android:layout_width="match_parent"
           android:layout_marginTop="@dimen/dimen_10"
           android:layout_height="wrap_content"
           android:textSize="@dimen/dimen_20"
           android:text="可点击可长按" />
    </LinearLayout>


    • 运行结果




    • 在代码中实现


            //下划线并加清晰
            tv_flag.getPaint().setFlags(Paint.UNDERLINE_TEXT_FLAG | Paint.ANTI_ALIAS_FLAG);
            tv_flag.getPaint().setAntiAlias(true);//抗锯齿

            int[] colors = {0xff188fff0xffff773D0xffff0000};//颜色的数组
            LinearGradient mLinearGradient = new LinearGradient(000
                    tv_gradient.getPaint().getTextSize(), colors, null, Shader.TileMode.CLAMP);
            tv_gradient.getPaint().setShader(mLinearGradient);
            tv_gradient.invalidate();

            int fillColor = Color.parseColor("#ff0000");//内部填充颜色
            GradientDrawable gd = new GradientDrawable();//创建drawable
            gd.setColor(fillColor);//设置背景色
            gd.setCornerRadius(10);//设置圆角
            tv_bg.setBackground(gd);//设置背景

            Spannable wordtoSpan = new SpannableString(tv_size.getText().toString());
            //setSpan:参数1,设置文字大小;参数2,开始的文字位置;参数3,结束改变文字位置不包含这个位置
            wordtoSpan.setSpan(new AbsoluteSizeSpan(DensityUtil.dip2px(this18)), 02, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            wordtoSpan.setSpan(new AbsoluteSizeSpan(DensityUtil.dip2px(this24)), 25, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            wordtoSpan.setSpan(new AbsoluteSizeSpan(DensityUtil.dip2px(this10)), 5, tv_size.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            tv_size.setText(wordtoSpan);

            //TextView其实也是有点击事件的毕竟它的爸爸Veiew
            tv_onclick.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    MLog.e("这里是点击事件");
                    Toast.makeText(TextViewActivity.this,"这里是点击事件",Toast.LENGTH_SHORT).show();
                }
            });
            tv_onclick.setOnLongClickListener(new View.OnLongClickListener() {
                @Override
                public boolean onLongClick(View v) {
                    MLog.e("这里长按事件");
                    Toast.makeText(TextViewActivity.this,"这里长按事件",Toast.LENGTH_SHORT).show();
                    //true表示事件已消费
                    return true;
                }
            });


    • 运行结果分析



      • TextView的属性在xml中可以使用的大部分在代码中也是可以实现的,看个人喜好怎么去使用。

      • 因TextView继承View,所以可以使用View的方法。如View.OnClickListener()和View.OnLongClickListener()还有去慢慢探索吧。



    3.在代码中创建并设置属性



    • 先看效果图:




    • 下面是实现所用的代码:


      //ll_act_tv布局文件根布局id
      LinearLayout ll_act_tv = findViewById(R.id.ll_act_tv);
      TextView textView = new TextView(this);//创建控件
      textView.setText("蠢代码写的哦");//设置控件内容
      textView.setTextColor(Color.RED);//设置控件颜色
      textView.setTextSize(DensityUtil.dip2px(this20));//设置控件字体大小
      ll_act_tv.addView(textView);

    TextView今天就聊到这里,后面还有它的子类,比较子类也是比较厉害的不可能一文搞定。你学会了吗?嘿嘿嘿


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

    Android-自定义拼图验证码

    废话不多说,先上图: 从效果图开始"临摹" 分析 从上面的效果图中,我们可以很直观的看出一共包含三个元素:背景图、空缺部分、填充部分,需要注意的是: 1. 空缺部分缺失的图片刚好是填充部分 2. 我们把填充部分位置固定在左侧,而随机生成空缺部分在右侧...
    继续阅读 »

    废话不多说,先上图:


    1.gif


    从效果图开始"临摹"


    分析


    从上面的效果图中,我们可以很直观的看出一共包含三个元素:背景图、空缺部分、填充部分,需要注意的是:
    1. 空缺部分缺失的图片刚好是填充部分
    2. 我们把填充部分位置固定在左侧,而随机生成空缺部分在右侧,增加验证难度


    思路



    1. 准备背景图片,通过canvas.drawBitmap() 方法画出背景图

    2. 计算View宽高,随机生成空缺部分的x坐标在(width/3, width)范围,固定填充部分的x左边在(0,width/3)范围内,保证填充部分和空缺部分在初始化时没有重叠。(不严谨,具体数值还要结合空缺部分/填充部分尺寸详细计算,仅提供思路)。

    3. 先随机生成空缺部分,然后根据空缺部分在原来Bitmap上的左边生成一样大小一样形状的图片,用于填充部分。

    4. 然后重写onTouchEvent方法,处理拖动时填充部分的位移,在MotionEvent.ACTION_UP条件下,计算填充部分和空缺部分在画布中的x坐标差值,判断当差值小于阙值 dx 时,则认为通过验证,否则调用 invalidate() 方法重新生成验证码。


    主要代码分析


    这里重写了onMeasure方法,根据我们准备的原图片尺寸设置View宽高,并且重新生成和View一样尺寸的背景图newBgBitmap,统一尺寸以便后面我们对左边的转化。(这里曾经有些地方参照画布尺寸计算,有些地方参照背景图bitmap尺寸计算,导致填充部分和空缺部分没有吻合)。


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int minimumWidth = getSuggestedMinimumWidth();
    /*根据原背景图宽高比设置画布尺寸*/
    width = measureSize(minimumWidth, widthMeasureSpec);
    float scale = width / (float) bgBitmap.getWidth();
    height = (int) (bgBitmap.getHeight() * scale);
    setMeasuredDimension(width, height);

    /*根据画布尺寸生成相同尺寸的背景图*/
    newBgBitmap = clipBitmap(bgBitmap, width, height);
    /*根据新的背景图生成填充部分*/
    srcBitmap = createSmallBitmap(newBgBitmap);
    }


    设置画笔的混合模式,生成一张自定义形状的图片供填充部分使用


        public Bitmap createSmallBitmap(Bitmap var) {
    Bitmap bitmap = Bitmap.createBitmap(shadowSize, shadowSize, Bitmap.Config.ARGB_8888);
    Canvas canvas1 = new Canvas(bitmap);
    canvas1.drawCircle(shadowSize / 2, shadowSize / 2, shadowSize / 2, paintSrc);
    /*设置混合模式*/
    paintSrc.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));

    /*在指定范围随机生成空缺部分坐标,保证空缺部分出现在View右侧*/
    int min = width / 3;
    int max = width - shadowSize / 2 - padding;
    Random random = new Random();
    shadowLeft = random.nextInt(max) % (max - min + 1) + min;
    Rect rect = new Rect(shadowLeft, (height - shadowSize) / 2, shadowSize + shadowLeft, (height + shadowSize) / 2);
    RectF rectF = new RectF(0, 0, shadowSize, shadowSize);
    canvas1.drawBitmap(var, rect, rectF, paintSrc);
    paintSrc.setXfermode(null);
    return bitmap;
    }

    在onDraw()方法中依次画出背景图、空缺部分、填充部分,注意先后顺序(具体细节自行处理,例如阴影、凹凸感等等)


        @Override
    protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    RectF rectF = new RectF(0, 0, width, height);
    /*画背景图*/
    canvas.drawBitmap(newBgBitmap, null, rectF, paintSrc);

    bgPaint.setColor(Color.parseColor("#000000"));
    /*画空缺部分周围阴影*/
    canvas.drawCircle(shadowLeft + shadowSize / 2, height / 2, shadowSize / 2, bgPaint);
    /*画空缺部分*/
    canvas.drawCircle(shadowLeft + shadowSize / 2, height / 2, shadowSize / 2, paintShadow);

    Rect rect = new Rect(srcLeft, (height - shadowSize) / 2, shadowSize + srcLeft, (height + shadowSize) / 2);

    bgPaint.setColor(Color.parseColor("#FFFFFF"));
    /*画填充部分周围阴影*/
    canvas.drawCircle(srcLeft + shadowSize / 2, height / 2, shadowSize / 2, bgPaint);
    /*画填充部分*/
    canvas.drawBitmap(srcBitmap, null, rect, paintSrc);
    }

    草纸代码参考


    随写随发布?


    package com.example.qingfengwei.myapplication;

    import android.content.Context;
    import android.content.res.Resources;
    import android.graphics.Bitmap;
    import android.graphics.BitmapFactory;
    import android.graphics.BlurMaskFilter;
    import android.graphics.Canvas;
    import android.graphics.Color;
    import android.graphics.Matrix;
    import android.graphics.Paint;
    import android.graphics.PorterDuff;
    import android.graphics.PorterDuffXfermode;
    import android.graphics.Rect;
    import android.graphics.RectF;
    import android.util.AttributeSet;
    import android.util.Log;
    import android.view.MotionEvent;
    import android.view.View;
    import android.widget.Toast;

    import java.util.Random;


    public class SlidingVerificationView extends View {

    private Bitmap bgBitmap;
    private Bitmap newBgBitmap;
    private Bitmap srcBitmap;

    private Paint paintShadow;
    private Paint paintSrc;
    private float curX;
    private float lastX;

    private int dx;
    private int shadowSize = dp2px(60);
    private int padding = dp2px(40);
    private int shadowLeft;
    private int srcLeft = padding;

    private int width, height;

    private Paint bgPaint;

    private OnVerifyListener listener;

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

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

    public SlidingVerificationView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    paintShadow = new Paint();
    paintShadow.setAntiAlias(true);
    paintShadow.setColor(Color.parseColor("#AA000000"));


    paintSrc = new Paint();
    paintSrc.setAntiAlias(true);
    paintSrc.setFilterBitmap(true);
    paintSrc.setStyle(Paint.Style.FILL_AND_STROKE);
    paintSrc.setColor(Color.WHITE);

    bgPaint = new Paint();
    bgPaint.setMaskFilter(new BlurMaskFilter(5, BlurMaskFilter.Blur.OUTER));
    bgPaint.setAntiAlias(true);
    bgPaint.setStyle(Paint.Style.FILL);

    bgBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.syzt);
    }

    public void setVerifyListener(OnVerifyListener listener) {
    this.listener = listener;
    }

    public Bitmap clipBitmap(Bitmap bm, int newWidth, int newHeight) {
    int width = bm.getWidth();
    int height = bm.getHeight();
    float scaleWidth = ((float) newWidth) / width;
    float scaleHeight = ((float) newHeight) / height;
    Matrix matrix = new Matrix();
    matrix.postScale(scaleWidth, scaleHeight);
    return Bitmap.createBitmap(bm, 0, 0, width, height, matrix, true);
    }


    public Bitmap createSmallBitmap(Bitmap var) {
    Bitmap bitmap = Bitmap.createBitmap(shadowSize, shadowSize, Bitmap.Config.ARGB_8888);
    Canvas canvas1 = new Canvas(bitmap);
    canvas1.drawCircle(shadowSize / 2, shadowSize / 2, shadowSize / 2, paintSrc);
    /*设置混合模式*/
    paintSrc.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));


    /*在指定范围随机生成空缺部分坐标,保证空缺部分出现在View右侧*/
    int min = width / 3;
    int max = width - shadowSize / 2 - padding;
    Random random = new Random();
    shadowLeft = random.nextInt(max) % (max - min + 1) + min;
    Rect rect = new Rect(shadowLeft, (height - shadowSize) / 2, shadowSize + shadowLeft, (height + shadowSize) / 2);
    RectF rectF = new RectF(0, 0, shadowSize, shadowSize);
    canvas1.drawBitmap(var, rect, rectF, paintSrc);
    paintSrc.setXfermode(null);
    return bitmap;
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
    curX = event.getRawX();
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
    lastX = event.getRawX();
    break;
    case MotionEvent.ACTION_MOVE:
    dx = (int) (curX - lastX);
    srcLeft = dx + padding;
    invalidate();
    break;
    case MotionEvent.ACTION_UP:

    boolean isSuccess = Math.abs(srcLeft - shadowLeft) < 8;

    if (isSuccess) {
    Toast.makeText(getContext(), "验证成功!", Toast.LENGTH_SHORT).show();
    Log.d("w", "check success!");
    } else {
    Toast.makeText(getContext(), "验证失败!", Toast.LENGTH_SHORT).show();
    Log.d("w", "check fail!");
    srcBitmap = createSmallBitmap(newBgBitmap);
    srcLeft = padding;
    invalidate();
    }

    if (listener != null) {
    listener.onResult(isSuccess);
    }
    break;
    }

    return true;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int minimumWidth = getSuggestedMinimumWidth();
    /*根据原背景图宽高比设置画布尺寸*/
    width = measureSize(minimumWidth, widthMeasureSpec);
    float scale = width / (float) bgBitmap.getWidth();
    height = (int) (bgBitmap.getHeight() * scale);
    setMeasuredDimension(width, height);

    /*根据画布尺寸生成相同尺寸的背景图*/
    newBgBitmap = clipBitmap(bgBitmap, width, height);
    /*根据新的背景图生成填充部分*/
    srcBitmap = createSmallBitmap(newBgBitmap);

    }

    private int measureSize(int defaultSize, int measureSpec) {
    int mode = MeasureSpec.getMode(measureSpec);
    int size = MeasureSpec.getSize(measureSpec);
    int result = defaultSize;
    switch (mode) {
    case MeasureSpec.UNSPECIFIED:
    result = defaultSize;
    break;
    case MeasureSpec.AT_MOST:
    case MeasureSpec.EXACTLY:
    result = size;
    break;
    }
    return result;
    }

    @Override
    protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    RectF rectF = new RectF(0, 0, width, height);
    /*画背景图*/
    canvas.drawBitmap(newBgBitmap, null, rectF, paintSrc);

    bgPaint.setColor(Color.parseColor("#000000"));
    /*画空缺部分周围阴影*/
    canvas.drawCircle(shadowLeft + shadowSize / 2, height / 2, shadowSize / 2, bgPaint);
    /*画空缺部分*/
    canvas.drawCircle(shadowLeft + shadowSize / 2, height / 2, shadowSize / 2, paintShadow);

    Rect rect = new Rect(srcLeft, (height - shadowSize) / 2, shadowSize + srcLeft, (height + shadowSize) / 2);

    bgPaint.setColor(Color.parseColor("#FFFFFF"));
    /*画填充部分周围阴影*/
    canvas.drawCircle(srcLeft + shadowSize / 2, height / 2, shadowSize / 2, bgPaint);
    /*画填充部分*/
    canvas.drawBitmap(srcBitmap, null, rect, paintSrc);
    }

    public static int dp2px(float dp) {
    float density = Resources.getSystem().getDisplayMetrics().density;
    return (int) (density * dp + 0.5f);
    }
    }



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

    手把手教你搭建AndroidJenkins环境及一键自动构建打包

    前言: 之前看到后端的同事,在服务器上刷刷的敲命令觉得很酷,给我的感觉是Linux很难上手。自从公司给我配了台服务器后,操作了一下,感觉和想的不是那么一回事,所以还是得多动手,不要空想。 正文开始: 看到网上说Linux安装软件基本是用yum,所以这边也是...
    继续阅读 »

    前言: 之前看到后端的同事,在服务器上刷刷的敲命令觉得很酷,给我的感觉是Linux很难上手。自从公司给我配了台服务器后,操作了一下,感觉和想的不是那么一回事,所以还是得多动手,不要空想。


    正文开始:


    看到网上说Linux安装软件基本是用yum,所以这边也是使用yum,从0-1搭建Linux Jenkins,实现Android 一键自动化构建部署打包。


    步骤一:安装JDK环境


    1.查看jdk的版本:


    # yum -y list java*


    2.选择要安装的JDK版本(带devel是JDK)


    # yum install -y java-1.8.0-openjdk-devel.x86_64


    3.安装完后,查看是否安装成功(看到版本号表示安装成功,不需要像Windows配置java_home环境变量)


    # java -version


    image.png


    步骤二:安装Jenkins


    1.yum安装Jenkins


    # yum install jenkins


    如果没有Jenkins的repos,按照官网提示再操作:


    # sudo wget -O /etc/yum.repos.d/jenkins.repo  https://pkg.jenkins.io/redhat-stable/jenkins.repo
    # sudo rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io.key

    2.修改Jenkins默认的配置(怕端口冲突,改成你想要名字和端口):


    # cd /etc/sysconfig/
    # vi jenkins

    image.png


    3.启动Jenkins


    # service jenkins start


    按照提示,到指定目录复制密码,下一步是选择安装默认的插件(推荐),注册一个账户,最后就会来到这个界面,表示Jenkins安装成功。


    image.png


    步骤三:安装gradle


    1.安装gradle gradle.org/releases/


    image.png 拿到安装包的下载链接: 创建一个安装的目录,我在新建了一个文件夹/opt/gradle/下 下载


    # cd /opt
    # mkdir gradle
    # cd /opt/gradle
    # wget https://downloads.gradle-dn.com/distributions/gradle-6.5-all.zip
    # unzip /opt/gradle gradle-6.5-all.zip

    步骤四:command tools 下载



    1. Command line tools only linux版本


    developer.android.com/studio


    # cd /opt
    # mkdir android
    # cd /opt/android
    # wget https://dl.google.com/android/repository/commandlinetools-linux-7302050_latest.zip
    # unzip /opt/android commandlinetools-linux-7302050_latest.zip

    步骤五:配置gradle和Android SDK 环境变量


    # cd /etc
    # /etc/profile

    image.png 在最后追加环境变量,保存


    export ANDROID_HOME="/opt/android"
    export GRADLE_HOME="/opt/gradle"
    export PATH="$ANDROID_HOME/tools:$ANDROID_HOME/tools/bin:$ANDROID_HOME/platform-tools:$GRADLE_HOME/gradle-4.9/bin:$PATH"


    刷新当前的shell环境


    # source /etc/profile


    配置gradle全局代理


    def repoConfig = {
    all { ArtifactRepository repo ->
    if (repo instanceof MavenArtifactRepository) {
    def url = repo.url.toString()
    if (url.contains('repo1.maven.org/maven2') || url.contains('jcenter.bintray.com')) {
    println "gradle 初始化: (${repo.name}: ${repo.url}) 移除"
    remove repo
    }
    }
    }
    maven { url 'https://maven.aliyun.com/repository/central' }
    maven { url 'https://maven.aliyun.com/repository/public' }
    maven { url 'https://maven.aliyun.com/repository/jcenter' }
    maven { url 'https://maven.aliyun.com/repository/google' }
    maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
    mavenLocal()
    mavenCentral()
    }

    allprojects {
    buildscript {
    repositories repoConfig
    }

    repositories repoConfig
    }

    这里因为项目里面配了一个google的maven地址,导致一直gradle构建超半个小时,排查了很久。


    项目builid.gradle里面千万不要配google的maven地址
    项目builid.gradle里面千万不要配google的maven地址
    项目builid.gradle里面千万不要配google的maven地址
    就是他,浪费了我几天时间排查,正常用
    google()
    jcenter()
    就可以了

    image.png


    查看gradle是否配置成功
    # gradle -version

    image.png


    步骤六:sdkmanager方式安装SDK


    因为是sdkmanager访问的是google,所以配置了谷歌代理才可以访问,当时折腾了很久才找到的dl.google.com代理


    # cd /etc/
    # vi hosts

    203.208.40.36 dl.google.com


    image.png


    # cd /opt/android/cmdline-tools


    在里面创建一个latest,将文件夹里面文件全部放到latest里面。


    # cd /opt/android/cmdline-tools/latest/bin


    查看版本
    # ./sdkmanager --list
    安装想要的SDK版本
    # ./sdkmanager --install "platforms;android-29"
    # ./sdkmanager --install "build-tools;29.0.2"

    步骤六:安装git


    1.下载最新的git github.com/git/git/rel…


    2.我通过xftp copy在/root目录下,解压


    安装依赖库
    yum install curl-devel expat-devel gettext-devel openssl-devel zlib-devel gcc perl-ExtUtils-MakeMaker

    # cd /root/git-2.32.0
    make prefix=/usr/local all
    make prefix=/usr/local install

    最后测试下git clone 你的git地址试试能不能拉代码

    步骤七:最后一步,Jenkins上创建项目


    1.在plugins.jenkins.io/ 下载插件


    构建化插件:extended-choice-parameter


    image.png


    点击高级-上传插件-等待安装成功即可


    image.png


    2.新建任务


    image.png


    构建化参数添加参考


    image.png


    我这个教程,Jenkins里面是零配置,直接执行shell脚本,很方便


    具体的脚本自己根据实际需求来编写,我这里就不举例了。


    gradle clean
    gradle assembleRelease--stacktrace -info

    结语: 搭建Jenkins的服务,我深有体会,由于没有服务器,首先在自己电脑搭建了一套Windows的Jenkins,也是遇到了各种疑难问题,最后还是解决了。然后,在Linux上部署这套Jenkins环境,就变得很轻松。这次最大的收获就是熟悉了Linux的操作,实现了Android 一键自动打包上传到服务器,减少了打包,上传服务器繁琐的操作。


    如果本文对你有帮助,请帮忙对个赞或者留言,你的鼓励是我分享的动力。




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

    Android agp 对 R 文件内联支持

    agp
    本文作者:郑超 背景 最近团队升级静态代码检测能力,依赖的相关编译检测能力需要用到较新的agp,而且目前云音乐agp版本用的是 3.5.0,对比现在 4.2.0 有较大差距,所以我们集中对 agp 进行了一次升级。在升级前通过官方文档,发现在 agp3...
    继续阅读 »

    本文作者:郑超



    背景


    最近团队升级静态代码检测能力,依赖的相关编译检测能力需要用到较新的agp,而且目前云音乐agp版本用的是 3.5.0,对比现在 4.2.0 有较大差距,所以我们集中对 agp 进行了一次升级。在升级前通过官方文档,发现在 agp3.6.0 和 4.1.0 版本分别对 R 文件的处理方式进行了相应的升级,具体升级如下。


    agp 3.6.0 变更


    Simplified R class generation


    The Android Gradle plugin simplifies the compile classpath by generating only one R class for each library module in your project and sharing those R classes with other module dependencies. This optimization should result in faster builds, but it requires that you keep the following in mind:



    • Because the compiler shares R classes with upstream module dependencies, it’s important that each module in your project uses a unique package name.

    • The visibility of a library's R class to other project dependencies is determined by the configuration used to include the library as a dependency. For example, if Library A includes Library B as an 'api' dependency, Library A and other libraries that depend on Library A have access to Library B's R class. However, other libraries might not have access to Library B's R class If Library A uses the implementation dependency configuration. To learn more, read about dependency configurations.


    从字面意思理解 agp3.6.0 简化了 R 的生成过程,每一个 module 直接生成 R.class (在 3.6.0 之前 R.class 生成的过程是为每个 module 先生成 R.java -> 再通过 javac 生成 R.class ,现在是省去了生成 R.java 和通过 javac 生成 R.class)


    现在我们来验证一下这个结果,建一个工程,工程中会建立 android library module。分别用 agp3.5.0 和 agp3.6.0 编译,然后看构建产物。


    agp 3.5.0 构建产物如下:


    image


    agp 3.6.0 构建产物如下:


    image


    从构建产物上来看也验证了这个结论,agp 3.5.0 到 3.6.0 通过减少 R 生成的中间过程,来提升 R 的生成效率(先生成 R.java 再通过 javac 生成 R.class 变为直接生成 R.class);


    agp 4.1.0升级如下:


    App size significantly reduced for apps using code shrinking


    Starting with this release, fields from R classes are no longer kept by default, which may result in significant APK size savings for apps that enable code shrinking. This should not result in a behavior change unless you are accessing R classes by reflection, in which case it is necessary to add keep rules for those R classes.


    从标题看 apk 包体积有显著减少(这个太有吸引力了),通过下面的描述,大致意思是不再保留 R 的 keep 规则,也就是 app 中不再包括 R 文件?(要不怎么减少包体积的)


    在分析这个结果之前先介绍下 apk 中,R 文件冗余的问题;


    R 文件冗余问题


    android 从 ADT 14 开始为了解决多个 library 中 R 文件中 id 冲突,所以将 Library 中的 R 的改成 static 的非常量属性。


    在 apk 打包的过程中,module 中的 R 文件采用对依赖库的R进行累计叠加的方式生成。如果我们的 app 架构如下:


    image


    编译打包时每个模块生成的 R 文件如下:



    1. R_lib1 = R_lib1;

    2. R_lib2 = R_lib2;

    3. R_lib3 = R_lib3;

    4. R_biz1 = R_lib1 + R_lib2 + R_lib3 + R_biz1(biz1本身的R)

    5. R_biz2 = R_lib2 + R_lib3 + R_biz2(biz2本身的R)

    6. R_app = R_lib1 + R_lib2 + R_lib3 + R_biz1 + R_biz2 + R_app(app本身R)


    在最终打成 apk 时,除了 R_app(因为 app 中的 R 是常量,在 javac 阶段 R 引用就会被替换成常量,所以打 release 混淆时,app 中的 R 文件会被 shrink 掉),其余的 R 文件全部都会打进 apk 包中。这就是 apk 中 R 文件冗余的由来。而且如果项目依赖层次越多,上层的业务组件越多,将会导致 apk 中的 R 文件将急剧的膨胀。


    R 文件内联(解决冗余问题)


    系统导致的冗余问题,总不会难住聪明的程序员。在业内目前已经有一些R文件内联的解决方案。大致思路如下:



    由于 R_app 是包括了所有依赖的的 R,所以可以自定义一个 transform 将所有 library module 中 R 引用都改成对 R_app 中的属性引用,然后删除所有依赖库中的 R 文件。这样在 app 中就只有一个顶层 R 文件。(这种做法不是非常彻底,在 apk 中仍然保留了一个顶层的 R,更彻底的可以将所有代码中对 R 的引用都替换成常量,并在 apk 中删除顶层的 R )



    agp 4.1.0 R 文件内联


    首先我们分别用 agp 4.1.0 和 agp 3.6.0 构建 apk 进行一个对比,从最终的产物来确认下是否做了 R 文件内联这件事。 测试工程做了一些便于分析的配置,配置如下:



    1. 开启 proguard


    buildTypes {
    release {
    minifyEnabled true // 打开
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
    }


    1. 关闭混淆,仅保留压缩和优化(避免混淆打开,带来的识别问题)


    // proguard-rules.pro中配置
    -dontobfuscate

    构建 release 包。 先看下 agp 3.6.0 生成的 apk:


    image


    从图中可以看到 bizlib module 中会有 R 文件,查看 SecondActivity 的 byte code ,会发现内部有对 R 文件的引用。


    接着再来看 agp 4.1.0 生成的 apk:


    image


    可以看到,bizlib module 中已经没有 R 文件,并且查看 SecondActivity 的 byte code ,会发现内部的引用已经变成了一个常量。


    由此可以确定,agp 4.1.0 是做了对 R 文件的内联,并且做的很彻底,不仅删除了冗余的 R 文件,并且还把所有对 R 文件的引用都改成了常量。


    具体分析


    现在我们来具体分析下 agp 4.1.0 是如何做到 R 内联的,首先我们大致分析下,要对 R 做内联,基本可以猜想到是在 class 到 dex 这个过程中做的。确定了大致阶段,那接下看能不能从构建产物来缩小相应的范围,最好能精确到具体的 task。(题外话:分析编译相关问题一般四板斧:1. 先从 app 的构建产物里面分析相应的结果;2.涉及到有依赖关系分析的可以将所有 task 的输入输出全部打印出来;3. 1、2满足不了时,会考虑去看相应的源码;4. 最后的大招就是调试编译过程;)


    首先我们看下构建产物里面的 dex,如下图:


    image


    接下来在 app module 中增加所有 task 输入输出打印的 gradle 脚本来辅助分析,相关脚本如下:


    gradle.taskGraph.afterTask { task ->
    try {
    println("---- task name:" + task.name)
    println("-------- inputs:")
    task.inputs.files.each { it ->
    println(it.absolutePath)
    }
    println("-------- outputs:")
    task.outputs.files.each { it ->
    println(it.absolutePath)
    }
    } catch (Exception e) {

    }
    }

    minifyReleaseWithR8 相应的输入输出如下:


    image


    从图中可以看出,输入有整个 app 的 R 文件的集合(R.jar),所以基本明确 R 的内联就是在 minifyReleaseWithR8 task 中处理的。


    接下来我们就具体分析下这个 task。 具体的逻辑在 R8Task.kt 里面.


    创建 minifyReleaseWithR8 task 代码如下:


    class CreationAction(
    creationConfig: BaseCreationConfig,
    isTestApplication: Boolean = false
    ) : ProguardConfigurableTask.CreationAction<R8Task, BaseCreationConfig>(creationConfig, isTestApplication) {
    override val type = R8Task::class.java
    // 创建 minifyReleaseWithR8 task
    override val name = computeTaskName("minify", "WithR8")
    .....
    }

    task 执行过程如下(由于代码过多,下面仅贴出部分关键节点):


        // 1. 第一步,task 具体执行
    override fun doTaskAction() {
    ......
    // 执行 shrink 操作
    shrink(
    bootClasspath = bootClasspath.toList(),
    minSdkVersion = minSdkVersion.get(),
    ......
    )
    }

    // 2. 第二步,调用 shrink 方法,主要做一些输入参数和配置项目的准备
    companion object {
    fun shrink(
    bootClasspath: List<File>,
    ......
    )
    {
    ......
    // 调用 r8Tool.kt 中的顶层方法,runR8
    runR8(
    filterMissingFiles(classes, logger),
    output.toPath(),
    ......
    )
    }
    // 3. 第三步,调用 R8 工具类,执行混淆、优化、脱糖、class to dex 等一系列操作
    fun runR8(
    inputClasses: Collection<Path>,
    ......
    )
    {
    ......
    ClassFileProviderFactory(libraries).use { libraryClasses ->
    ClassFileProviderFactory(classpath).use { classpathClasses ->
    r8CommandBuilder.addLibraryResourceProvider(libraryClasses.orderedProvider)
    r8CommandBuilder.addClasspathResourceProvider(classpathClasses.orderedProvider)
    // 调用 R8 工具类中的run方法
    R8.run(r8CommandBuilder.build())
    }
    }
    }

    至此可以知道实际上 agp 4.1.0 中是通过 R8 来做到 R 文件的内联的。那 R8 是如果做到的呢?这里简要描述下,不再做具体代码的分析:



    R8 从能力上是包括了 Proguard 和 D8(java脱糖、dx、multidex),也就是从 class 到 dex 的过程,并在这个过程中做了脱糖、Proguard 及 multidex 等事情。在 R8 对代码做 shrink 和 optimize 时会将代码中对常量的引用替换成常量值。这样代码中将不会有对 R 文件的引用,这样在 shrink 时就会将 R 文件删除。



    当然要达到这个效果 agp 在 4.1.0 版本里面对默认的 keep 规则也要做一些调整,4.1.0 里面删除了默认对 R 的 keep 规则,相应的规则如下:



    -keepclassmembers class **.R$* {
    public static <fields>;
    }


    总结



    1. 从 agp 对 R 文件的处理历史来看,android 编译团队一直在对R文件的生成过程不断做优化,并在 agp 4.1.0 版本中彻底解决了 R 文件冗余的问题。


    2. 编译相关问题分析思路:



      1. 先从 app 的构建产物里面分析相应的结果;

      2. 涉及到有依赖关系分析的可以将所有 task 的输入输出全部打印出来;

      3. 1、2满足不了时,会考虑去看相应的源码;

      4. 最后的大招就是调试编译过程;


    3. 从云音乐 app 这次 agp 升级的效果来看,app 的体积降低了接近 7M,编译速度也有很大的提升,特别是 release 速度快了 10 分钟+(task 合并),整体收益还是比较可观的。



    文章中使用的测试工程


    参考资料



    1. Shrink, obfuscate, and optimize your app

    2. r8

    3. Android Gradle plugin release notes



    本文发布自 网易云音乐大前端团队,文章未经授权禁止任何形式的转载。我们常年招收前端、iOS、Android,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!


    收起阅读 »

    深入解析dio(一) Socket 编程实现本地多端群聊

    深入解析dio(一) Socket 编程实现本地多端群聊引言无论你是否用过, wendux 大佬开源的 dio 项目,应该是目前 Flutter 中最 🔥 的网络请求库,在 github 上接近 1W 的 star。但其...
    继续阅读 »

    深入解析dio(一) Socket 编程实现本地多端群聊

    引言

    无论你是否用过, wendux 大佬开源的 dio 项目,应该是目前 Flutter 中最 🔥 的网络请求库,在 github 上接近 1W 的 star。

    但其实 Dart 中已经有 dart:io 库为我们提供了网络服务,为何 Dio 又如此受到开发者青睐?背后有哪些优秀的设计值得我们学习?

    这个系列预计会花 6 期左右从计算机网络原理,到 Dart 中的网络编程,最后再到 Dio 的架构设计,通过原理分析 + 练习的方式,带大家由浅入深的掌握 Dart 中的网络编程与 Dio 库的设计。

    本期,我们会通过编写一个简单的本地群聊服务一起学习计算机网络基础知识与 Dart 中的 Socket 编程


    Socket 是什么

    想要了解 Socket 是什么,需要先复习一下网络基础。

    无论微信聊天,观看视频或者打开网页,当我们通过网络进行一次数据传输时。数据根据网络协议进行传输, 在 TCP/IP 协议中,经历如下的流转:

    image.png

    TCP/IP 定义了四层结构,每一层都是为了完成一种功能,为了完成这些功能,需要遵循一些规则,这些规则就是协议,每一层都定义了一些协议。

    • 应用层

    应用层决定了向用户提供应用服务时通信的活动。TCP/IP 协议族内预存了各类通用的应用服务。比如,FTP(FileTransfer Protocol,文件传输协议)和 DNS(Domain Name System,域名系统)服务就是其中两类。HTTP 协议也处于该层。

    • 传输层

    传输层对上层应用层,提供处于网络连接中的两台计算机之间端到端的数据传输。在传输层有两个性质不同的协议:TCP(Transmission ControlProtocol,传输控制协议)和UDP(User Data Protocol,用户数据报协议)。

    • 网络层(又名网络互连层)

    网络层用来处理在网络上流动的数据包。数据包是网络传输的最小数据单位。该层规定了通过怎样的路径(所谓的传输路线)到达对方计算机,并把数据包传送给对方。与对方计算机之间通过多台计算机或网络设备进行传输时,网络层所起的作用就是在众多的选项内选择一条传输路线。

    • 网络访问层(又名链路层)

    用来处理连接网络的硬件部分。包括控制操作系统、硬件的设备驱动、NIC(Network Interface Card,网络适配器,即网卡),及光纤等物理可见部分(还包括连接器等一切传输媒介)。硬件上的范畴均在链路层的作用范围之内。

    今天的主角 Socket 是应用层 与 TCP/IP 协议族通信的中间软件抽象层,表现为一个封装了 TCP / IP协议族 的编程接口(API)

    image.png

    为什么我们一开始要了解 Socket 编程,因为比起直接使用封装好的网络接口,Socket 能让我们更接近接近网络的本质,同时不用关心底层链路的细节。


    如何使用 Dart 中的 Socket

    dart:io 库中提供了两个类,第一个是 Socket,我们可以用它作为客户端与服务器建立连接。 第二个是 ServerSocket,我们将使用它创建一个服务器,并与客户端进行连接。

    1、Socket 客户端

    本系列代码均上传,可直接运行:io_practice/socket_study

    Socket 类中有一个静态方法 connect(host, int port) 。第一个参数 host 可以是一个域名或者 IP 的 String,也可以是 InternetAddress 对象。

    connect 返回一个 Future<Socket> 对象,当 socket 与 host 完成连接时 Future 对象回调。

    // socket_pratice1.dart
    void main() {
    Socket.connect("www.baidu.com", 80).then((socket) {
    print('Connected to: '
    '${socket.remoteAddress.address}:${socket.remotePort}');
    socket.destroy();
    });
    }
    复制代码

    这个 case 中,我们通过 80 端口(为 HTTP 协议开放)与 http://www.baidu.com 连接。连接到服务器之后,打印出连接的 IP 地址和端口,最后通过 socket.destroy() 关闭连接。在命令行中 执行 dart socket_pratice1.dart 可以看到如下输出:

    ➜  socket_study dart socket_pratice1.dart 
    socket_pratice2.dart: Warning: Interpreting this as package URI, 'package:io_pratice/socket_study/socket_pratice2.dart'.
    Connected to: 220.181.38.149:80
    复制代码

    通过简单的函数调用,Dart 为我们完成了 http://www.baidu.com 的 IP 查找与 TCP 建立连接,我们只需要等待即可。 在连接建立之后,我们可以和服务端进行数据交互,为此我们需要做两件事。

    1、发起请求 2、响应接受数据

    对应 Socket 中提供的两个方法 Socket.write(String data) 和 Socket.listen(void onData(data)) 。

    // socket_pratice2.dart
    void main() {
    String indexRequest = 'GET / HTTP/1.1\nConnection: close\n\n';

    //与百度通过 80 端口连接
    Socket.connect("www.baidu.com", 80).then((socket) {
    print('Connected to: '
    '${socket.remoteAddress.address}:${socket.remotePort}');

    //监听 socket 的数据返回
    socket.listen((data) {
    print(new String.fromCharCodes(data).trim());
    }, onDone: () {
    print("Done");
    socket.destroy();
    });

    //发送数据
    socket.write(indexRequest);
    });
    }
    复制代码

    运行这段代码可以看到 HTTP/1.1 请求头,以及页面数据。这是学习 web 协议很好的一个工具,我们还可以看到设 cookie 等值。(一般不用这种方式连接 HTTP 服务器,Dart 中提供了 HttpClient 类,提供更多能力)

    ➜  socket_study dart socket_pratice2.dart 
    socket_pratice2.dart: Warning: Interpreting this as package URI, 'package:io_pratice/socket_study/socket_pratice2.dart'.
    Connected to: 220.181.38.150:80
    HTTP/1.1 200 OK
    Accept-Ranges: bytes
    Cache-Control: no-cache
    Content-Length: 14615
    Content-Type: text/html
    ...
    ...
    (headers and HTML code)
    ...
    </script></body></html>
    Done
    复制代码

    2、ServerSocket

    使用 Socket 可以很容易的与服务器连接,同样我们可以使用 ServerSocket 对象创建一个可以处理客户端请求的服务器。 首先我们需要绑定到一个特定的端口并进行监听,使用 ServerSocket.bind(address,int port) 方法即可。这个方法会返回 Future<ServerSocket> 对象,在绑定成功后返回 ServerSocket 对象。之后 ServerSocket.listen(void onData(Socket event)) 方法注册回调,便可以得到客户端连接的 Socket 对象。注意,端口号需要大于 1024 (保留范围)。

    // serversocket_pratice1.dart
    void main() {
    ServerSocket.bind(InternetAddress.anyIPv4, 4567)
    .then((ServerSocket server) {
    server.listen(handleClient);
    });
    }

    void handleClient(Socket client) {
    print('Connection from '
    '${client.remoteAddress.address}:${client.remotePort}');
    client.write("Hello from simple server!\n");
    client.close();
    }
    复制代码

    与客户端不同的是,在 ServerSocket.listen 中我们监听的不是二进制数据,而是客户端连接。 当客户端发起连接时,我们可以得到一个表示客户端连接的 Socket 对象。作为参数调用 handleClient(Socket client) 函数。通过这个 Socket 对象,我们可以获取到客户端的 IP 端口等信息,并且可以与其通信。运行这个程序后,我们需要一个客户端连接服务器。可以将上一个案例中 conect 的地址改为 127.0.0.0.1,端口改为 4567,或者使用 telnet 作为客户端发起。

    运行服务端程序:

    ➜  socket_study dart serversocket_pratice1.dart 
    serversocket_pratice1.dart: Warning: Interpreting this as package URI, 'package:io_pratice/socket_study/serversocket_pratice1.dart'.
    Connection from 127.0.0.1:54555 // 客户端连接之后打印其 ip 与端口
    复制代码

    客户端使用 telnet 请求:

    ➜  io_pratice telnet localhost 4567
    Trying 127.0.0.1...
    Connected to localhost.
    Escape character is '^]'.
    Hello from simple server! // 来自服务端的消息
    Connection closed by foreign host.
    复制代码

    即使客户端关闭连接,服务器程序仍然不会退出,继续等待下一个连接,Dart 已经为我们处理好了一切。

    实战:本地群聊服务

    1、聊天服务器

    有了上面的实践,我们可以尝试编写一个简单的群聊服务。当某个客户端发送消息时,其他所有连接的客户端都可以收到这条消息,并且能优雅的处理错误和断开连接。

    image.png

    如图,我们的三个客户端与服务器保持连接,当其中一个发送消息时,由服务端将消息分发给其他连接者。 所以我们创建一个集合来存储每一个客户端连接对象

    List<ChatClient> clients = [];
    复制代码

    每一个 ChatClient 表示一个连接,我们通过对 Socket 进行简单的封装,提供基本的消息监听,退出与异常处理:

    class ChatClient {
    Socket _socket;
    String _address;
    int _port;

    ChatClient(Socket s){
    _socket = s;
    _address = _socket.remoteAddress.address;
    _port = _socket.remotePort;

    _socket.listen(messageHandler,
    onError: errorHandler,
    onDone: finishedHandler);
    }

    void messageHandler(List data){
    String message = new String.fromCharCodes(data).trim();
    // 接收到客户端的套接字之后进行消息分发
    distributeMessage(this, '${_address}:${_port} Message: $message');
    }

    void errorHandler(error){
    print('${_address}:${_port} Error: $error');
    // 从保存过的 Client 中移除
    removeClient(this);
    _socket.close();
    }

    void finishedHandler() {
    print('${_address}:${_port} Disconnected');
    removeClient(this);
    _socket.close();
    }

    void write(String message){
    _socket.write(message);
    }
    }
    复制代码

    当服务端接受到某个客户端发送的消息时,需要转发给聊天室的其他客户端。

    image.png

    我们通过 messageHandler 中的 distributeMessage 进行消息分发:

    ...
    void distributeMessage(ChatClient client, String message){
    for (ChatClient c in clients) {
    if (c != client){
    c.write(message + "\n");
    }
    }
    }
    ...
    复制代码

    最后我们只需要监听每一个客户端的连接,将其添加至 clients 集合中即可:

    // chatroom.dart

    ServerSocket server;

    void main() {
    ServerSocket.bind(InternetAddress.ANY_IP_V4, 4567)
    .then((ServerSocket socket) {
    server = socket;
    server.listen((client) {
    handleConnection(client);
    });
    });
    }

    void handleConnection(Socket client){
    print('Connection from '
    '${client.remoteAddress.address}:${client.remotePort}');

    clients.add(new ChatClient(client));

    client.write("Welcome to dart-chat! "
    "There are ${clients.length - 1} other clients\n");
    }
    复制代码

    直接运行程序

    ➜ dart chatroom.dart
    复制代码

    使用 telnet 测试服务器连接:

    ➜  socket_study telnet localhost 4567 
    Trying 127.0.0.1...
    Connected to localhost.
    Escape character is '^]'.
    Welcome to dart-chat! There are 0 other clients
    复制代码

    2、聊天客户端

    聊天客户端会简单很多,他只需要连接到服务器并接受消息;以及读取用户的输入信息并将其发送至客户端的方法。

    前面我们已经实践过如何从服务器接收数据,所以我们只需实现发送消息即可。

    通过 dart:io 中的 stdin 能帮助我们轻松的读取键盘输入:

    // chatclient.dart

    Socket socket;

    void main() {
    Socket.connect("localhost", 4567)
    .then((Socket sock) {
    socket = sock;
    socket.listen(dataHandler,
    onError: errorHandler,
    onDone: doneHandler,
    cancelOnError: false);
    })
    .catchError((AsyncError e) {
    print("Unable to connect: $e");
    exit(1);
    });

    // 监听键盘输入,将数据发送至服务端
    stdin.listen((data) =>
    socket.write(
    new String.fromCharCodes(data).trim() + '\n'));
    }

    void dataHandler(data){
    print(new String.fromCharCodes(data).trim());
    }

    void errorHandler(error, StackTrace trace){
    print(error);
    }

    void doneHandler(){
    socket.destroy();
    exit(0);
    }
    复制代码

    之后运行服务器,并通过多个命令行运行多个客户端程序。你可以在某个客户端中输入消息,之后在其他客户端接收到消息。

    image.png

    如果你有多个设备,也可以通过 Socket.connect(host, int port) 与服务器进行连接,当然这需要你提供每个设备的 IP 地址,这该如何做到?下一期我会通过 UDP 与组播协议进一步完善群聊服务。

    收起阅读 »