注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

iOS开发制作同时支持armv7,armv7s,arm64,i386,x86_64的静态库.a

iOS
一、概要平时项目开发中,可能使用第三方提供的静态库.a,如果.a提供方技术不成熟,使用的时候就会出现问题,例如:在真机上编译报错:No architectures to compile for (ONLY_ACTIVE_ARCH=YES, active arc...
继续阅读 »

一、概要

平时项目开发中,可能使用第三方提供的静态库.a,如果.a提供方技术不成熟,使用的时候就会出现问题,例如:

在真机上编译报错:No architectures to compile for (ONLY_ACTIVE_ARCH=YES, active arch=x86_64, VALID_ARCHS=i386).

在模拟器上编译报错:No architectures to compile for (ONLY_ACTIVE_ARCH=YES, active arch=armv7s, VALID_ARCHS=armv7 armv6).

要解决以上问题,就要了解一下Apple移动设备处理器指令集相关的一些细节知识。

 

二、几个重要概念

1、ARM

ARM处理器,特点是体积小、低功耗、低成本、高性能,所以几乎所有手机处理器都基于ARM,在嵌入式系统中应用广泛。

 

2、ARM处理器指令集

armv6|armv7|armv7s|arm64都是ARM处理器的指令集,这些指令集都是向下兼容的,例如armv7指令集兼容armv6,只是使用armv6的时候无法发挥出其性能,无法使用armv7的新特性,从而会导致程序执行效率没那么高。

还有两个我们也很熟悉的指令集:i386|x86_64 是Mac处理器的指令集,i386是针对intel通用微处理器32架构的。x86_64是针对x86架构的64位处理器。所以当使用iOS模拟器的时候会遇到i386|x86_64,iOS模拟器没有arm指令集。

 

3、目前iOS移动设备指令集

arm64:iPhone5S| iPad Air| iPad mini2(iPad mini with Retina Display)

armv7s:iPhone5|iPhone5C|iPad4(iPad with Retina Display)

armv7:iPhone3GS|iPhone4|iPhone4S|iPad|iPad2|iPad3(The New iPad)|iPad mini|iPod Touch 3G|iPod Touch4

armv6 设备: iPhone, iPhone2, iPhone3G, 第一代、第二代 iPod Touch(一般不需要去支持)

 

4、Xcode中指令集相关选项(Build Setting中)

(1)Architectures

Space-separated list of identifiers. Specifies the architectures (ABIs, processor models) to which the binary is targeted. When this build setting specifies more than one architecture, the generated binary may contain object code for each of the specified
architectures.

指定工程被编译成可支持哪些指令集类型,而支持的指令集越多,就会编译出包含多个指令集代码的数据包,对应生成二进制包就越大,也就是ipa包会变大。

(2)Valid Architectures

Space-separated list of identifiers. Specifies the architectures for which the binary may be built. During the build, this list is intersected with the value of ARCHS build setting; the resulting list specifies the architectures the binary can run on. If
the resulting architecture list is empty, the target generates no binary.

限制可能被支持的指令集的范围,也就是Xcode编译出来的二进制包类型最终从这些类型产生,而编译出哪种指令集的包,将由Architectures与Valid Architectures(因此这个不能为空)的交集来确定,例如:
比如,你的Valid Architectures设置的支持arm指令集版本有:armv7/armv7s/arm64,对应的Architectures设置的支持arm指令集版本有:armv7s,这时Xcode只会生成一个armv7s指令集的二进制包。

再比如:将Architectures支持arm指令集设置为:armv7,armv7s,对应的Valid Architectures的支持的指令集设置为:armv7s,arm64,那么此时,XCode生成二进制包所支持的指令集只有armv7s

 

在Xcode6.1.1里的 Valid Architectures  设置里, 默认为 Standard architectures(armv7,arm64),如果你想改的话,自己在other中更改。

原因解释如下:
使用 standard architectures (including 64-bit)(armv7,arm64) 参数,则打的包里面有32位、64位两份代码,在iPhone5s( iPhone5s的cpu是64位的 )下,会首选运行64位代码包, 其余的iPhone( 其余iPhone都是32位的,iPhone5c也是32位 ),只能运行32位包,但是包含两种架构的代码包,只有运行在ios6,ios7系统上。
这也就是说,这种打包方式,对手机几乎没要求,但是对系统有要求,即ios6以上。
而使用 standard architectures (armv7,armv7s) 参数, 则打的包里只有32位代码, iPhone5s的cpu是64位,但是可以兼容32位代码,即可以运行32位代码。但是这会降低iPhone5s的性能。 其余的iPhone对32位代码包更没问题, 而32位代码包,对系统也几乎也没什么限制。
所以总结如下:

要发挥iPhone5s的64位性能,就要包含64位包,那么系统最低要求为ios6。 如果要兼容ios5以及更低的系统,只能打32位的包,系统都能通用,但是会丧失iPhone5s的性能。

(3)Build Active Architecture Only

指定是否只对当前连接设备所支持的指令集编译

当其值设置为YES,这个属性设置为yes,是为了debug的时候编译速度更快,它只编译当前的architecture版本,而设置为no时,会编译所有的版本。 编译出的版本是向下兼容的,连接的设备的指令集匹配是由高到低(arm64 > armv7s > armv7)依次匹配的。比如你设置此值为yes,用iphone4编译出来的是armv7版本的,iphone5也可以运行,但是armv6的设备就不能运行。  所以,一般debug的时候可以选择设置为yes,release的时候要改为no,以适应不同设备。

1)

Architectures:  armv7, armv7s, arm64
ValidArchitectures:  armv6, armv7s, arm64
生成二进制包支持的指令集: arm64

2)

Architectures: armv6, armv7, armv7s
Valid Architectures:  armv6, armv7s, arm64
生成二进制包支持的指令集: armv7s

3)

Architectures: armv7, armv7s, arm64
Valid Architectures: armv7,armv7s

这种情况是报错的,因为允许使用指令集中没有arm64。

注:如果你对ipa安装包大小有要求,可以减少安装包的指令集的数量,这样就可以尽可能的减少包的大小。当然这样做会使部分设备出现性能损失,当然在普通应用中这点体现几乎感觉不到,至少不会威胁到用户体检。

 

三、制作静态库.a是指令集选择

现在回归到正题,如何制作一个“没有问题”的.a静态库,通过以上信息了解到,当我们做App的时候,为了追求高效率,并且减小包的大小,Build Active Architecture Only设置成YES,Architectures按Xcode默认配置就可以,因为arm64向前兼容。但制作.a静态库就不同了,因为要保证兼容性,包括不同iOS设备以及模拟器运行不出错,所以结合当前行业情况,要做到最大的兼容性。

ValidArchitectures设置为:armv7|armv7s|arm64|i386|x86_64

Architectures设置不变(或根据你需要):  armv7|arm64

然后分别选择iOS设备和模拟器进行编译,最后找到相关的.a进行合包,使用lipo -create 真机库.a的路径 模拟器库.a的的路径 -output 合成库的名字.a(详情可以参考http://blog.csdn.net/lizhongfu2013/article/details/12648633)

这样就制作了一个通用的静态库.a

链接:https://www.jishudog.com/30423/html

收起阅读 »

从XML到View显示在屏幕上,都发生了什么

前言 View绘制可以说是Android开发的必备技能,但是关于View绘制的的知识点也有些繁杂。 如果我们从头开始阅读源码,往往千头万绪,抓不住要领。 目前当我们写页面时,布局都是写在XML里的,我们可以思考下:布局从XML到显示到屏幕上,都发生了什么,可...
继续阅读 »

前言


View绘制可以说是Android开发的必备技能,但是关于View绘制的的知识点也有些繁杂。
如果我们从头开始阅读源码,往往千头万绪,抓不住要领。
目前当我们写页面时,布局都是写在XML里的,我们可以思考下:布局从XML到显示到屏幕上,都发生了什么,可以分为哪几个部分?
我们将整个显示流程分解为以下几个部分



  1. 代码是怎么从XML转换成View的?

  2. View是怎么添加到页面上的?

  3. 在内存中View到底是怎么绘制的?

  4. View绘制完成后是怎么显示到屏幕上的?


本文目录如下所示:


1. XML是怎么转换成View的?


我们都知道,在android中写布局一般是通过XML,然后通过setContentView方法配置到页面中
看来XML转换成View就是在这个setContentView中了


1.1 setContentView中做了什么


    public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);
mAppCompatWindowCallback.getWrapped().onContentChanged();
}

可以看到resId传给了我们熟悉的LayoutInflater,看来xml转化成View就是在LayoutInflater方法中实现的了


1.2 LayoutInflater中做了什么?


    public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot) {
final Resources res = getContext().getResources();
//预编译直接返回view,目前还未启用
View view = tryInflatePrecompiled(resource, res, root, attachToRoot);
if (view != null) {
return view;
}
XmlResourceParser parser = res.getLayout(resource);
try {
//真正将`XML`转化为`View`
return inflate(parser, root, attachToRoot);
} finally {
parser.close();
}
}

代码也比较简单,我们一起来分析下



  1. 首先我们需要明确,将XML转化为View牵涉到一些耗时操作,比如XML解析是一个io操作,将XML转化为View涉及到反射,这也是耗时的

  2. 我们可以看到在解析前有个tryInflatePrecompiled方法,这个方法就是希望可以在编译阶段直接预编译XML,在运行时直接返回构建好的View,看起来Google希望通过这种方式解决XML的性能问题。不过这个功能目前还没有启用,因此此方法直接返回null,目前生效的还是下面的方法

  3. 真正将XML解析为View的还是在inflate方法中,将标签名转化为View的名称,XML中的各种属性转化为AttributeSet对象,然后通过反射生成View对象


由于篇幅原因,这里就不再粘贴inflate方法的源码了,里面主要需要注意下setFactorysetFactory2方法
在真正进行反射前,会先调用这两个方法尝试创建一下View,而且系统开放了API,我们可以自定义解析XML方式
这就给了我们一些面向切面编程的空间,可以利用这两个API实现换肤,替换字体,替换 View,提升View构建速度等操作
希望进一步了解的同学可参考:探究 LayoutInflater setFactory


1.3 小结


XML转化为View转化为主要是通过LayoutInflator来完成的,将标签名转化为View的名称,XML中的各种属性转化为AttributeSet对象,然后通过反射生成View对象
这个过程中存在一些耗时操作,比如解析XMLIO操作,通过反射生成View等,我们可以通过多种方式优化这个过程,比如将反向的耗时转移到编译期,有兴趣的同学可以参阅:Android "退一步"的布局加载优化


2. View是怎么添加到页面上的?


经过上面这步,View已经被创建出来了,但是View又是怎么添加到页面(Activity)上的呢?
我们再来看下setContentView方法


    public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);
mAppCompatWindowCallback.getWrapped().onContentChanged();
}

LayoutInflater有两个参数,第二个参数就是root,即创建出的view要被添加的父view
所以答案也就呼之欲出了,创建出来的view被添加到了contentParent上,即R.id.content
那么问题来了,这个R.id.content是哪来的呢?


2.1 R.id.content从何而来?


我们看到,setContentView开头调用了ensureSubDecor方法,一起来看下它的源码


    private void ensureSubDecor() {
if (!mSubDecorInstalled) {
mSubDecor = createSubDecor();
}
}
private ViewGroup createSubDecor() {
// Now let's make sure that the Window has installed its decor by retrieving it
ensureWindow();
mWindow.getDecorView();

final LayoutInflater inflater = LayoutInflater.from(mContext);
ViewGroup subDecor = null;

//省略其他样式subDecor布局的实例化
//包含 actionBar floatTitle ActionMode等样式
subDecor = (ViewGroup) inflater.inflate(R.layout.abc_screen_simple, null);
final ContentFrameLayout contentView = (ContentFrameLayout) subDecor.findViewById(R.id.action_bar_activity_content);

final ViewGroup windowContentView = (ViewGroup) mWindow.findViewById(android.R.id.content);
// 把`contentView`的id设置为android.R.id.content,把windowContentView的id设置为View.NO_ID
windowContentView.setId(View.NO_ID);
contentView.setId(android.R.id.content);

//将subDecor添加到window
mWindow.setContentView(subDecor);
return subDecor;
}

可以看出,主要工作是创建subDecor并添加到window



  • 步骤一:确认windowattach(设置背景等操作)

  • 步骤二:获取DecorView,因为是第一次调用所以会installDecor(创建DecorViewwindowContentView)

  • 步骤三:从xml中实例化出subDecor布局

  • 步骤四:将subDecorcontentViewid设置为R.id.content

  • 步骤四:将subDecor添加到window


现在我们已经知道R.id.content从何而来了,并且知道了subDecor最终会添加到window
那么问题来了,window又是什么呢?


2.2 window到底是什么?


我们上文提到,我们创建的view会被添加到subDecor上,最后会被添加到window中,那么window是什么?为什么要有window?


我们在应用中有多个页面,手机上也有多个应用,这么多页面同时只能有一个页面显示在手机上,这个时候就需要有一个机制来管理当前显示哪个页面
于是Android在系统进程中创建了一个系统服务WindowManagerService(WMS)专门用来管理屏幕上的窗口,而View只能显示在对应的窗口上,如果不符合规定就不开辟窗口进而对应的View也无法显示



window机制就是为了管理屏幕上的view的显示以及触摸事件的传递问题



值得注意的事,上面的window窗口很容易混淆,Android SDK中的Window是一个抽象类,它有一个唯一实现类PhoneWindowPhoneWindow内部会持有一个DecorView(根View),它的职责就是对DecorView做一些标准化的处理,比如标题、背景、导航栏、事件中转等,很显然与我们前面所说的窗口概念不符合


总得来说PhoneWindow只是提供些标准的UI方案,与窗口不等价
窗口是一个抽象概念,即当前应该显示哪个页面,系统通过WindowManagerService(WMS)来管理
关于窗口机制,想了解更加详细的同学,可参考:通俗易懂 Android视图系统的设计与实现,写得非常通俗易懂,有兴趣的可以了解下


2.3 View什么时候真正可见?


上面提到PhoneWindow只是提供些标准的UI方案,并不是真正的窗口
那么我们的View到底什么时候添加到窗口上,什么时候真正对用户可见?


#ActivityThread
public void handleResumeActivity(...) {
//...
//注释1
r.window = r.activity.getWindow();
View decor = r.window.getDecorView();
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
...
//注释2
wm.addView(decor, l);
...
}

#ViewRootImpl.java
public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
synchronized (this) {
if (mView == null) {
//记录DecorView
mView = view;
//省略
//开启View的三大流程(measure、layout、draw)
requestLayout();
try {
//添加到WindowManagerService里,这里是真正添加window到底层
//这里的返回值判断window是否成功添加,权限判断等。
//比如用Application的context开启dialog,这里会添加不成功
// 注释3
res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes,
getHostVisibility(), mDisplay.getDisplayId(), mTmpFrame,
mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,
mAttachInfo.mOutsets, mAttachInfo.mDisplayCutout, mInputChannel,
mTempInsets);
setFrame(mTmpFrame);
} catch (RemoteException e) {
}
//省略
//输入事件接收
}
}
}



  1. 注释1处会从Activity中取出PhoneWindow,DecorView,WindowManager

  2. 注释2处调用了WindowManageraddView方法,顾名思义就是将DecorView添加至窗口当中

  3. 最后会调到ViewRootImpl中注释3处,这里才是真正的通过WMS在屏幕上开辟一个窗口,到这一步我们的View也就可以显示到屏幕上了


可以看出,当我们打开一个Activity时,界面真正可见是在onResume之后


2.4 Activity,PhoneWindow,View的关系



  1. Phonewindowactivity 的一个成员变量,会在Activity.attatch时初始化

  2. PhoneWindowView的容器,对DecorView做一些标准化的处理,比如标题、背景、导航栏、事件中转等

  3. Activity则提供了窗口的生命周期,屏蔽了窗口机制的复杂细节,开发者只需要基于模板方法开发即可


如下图所示


2.5 小结


View添加到页面上,主要经过了这么几个过程



  • 1.启动activity

  • 2.创建PhoneWindow

  • 3.设置布局setContentView,将layoutId转化为View

  • 4.确认subDecorView的初始化,将subDecorView添加到PhoneWindow

  • 5.添加layoutId转化后的Viewandroid.R.id.content

  • 6.在onResume中将DecorViewView添加到WindowManager

  • 7.View真正显示到屏幕上了


3. View到底是怎么绘制的?


经过上一步,View已经添加到window上了,接下来就是View本身的绘制了
View的绘制主要经过以下几步
1、首先需要确定View占的空间尺寸(measure)
2、确定了空间尺寸,就需要确定摆放在哪个位置(layout)
3、确认了摆放位置,就需要确定在上面展示些什么东西(draw)


这几个阶段,View已经封装了模板方法给我们,我们直接重写onMeasure,onLayout,onDraw这几个方法就好了
而绘制的入口,就是上面ViewRootImpl.setView中的requestLayout


3.1 requestLayout如何触发绘制


上文说到requestLayout会触发绘制,我们一起来看下源码


ViewRootImpl.java
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
//检查是否是主线程,如果不是则直接抛出异常,ViewRootImpl创建的时候生成一个主线程引用
//用当前线程和引用比较,如果是同一个则是主线程
//这也是为什么在子线程对View进行更新、绘制会报错的原因
checkThread();
//用来标记需要进行layout
mLayoutRequested = true;
//绘制请求
scheduleTraversals();
}
}

void scheduleTraversals() {
if (!mTraversalScheduled) {
//标记一次绘制请求,用来屏蔽短时间内的重复请求
mTraversalScheduled = true;
//往主线程Looper队列里放同步屏障消息,用来控制异步消息的执行
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
//放入mChoreographer队列里
//主要是将mTraversalRunnable放入队列
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
//省略
}
}

final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();
}
}

void doTraversal() {
//没有取消绘制的话则开始绘制
if (mTraversalScheduled) {
mTraversalScheduled = false;
//移除同步屏障
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

//真正开始执行measure、layout、draw等方法
performTraversals();
}
}

requestLayout中其实主要也是做了以下几件事



  1. 检查绘制的线程与View创建的线程是否是同一个线程

  2. 通过Handler同步屏障机制,保证UI绘制消息优先级是最高的

  3. mTraversalRunnable传入Choreographer,监听vsync信号。

  4. 收到vsync信号后会回调TraversalRunnable,移除同步屏障并开始真正的measure,layout,draw


View绘制流程图如下:


3.2 MeasureSpec分析


在测量过程中,会传入一个MeasureSpec参数,MeasureSpec封装了View的规格尺寸参数,包括View的宽高以及测量模式。
它的高2位代表测量模式,低30位代表尺寸。其中测量模式总共有3中。



  • UNSPECIFIED:未指定模式不对子View的尺寸进行限制。

  • AT_MOST:最大模式对应于wrap_content属性,父容器已经确定子View的大小,并且子View不能大于这个值。

  • EXACTLY:精确模式对应于match_parent属性和具体的数值,子View可以达到父容器指定大小的值。


普通viewMeasureSpec创建规则如下


结合这个表,我们可以一起来看一个问题


<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@android:color/red"
xmlns:android="http://schemas.android.com/apk/res/android">
<View
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@android:color/blue"/>
</FrameLayout>

请问这样一个布局,最后是什么颜色呢?
答案是蓝色,并且占满屏幕


简单来说,当我们自定义View 时,如果没有对MODE做处理,设置wrap_contentmatch_content结果其实是一样的,View 的宽高都是取父 View 的宽高
本问题的详细解析可见:一道滴滴面试题


3.3 小结



  1. View的绘制需要定位,测量,绘制三个步骤,为了简化自定义View的过程,官方已经提供了模板方法,我们重写相关方法即可

  2. ViewRootImpl中的requestLayout是绘制的入口,当然我们在View中调用invalidate或者requestLayout也会触发重绘

  3. 绘制过程本质上也是通过Handler发送消息,为了提高绘制消息的优先级,会开启同步屏蔽机制

  4. mTraversalRunnable传入Choreographer,监听vsync信号。注意,vsync信号注册了才会监听。

  5. 收到vsync信号后会回调TraversalRunnable,移除同步屏障并开始真正的measure,layout,draw过程

  6. 接下来就是回调各个ViewonMeasure,onLayout,onDraw过程


4 View绘制完成后是怎么显示到屏幕上的?


目前我们已经知道了,从XML到调用View.onDraw的过程,但是从onDraw到显示到屏幕上似乎还有些距离
我们知道,View最后要显示在屏幕上,CPU负责计算帧数据,把计算好的数据交给GPUGPU会对图形数据进行渲染,渲染好后放到buffer(图像缓冲区)里存起来,然后Display(屏幕或显示器)负责把buffer里的数据呈现到屏幕上
那么问题来了,canvas.draw是怎么转化成Graphic Buffer的呢?


其大概流程如图所示:

可以看出,这个过程还是相当复杂的,由于篇幅原因,这里就不展开了,感兴趣的同学可以参阅苍耳叔叔的系列文章:Android图形系统综述(干货篇)


总结


XMLView显示到屏幕上主要涉及到以下知识点



  1. Activity的启动

  2. LayoutInflater填充View的原理

  3. PhoneWindow,Activity,View的关系

  4. Android窗口机制与WindowManagerService管理窗口

  5. View的绘制流程,measure,layout,draw等与Handler同步屏障机制

  6. Android屏幕刷新机制,VSync信号监听,三级缓冲等

  7. Android图形绘制,包括SurfaceFinger工作流程,软件绘制,硬件加速等


这篇文章其实已经比较长了,但是要完全了解从XML到显示到屏幕上的过程,还是不够详细,有很多地方只做了简述,如果有什么错误或者需要补充的地方,欢迎在评论区提出
由于篇幅原因,有一些知识点没有写得很详细,下面列出一些更好的文章供参考:
Android窗口机制:通俗易懂 Android视图系统的设计与实现
Android屏幕刷新机制: “终于懂了” 系列:Android屏幕刷新机制—VSync、Choreographer 全面理解!
Android图形系统: Android图形系统综述(干货篇)
Handler同步屏障机制:关于Handler同步屏障你可能不知道的问题


参考资料


【Android进阶】这一次把View绘制流程刻在脑子里!!
Android Activity创建到View的显示过程



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

Android AGP 7.0 适配,开始强制 JDK 11

本次跟随 Arctic Fox 更新的其中一个重点就是 AGP 7.0 的调整,估计很多直接升级到 AGP 7.0 的开发者都会发现项目出现一些异常,本篇主要结合官方简单介绍 AGP 7.0 下的主要调整内容。 跳过版本 5 和 6 直接进入 AGP 7...
继续阅读 »

本次跟随 Arctic Fox 更新的其中一个重点就是 AGP 7.0 的调整,估计很多直接升级到 AGP 7.0 的开发者都会发现项目出现一些异常,本篇主要结合官方简单介绍 AGP 7.0 下的主要调整内容。



跳过版本 5 和 6 直接进入 AGP 7.0.0 的原因,是为了和 Gradle 的版本 匹配,这意味着 AGP 7.x 就是和 Gradle 7.x API 一一对应。



通过此次版本号的更改,AGP 版本号将与 Android Studio 版本号分开,不过目前的情况看 Android Studio 和 Android Gradle 插件会同时发布。一般来说使用稳定版 AGP 的项目是应该可以在较新版本的 Android Studio 中打开。



运行 AGP 7 需要 JDK 11


是的,不要惊讶,使用 Android Gradle plugin 7.0 构建时需要 JDK 11 才能运行 Gradle


但是也请不必过多当心,这里的 JDK 11 是免费的 OpenJDK 11 ,并且只要你更新到 Android Studio Arctic Fox ,它是直接捆绑了 JDK 11 并将 Gradle 配置为默认使用它,所以大多数情况下,如果你本地配置正常,是可以直接使用 AGP 7.0 的升级。


当然,你也可以手动选择配置,在 Project StructureSDK Location 栏目,可以看到 JDK 的配置位置已经被移动到 Gradle Settings



在打开的 Gradle projects 可以看到 Gradle 对应的配置选项,并且有 Gradle JDK 等可选的参数,你可以选择自己的 Java SDK ,也可以选择 AS 自带的 JDK




比如 Mac 下可以看到捆绑的 JDK 位置在 /Applications/Android\ Studio.app/Contents/jre/Contents/Home/bin/java





如果是需要抛开 Android Studio 来配置运行的 AGP 时,通过会使用 JAVA_HOME 环境变量 或 -Dorg.gradle.java.home 命令行选项 设置为 JDK 11 的安装目录来升级 JDK 版本。



Variant API stable


新的 Variant API 现在已经是稳定的版本, 可以查看 com.android.build.api.variant 包中的新接口,以及 gradle-recipes GitHub 项目中的示例。


作为新 Variant API 的一部分,通过 Artifacts 接口提供了许多称为 artifacts 的中间文件,如合并的清单功能,可以通过使用第三方插件和代码安全地获取和定制。



后续将通过添加新功能和增加可用于定制的中间件的数量来继续扩展 Variant API。



Lint 的行为变化


改进了库依赖项的 lint


运行 lint with checkDependencies = true 现在比以前更快了,对于库依赖的 Android App 的项目,建议使用 checkDependencies = true 的方式,和运行 ./gradlew :app:lint,这将并行分析的所有依赖模块,并且提供一份单独包含有 App 和依赖的 issues 文件。


// build.gradle

android {
  ...
  lintOptions {
    checkDependencies true
  }
}

// build.gradle.kts

android {
  ...
  lint {
    isCheckDependencies = true
  }
}

Lint 任务现在可以 UP-TO-DATE


如果模块的源和资源没有改变,则不需要再次运行该模块的 lint 分析任务,当出现这种情况时,任务的执行在 Gradle 输出中显示为“UP-TO-DATE”。


通过此次的变换,当在带有 checkDependencies = true 的应用程序模块上运行 lint 时,只有发生更改的模块需要运行分析,因此 Lint 可以运行得更快。



如果输入没有发生更改,则 Lint 报告任务也不需要运行,这里有一个相关的已知问题是,当 lint 任务为 UP-TO-DATE 时,没有打印到 stdout 的 lint 文本输出(问题 #191897708)。



在动态功能模块上运行 lint


AGP 不再支持从动态功能模块运行 lint,从相应的模块运行 lint 将在其动态功能模块上运行 lint,并将所有问题包含在应用程序的 lint 报告中。


一个相关的已知问题 是,当 checkDependencies = true 时,从模块运行 lint 不再会检查动态功能库依赖项,除非它们也是应用程序的依赖项(问题 #191977888)。


仅在默认 variant 上运行 lint


运行 ./gradlew :app:lint 现在只运行默认的 variant , 在以前版本的 AGP 中,它将为所有 variants 运行 lint。


Missing class warnings in R8 shrinker


R8 可以更精确和更一致地处理丢失类和 -dontwarn 的选项,因此开发者应该开始针对 R8 发出的缺失类警告进行处理。


当 R8 遇到未在 App 或其依赖项之一中定义的类引用时,它将发出警告,并显示在您的构建输出中。例如:


R8: Missing class: java.lang.instrument.ClassFileTransformer

此警告意味着 java.lang.instrument.ClassFileTransformer 在分析代码时找不到类定义,虽然这些经过可能存在错误,所以开发者可能希望可以忽略此警告,忽略警告的两个常见原因是:





    1. 以 JVM 为目标的库和缺少的类是 JVM 库类型(如上例所示)。




    1. 依赖项之一使用仅限编译时的 API。



所以可以通过向文件添加 -dontwarn 规则来向 proguard-rules.pro 忽略缺少的类警告,例如:


-dontwarn java.lang.instrument.ClassFileTransformer

为方便起见,AGP 将生成一个包含所有可能丢失的规则的文件,将它们写入如下文件路径  app/build/outputs/mapping/release/missing_rules.txt, 这样可以方便地将规则添加到 proguard-rules.pro 文件以忽略警告。



在 AGP 7.0 中缺少类消息将显示为警告,当然你可以通过 android.r8.failOnMissingClasses = truegradle.properties 讲他们变成 errors 。



在 AGP 8.0 中,这些警告将变为破坏构建的 errors,你可以通过将选项添加 -ignorewarnings配置到 proguard-rules.pro 文件来保持 AGP 7.0 行为,但不建议这样做


移除了 Android Gradle 插件构建缓存


AGP 构建缓存已在 AGP 4.1 中删除,之前在 AGP 2.3 中引入是为了补充 Gradle 构建缓存,AGP 构建缓存被 AGP 4.1 中的 Gradle 构建缓存完全取代,这个变换其实不会影响构建时间。


在 AGP 7.0 中 android.enableBuildCache 属性、android.buildCacheDir属性和cleanBuildCache 任务已经被删除。


在项目中使用 Java 11


现在可以项目中使用 Java 11 去编译你的项目代码了,开发者能够使用更新后的语言功能,例如私有接口方法、匿名类的 diamond 运输符和 lambda 参数的局部变量语法。


要启用此功能,请设置 compileOptions 为所需的 Java 版本并设置 compileSdkVersion 为 30 或更高版本:


// build.gradle

android {
    compileSdkVersion 30

    compileOptions {
      sourceCompatibility JavaVersion.VERSION_11
      targetCompatibility JavaVersion.VERSION_11
    }

    // For Kotlin projects
    kotlinOptions {
      jvmTarget = "11"
    }
}

// build.gradle.kts

android {
    compileSdkVersion(30)

    compileOptions {
      sourceCompatibility(JavaVersion.VERSION_11)
      targetCompatibility(JavaVersion.VERSION_11)
    }

    kotlinOptions {
      jvmTarget = "11"
    }
}

已知的问题


与 1.?4.?x 的 Kotlin 多平台插件兼容


Android Gradle 插件 7.0.0 与 Kotlin 多平台插件 1.5.0 及更高版本兼容。


使用 Kotlin 多平台支持的项目需要更新到 Kotlin 1.5.0 才能使用 Android Gradle 插件 7.0.0。


缺少 lint 输出


当 lint 任务是最新的(问题 #191897708)时,文本输出没有打印到 stdout 的 lint ,此问题将在 Android Gradle 插件 7.1 中修复。


并非所有动态功能库依赖项都经过 lint 检查


checkDependencies = true 从应用程序模块运行 lint 时,不会检查动态功能库依赖项,除非它们也是应用程序依赖项(问题 #191977888)。


收起阅读 »

【Flutter 组件集录】Switch 是怎样炼成的

一、 Switch 组件使用详解 可能有人会觉得 Switch 组件非常简单,有什么好说的呢?其实 Switch 组件源码洋洋洒洒 近千行 ,其中关于主题处理、平台适配、事件处理、动画处理、绘制处理 都有值得我们学习的地方。那么废话不多说,来一起看看 Swi...
继续阅读 »
一、 Switch 组件使用详解

可能有人会觉得 Switch 组件非常简单,有什么好说的呢?其实 Switch 组件源码洋洋洒洒 近千行 ,其中关于主题处理平台适配事件处理动画处理绘制处理 都有值得我们学习的地方。那么废话不多说,来一起看看 Switch 是怎么炼成的吧。





1. Switch 最简使用:valueonChanged

Switch 组件的使用中注意:该组件是 StatelessWidget ,表示本身并不维护 开关状态。这也就意味着,我把只能通过 重新构建 Switch组件 来切换 开关状态 。在构建 Switch 时必须传入 valueonChanged 两个参数,其中 value 表示 Switch 开关的状态,onChanged 是状态变化回调函数。


如下,在 _SwitchDemoState 中定义状态 _value 用于表示 Switch 开关的状态,在 _onChanged 回调中改变状态值,并 重新构建 Switch 组件,这样就能达到点击进行开关的效果。


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

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

class _SwitchDemoState extends State<SwitchDemo> {
bool _value = false;

@override
Widget build(BuildContext context) {
return Switch(
value: _value,
onChanged: _onChanged,
);
}

void _onChanged(bool value) {
setState(() {
_value = value;
});
}
}

其实这里可能很让人疑惑 Switch 为什么不自己维护 开关状态,要将改状态交由外界指定呢?既然 SwitchStatelessWidget ,为什么可以执行滑动的动画?还有 onChanged 方法又是何时触发的?带着这些问题我们来逐渐去认识这个属性而陌生的 Switch 组件。




2. Switch 的四个主要颜色

Switch 的构造方法中可以看出,其中定义了非常多的颜色相关属性。



先看前四个颜色属性:



  • inactiveThumbColor 代表关闭时圆圈的颜色。

  • inactiveTrackColor 代表关闭时滑槽的颜色。




  • activeColor 代表打开时圆圈的颜色。

  • inactiveTrackColor 代表打开时滑槽的颜色。



Switch(
activeColor: Colors.blue,
activeTrackColor: Colors.green,
inactiveThumbColor: Colors.orange,
inactiveTrackColor: Colors.pinkAccent,
value: _value,
onChanged: _onChanged,
);



3. hoverColor 、 mouseCursor 和 splashRadius

前两个属性一般只能在桌面或web 端起作用,hoverColor 顾名思义是鼠标悬浮时,外层的大圈颜色,splashRadius 表示大圈的半径,如果不想要外圈的悬浮效果,可以将半径设为 0 。另外, mouseCursor 代表鼠标的样式,比如下面的小拳头是 SystemMouseCursors.grabbing



Switch(
activeColor: Colors.blue,
activeTrackColor: Colors.green,
inactiveThumbColor: Colors.orange,
inactiveTrackColor: Colors.pinkAccent,
hoverColor: Colors.blue.withOpacity(0.2),
mouseCursor: SystemMouseCursors.grabbing,
value: _value,
onChanged: _onChanged,
);

mouseCursor 属性的类型为 MouseCursor ,其中 SystemMouseCursors 中定义了非常多的鼠标指针类型以供使用。下面给出几个效果:




















contextMenu copy forbidden text



5. 指定图片

通过 activeThumbImageinactiveThumbImage 可以指定小圆中开启/关闭 时的图片。另外 onActiveThumbImageErroronInactiveThumbImageError 两个回调用于图片加载错误的监听。




当小圆同时指定 图片颜色 属性时,会显示 图片


Switch(
activeColor: Colors.blue,
activeThumbImage: AssetImage('assets/images/icon_head.png'),
inactiveThumbImage: AssetImage('assets/images/icon_8.jpg'),
activeTrackColor: Colors.green,
inactiveThumbColor: Colors.orange,
inactiveTrackColor: Colors.pinkAccent,
hoverColor: Colors.blue.withOpacity(0.2),
mouseCursor: SystemMouseCursors.move,
splashRadius: 15,
value: _value,
onChanged: _onChanged,
);



6.主题相关属性: thumbColor 和 trackColor

一些具有交互性的 Material 组件会通过有 MaterialState 枚举定义交互行为,有如下 7 个元素。


enum MaterialState {
hovered,
focused,
pressed,
dragged,
selected,
disabled,
error,
}

可以看出这两个成员都是 MaterialStateProperty 类型,那这种类型的对象如何创建,又有什么特点呢?


---->[Switch 成员声明]----
final MaterialStateProperty<Color?>? thumbColor;
final MaterialStateProperty<Color?>? trackColor;



简单来说通过 MaterialStateProperty.resolveWith 方法,传入一个函数返回对应泛型数据。如下回调函数为 getThumbColor ,回调参数为 Set<MaterialState> 。也仅仅说,会根据 MaterialState 集合,来返回泛型数据。从 thumbColor 属性源码注释中可以看出,Switch 有如下四种 MaterialState



getThumbColor 中根据 states 的情况,分别对几种状态返回不同颜色,这样 Switch 在不同的状态下,就会自动使用对应颜色。比如下面的 onChanged: null 代表 Switch 不可用,在 getThumbColor 中当为 disabled ,会返回红色。



thumbColor 代表小圆颜色,trackColor 代表滑槽颜色,使用方式是一样的。这里可能有人会问:有三个属性可以设置小圆,那它们同时存在,优先级怎么样?结果测试发现,inactiveThumbImage 会优先显示,优先级如下:


inactiveThumbImage > thumbColor > inactiveThumbColor > 默认 Switch 主题



上面提到了 默认 Switch 主题 ,这里就来说一下 SwitchTheme ,它是一个 InheritedWidget,维护 SwitchThemeData 类型数据,具体内容如下:



我们可以通过在上层嵌套 SwitchTheme 来为子树中的 Switch 指定默认样式,由于 MaterialApp 内部继承了 SwitchTheme 组件,我们可以在 theme 中指定 Switch 的主题样式。这样在指定 Switch 的相关颜色属性,就会使用默认的主题样式:






7. Switch 的焦点: focusColor 与 autofocus

Switch 组件是拥有焦点的,焦点相关的处理被封装在组件内部。focusColor 表示聚焦时的颜色,可被聚焦的组件有个特点:在桌面或 web 平台中可以通过 Tab 键,切换焦点。如下是六个 Switch 通过 Tab 键切换焦点的效果:



@override
Widget build(BuildContext context) {
return
Wrap(
children: List.generate(6, (index) => Switch(
value: _value,
focusColor: Colors.blue.withOpacity(0.1),
onChanged: _onChanged,
))
);
}



8. Switch 的尺寸相关: materialTapTargetSize

MaterialTapTargetSize 是一个枚举类型,有两个元素。该属性可以影响 Switch 的大小,如下分布是 paddedshrinkWrap 的效果。通过调试可知,默认是 padded 。下面在源码分析中会详细介绍该属性的作用。


enum MaterialTapTargetSize {
padded,
shrinkWrap,
}




二、 挖掘 Switch 源码中的一些细节


1. 类型 _SwitchType

Switch 类中有一个 _SwitchType 类型成员,该成员完全被封装在 Switch 内部,我们是无法直接操作的。 _SwitchType 是只有两个元素的枚举类。


enum _SwitchType { material, adaptive }

---->[Switch 成员声明]----
final _SwitchType _switchType;

既然是成员变量,必然会在类内部被初始化,一般来说对 成员变量 初始化的地方在 构造方法 中。如下, Switch 的普通构造 中,会将 _switchType 设为 _SwitchType.material





一般来说,枚举对象就是为了分类处理,在 Switch#build 方法中,会根据 _switchType 的值进行不同的构建逻辑,如果是 material ,则所有的平台都使用Material风格的 Switch 。 如果是 adaptive 会根据平台的不同,使用不同的风格的 Switch 。在 androidfuchsialinuxwindows 中会使用 Material 风格;在 iOSmacOS 中会使用 Cupertino 风格。



到这里,可能有人会问, _SwitchType 成员完全被封装在 Switch 内部,那如何设置 adaptive 类型呢?仔细查看源码可以看出 Switch 还有一个 adaptive 构造,此处会将 _switchType 设为 _SwitchType.adaptive





2. 两种风格的 Switch 构建

_buildCupertinoSwitch 是当模式为 adaptive 时,用于构建 iOSmacOS 平台 Switch 组件构建,可以看出其内部是通过 CupertinoSwitch 进行构建,效果如下:






_buildMaterialSwitch 用于构建 Material 风格的 Switch 组件构建,可见其内部通过 _MaterialSwitch 组件进行构建。到这里我们就可以回答:既然 SwitchStatelessWidget ,为什么可以执行滑动的动画?因为 _MaterialSwitch 组件是 StatefulWidget ,它可以在内部改变组件状态。





3.Switch 尺寸的确定

从上面可以看出,两种风格的 Switch 都是通过 _getSwitchSize 获取 Size 尺寸的。如下代码中,可以看出,尺寸是通过 MaterialTapTargetSize 对象控制的。如果未指定 materialTapTargetSize 则会通过主题获取,调试可以看出,主题中 materialTapTargetSize 默认是 padded


Size _getSwitchSize(ThemeData theme) {
final MaterialTapTargetSize effectiveMaterialTapTargetSize = materialTapTargetSize
?? theme.switchTheme.materialTapTargetSize
?? theme.materialTapTargetSize;
switch (effectiveMaterialTapTargetSize) {
case MaterialTapTargetSize.padded:
return const Size(_kSwitchWidth, _kSwitchHeight);
case MaterialTapTargetSize.shrinkWrap:
return const Size(_kSwitchWidth, _kSwitchHeightCollapsed);
}
}

下面分别是 paddedshrinkWrap 的调试信息,可以很清楚地看出尺寸情况。






到这里 Switch 组件的源码就已经面面俱到了,我们可以发现,它作为一个 StatelessWidget 并不能做太多的事,只是定义了很多属性,并通过别的组件进行构建。也就是说,它本身起到平台差异的统筹、封装的作用,目的就是方便用户使用。




4. onChanged 方法触发的时机

通过调试可以发现,onChanged 方法 的触发是 ToggleableStateMixin#_handleTap 中触发的。如下是 buildToggleable 的源码,可以看出其中通过 GestureDetector 监听点击事件。



_MaterialSwitchState.build 方法中,可以看到其中通过 GestureDetector 监听了水平拖拽事件,这也是为什么 Switch 可以支持拖动的原因,同时 child 属性是 buildToggleable ,也就是上面的组件,支持点击事件。这是一个很好的多事件监听的案例。





5.动画的创建与触发

仔细看一下滑动的过程,可以看出其中有 位移动画透明度渐变动画。 首先来说一下动画的来源:






这些动画器都定义在 ToggleableStateMixin 中。而 _MaterialSwitchState 混入了 ToggleableStateMixin





和隐式动画一样, _MaterialSwitchState 中的动画触发也是通过重构组件,执行 didUpdateWidget 。如果你了解隐式动画,就不难理解 Switch 的动画触发机制。



最后,绘制是通过 _SwitchPainter 画出来的,这个画板是比较复杂的,这里就不展开了,有兴趣的可以自己研究一下。



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


作者:张风捷特烈

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

iOS 界面渲染流程分析

iOS
前言本文阅读建议 1.一定要辩证的看待本文. 2.本文所表达观点并不是最终观点,还会更新,因为本人还在学习过程中,有什么遗漏或错误还望各位指出. 3.觉得哪里不妥请在评论留下建议~ 4.觉得还行的话就点个小心心鼓励下我吧~ 在最近的面试中,我发现一道面试题,其...
继续阅读 »

前言

本文阅读建议
1.一定要辩证的看待本文.
2.本文所表达观点并不是最终观点,还会更新,因为本人还在学习过程中,有什么遗漏或错误还望各位指出.
3.觉得哪里不妥请在评论留下建议~
4.觉得还行的话就点个小心心鼓励下我吧~

在最近的面试中,我发现一道面试题,其考点是:围绕iOS App中一个视图从添加到完全渲染,在这个过程中,iOS系统都做了什么?

在进行了大量的文章查阅以及学习以后,将所有较为可靠的资料总结一下供大家参考。


面试题

本文可为以下面试题提供参考:

  1. app从点击屏幕(硬件)到完全渲染,中间发生了什么?越详细越好 要求讲到进程间通信?出处
  2. 一个UIImageView添加到视图上以后,内部是如何渲染到手机上的,请简述其流程?
  3. 在一个表内有很多cell,每个cell上有很多个视图,如何解决卡顿问题?
  4. UIView与CALayer的区别?

简答

iOS渲染视图的核心是Core Animation
其渲染层次依次为:图层树->呈现树->渲染树

  1. CPU阶段
    1. 布局(Frame)
    2. 显示(Core Graphics)
    3. 准备(QuartzCore/Core Animation)
    4. 通过IPC提交(打包好的图层树以及动画属性)
  2. OpenGL ES阶段
    1. 生成(Generate)
    2. 绑定(Bind)
    3. 缓存数据(Buffer Data)
    4. 启用(Enable)
    5. 设置指针(Set Pointers)
    6. 绘图(Draw)
    7. 清除(Delete)
  3. GPU阶段
    1. 接收提交的纹理(Texture)和顶点描述(三角形)
    2. 应用变换(transform)
    3. 合并渲染(离屏渲染等)

其iOS平台渲染核心原理的重点主要围绕前后帧缓存、Vsync信号、CADisplayLink

文字简答:

  1. 首先一个视图由CPU进行Frame布局,准备视图和图层的层级关系,查询是否有重写drawRect:drawLayer:inContext:方法,注意:如果有重写的话,这里的渲染是会占用CPU进行处理的
  2. CPU会将处理视图和图层的层级关系打包,通过IPC(内部处理通信)通道提交给渲染服务,渲染服务由OpenGL ES和GPU组成。
  3. 渲染服务首先将图层数据交给OpenGL ES进行纹理生成和着色。生成前后帧缓存,再根据显示硬件的刷新频率,一般以设备的VSync信号CADisplayLink为标准,进行前后帧缓存的切换。
  4. 最后,将最终要显示在画面上的后帧缓存交给GPU,进行采集图片和形状,运行变换,应用纹理和混合。最终显示在屏幕上。

以上仅仅是对该题简单回答,其中的原理以及瓶颈和优化,后面会详细介绍。


知识点

  1. 重新认识Core Animation
  2. CPU渲染职能
  3. OpenGL ES渲染职能
  4. GPU渲染职能
  5. IPC内部通信(进程间通信)
  6. 前后帧缓存&Vsync信号
  7. 视图渲染优化&卡顿优化
  8. Metal渲染引擎
  9. 事件响应链&Runloop原理
  10. CALayer的职能

重新认识Core Animation

苹果官方文档-Core Animation
Core Animation并仅仅是字面意思的核心动画,而是整个显示核心都是围绕QuartzCore框架中的Core Animation

Core Animation是依赖于OpenGL ES做GPU渲染,CoreGraphics做CPU渲染,但在本文中,以及官方文档都是将OpenGL与GPU分开说明。

Core Animation 在 RunLoop 中注册了一个 Observer,监听了 BeforeWaiting 和 Exit 事件。这个 Observer 的优先级是 2000000,低于常见的其他 Observer。当一个触摸事件到来时,RunLoop 被唤醒,App 中的代码会执行一些操作,比如创建和调整视图层级、设置 UIView 的 frame、修改 CALayer 的透明度、为视图添加一个动画;这些操作最终都会被 CALayer 捕获,并通过 CATransaction 提交到一个中间状态去(CATransaction 的文档略有提到这些内容,但并不完整)。当上面所有操作结束后,RunLoop 即将进入休眠(或者退出)时,关注该事件的 Observer 都会得到通知。这时 CA 注册的那个 Observer 就会在回调中,把所有的中间状态合并提交到 GPU 去显示;如果此处有动画,CA 会通过 DisplayLink 等机制多次触发相关流程。

CPU渲染职能

在这里推荐大家去阅读落影loyinglin的文章iOS开发-视图渲染与性能优化

  • 显示逻辑
    • CoreAnimation提交会话,包括自己和子树(view hierarchy)的layout状态等;
    • RenderServer解析提交的子树状态,生成绘制指令
    • GPU执行绘制指令
    • 显示渲染后的数据
  • 提交流程
    • 布局(Layout)
      • 调用layoutSubviews方法
      • 调用addSubview:方法
    • 显示(Display)
      • 通过drawRect绘制视图;
      • 绘制string(字符串);
    • 准备提交(Prepare)
      • 解码图片;
      • 图片格式转换;
    • 提交(Commit)
      • 打包layers并发送到渲染server;
      • 递归提交子树的layers;
      • 如果子树太复杂,会消耗很大,对性能造成影响;

CPU渲染职能主要体现在以下5个方面:

布局计算
如果你的视图层级过于复杂,当视图呈现或者修改的时候,计算图层帧率就会消耗一部分时间。特别是使用iOS6的自动布局机制尤为明显,它应该是比老版的自动调整逻辑加强了CPU的工作。

视图懒加载
iOS只会当视图控制器的视图显示到屏幕上时才会加载它。这对内存使用和程序启动时间很有好处,但是当呈现到屏幕上之前,按下按钮导致的许多工作都会不能被及时响应。比如控制器从数据库中获取数据,或者视图 从一个nib文件中加载,或者涉及IO的图片显示,都会比CPU正常操作慢得多。

Core Graphics绘制
如果对视图实现了drawRect:drawLayer:inContext:方法,或者 CALayerDelegate 的 方法,那么在绘制任何东 西之前都会产生一个巨大的性能开销。为了支持对图层内容的任意绘制,Core Animation必须创建一个内存中等大小的寄宿图片。然后一旦绘制结束之后, 必须把图片数据通过IPC传到渲染服务器。在此基础上,Core Graphics绘制就会变得十分缓慢,所以在一个对性能十分挑剔的场景下这样做十分不好。

解压图片
PNG或者JPEG压缩之后的图片文件会比同质量的位图小得多。但是在图片绘制到屏幕上之前,必须把它扩展成完整的未解压的尺寸(通常等同于图片宽 x 长 x 4个字节)。为了节省内存,iOS通常直到真正绘制的时候才去解码图片。根据你加载图片的方式,第一次对 图层内容赋值的时候(直接或者间接使用 UIImageView )或者把它绘制到 Core Graphics中,都需要对它解压,这样的话,对于一个较大的图片,都会占用一定的时间。

图层打包
当图层被成功打包,发送到渲染服务器之后,CPU仍然要做如下工作:为了显示 屏幕上的图层,Core Animation必须对渲染树种的每个可见图层通过OpenGL循环 转换成纹理三角板。由于GPU并不知晓Core Animation图层的任何结构,所以必须 要由CPU做这些事情。这里CPU涉及的工作和图层个数成正比,所以如果在你的层 级关系中有太多的图层,就会导致CPU没一帧的渲染,即使这些事情不是你的应用 程序可控的。

OpenGL ES渲染职能

这里推荐大家去看《OpenGL ES应用开发实践指南:iOS卷》,因为篇幅过长,就不赘述OpenGL的原理。

简单来说,OpenGL ES是对图层进行取色,采样,生成纹理,绑定数据,生成前后帧缓存。

纹理的概念:纹理是一个用来保存图像的颜色元􏰈值的 OpenGL ES 缓存,可以简单理解为一个单位。

1)生成(Generate)— 请 OpenGL ES 为图形处理器制的缓存生成一个独一无二的标识符。 
2)绑定(Bind)— 告诉 OpenGL ES 为接下来的运算使用一个缓存。
3)缓存数据(Buffer Data)— 让 OpenGL ES 为当前定的缓存分配并初始化 够的内存(通常是从 CPU 制的内存复制数据到分配的内存)。
4)启用(Enable)或者(Disable)— 告诉 OpenGL ES 在接下来的渲染中是 使用缓存中的数据。
5)设置指(Set Pointers)— 告诉 Open-GL ES 在缓存中的数据的类型和所有需 要的数据的内存移值。
6)绘图(Draw) — 告诉 OpenGL ES 使用当前定并启用的缓存中的数据渲染 整个场景或者某个场景的一部分。
7)删除除(Delete)— 告诉 OpenGL ES 除以前生成的缓存并释相关的资源。

当显示一个UIImageView时,Core Animation会创建一个OpenGL ES纹理,并确保在这个图层中的位图被上传到对应的纹理中。当你重写-drawInContext方法时,Core Animation会请求分配一个纹理,同时确保Core Graphics会将你在-drawInContext中绘制的东西放入到纹理的位图数据中。

iOS 操作系统不会让应用直接向前帧缓存或者 后帧缓存绘图,也不会让应用直接复制前帧缓存和后帧缓存之间的切换。操作系统为自 己保留了这些操作,以便它可以随时使用 Core Animation 合成器来控制显示的最终外观

最终,生成前后帧缓存会再交由GPU进行最后一步的工作。

GPU渲染职能

GPU会根据生成的前后帧缓存数据,根据实际情况进行合成,其中造成GPU渲染负担的一般是:离屏渲染,图层混合,延迟加载。

  • 普通的Tile-Based渲染流程
    • CommandBuffer,接受OpenGL ES处理完毕的渲染指令;
    • Tiler,调用顶点着色器,把顶点数据进行分块(Tiling);
    • ParameterBuffer,接受分块完毕的tile和对应的渲染参数;
    • Renderer,调用片元着色器,进行像素渲染;
      -RenderBuffer,存储渲染完毕的像素;
  • 离屏渲染 —— 遮罩(Mask)
    • 渲染layer的mask纹理,同Tile-Based的基本渲染逻辑;
    • 渲染layer的content纹理,同Tile-Based的基本渲染逻辑;
    • Compositing操作,合并1、2的纹理;
  • 离屏渲染 ——UIVisiualEffectView
  • 渲染等待
  • 光栅化
  • 组透明度

GPU用来采集图片和形状,运行变换,应用文理和混合,最终把它们输送到屏幕上。

太多的几何结构会影响GPU速度,但这并不是GPU的瓶颈限制原因,但由于图层在显示之前要通过IPC发送到渲染服务器的时候(图层实际上是由很多小物体组成的特别重量级的对象),太多的图层就会引起CPU的瓶颈。

重绘。主要由重叠的半透明图层引起。GPU的填充比率(用颜色填充像素的比率)是有限的,所以要避免重绘。


IPC内部通信(进程间通信)

在研究这个问题的过程中,我有想过去看一下源码,试着去理解在视图完全渲染之前,IPC是如何调度的,可惜苹果并没有开源绘制过程中的代码。这里推荐官方文章给大家了解一下iOS中IPC是如何运作的。

苹果官方文档-Mach内核编程 IPC通信

前后帧缓存&Vsync信号

虽然我们不能看到苹果内部是如何实现的,但是苹果官方也提供了我们可以参考的对象,也就是VSync信号CADisplayLink对象。

iOS 的显示系统是由 VSync 信号驱动的,VSync 信号由硬件时钟生成,每秒钟发出 60 次(这个值取决设备硬件,比如 iPhone 真机上通常是 59.97)。iOS 图形服务接收到 VSync 信号后,会通过 IPC 通知到 App 内。App 的 Runloop 在启动后会注册对应的 CFRunLoopSource 通过 mach_port 接收传过来的时钟信号通知,随后 Source 的回调会驱动整个 App 的动画与显示。

帧缓存:接收渲染结果的缓冲区,为GPU指定存储渲染结果的区域

帧缓存可以同时存在多个,但是屏幕显示像素受到保存在前帧缓存(front frame buffer)的特定帧缓存中的像素颜色元素的控制。
程序的渲染结果通常保存在后帧缓存(back frame buffer)在内的其他帧缓存,当渲染后的后帧缓存完成后,前后帧缓存会互换。(这部分操作由操作系统来完成)

前帧缓存决定了屏幕上显示的像素颜色,会在适当的时候与后帧缓存切换。

Core Animation的合成器会联合OpenGL ES层和UIView层、StatusBar层等,在后帧缓存混合产生最终的颜色,并切换前后帧缓存;
OpenGL ES坐标是以浮点数来存储,即使是其他数据类型的顶点数据也会被转化成浮点型;


视图加载

那么在了解iOS视图渲染流程以后,再来看一下第二题:
一个UIImageView添加到视图上以后,内部是如何渲染到手机上的,请简述其流程?

图片的显示分为三步:加载、解码、渲染。
通常,我们操作的只有加载,解码和渲染是由UIKit进行。
以UIImageView为例。当其显示在屏幕上时,需要UIImage作为数据源。
UIImage持有的数据是未解码的压缩数据,能节省较多的内存和加快存储。
当UIImage被赋值给UIImage时(例如imageView.image = image;),图像数据会被解码,变成RGB的颜色数据。
解码是一个计算量较大的任务,且需要CPU来执行;并且解码出来的图片体积与图片的宽高有关系,而与图片原来的体积无关。
此处引用-->iOS性能优化——图片加载和处理

我查看了较为流行的第三方库源码,例如YYImage、SDWebImage、FastImageCache,其中加载一个图片的流程大致为:

  1. 查看UIImageView的API我们可以发现,UIImage封装了一个CoreGraphics/CGImage的对象。
    1.+[UIImage imageWithContentsOfFile:]使用Image I/O创建CGImageRef内存映射数据。此时,图像尚未解码。
  2. 返回的图像被分配给UIImageView。
  3. 如果图像数据为未解码的PNG/JPG,解码为位图数据
  4. 隐式CATransaction捕获到UIImageView layer树的变化
  5. 在主运行循环的下一次迭代中,Core Animation提交隐式事务,这会涉及创建已设置为层内容的所有图像的副本,根据图像:
    1. 缓冲区被分配用于管理文件IO和解压缩操作。
    2. 文件数据从磁盘读入内存。
    3. 压缩的图像数据被解码成其未压缩的位图形式
    4. Core Animation使用未压缩的位图数据来渲染图层。

再看一下YYImage的源码,其流程也大致为:

  1. 获取图片二进制数据
  2. 创建一个CGImageRef对象
  3. 使用CGBitmapContextCreate()方法创建一个上下文对象
  4. 使用CGContextDrawImage()方法绘制到上下文
  5. 使用CGBitmapContextCreateImage()生成CGImageRef对象。
  6. 最后使用imageWithCGImage()方法将CGImage转化为UIImage。

当然YYImage不止做了这些,还有解码器编码器,支持webP等多种格式,并且还写了自定义的操作队列,对网络加载图片进行了优化。在此不赘述。

推荐文章:
苹果官方文档-CGImage位图
iOS图片加载速度极限优化—FastImageCache解析
Image I/O详解的文章
在这里同时推荐Y大的两篇文章
移动端图片格式调研
iOS 处理图片的一些小 Tip

视图渲染优化&卡顿优化

接下来我们看一下第三题:在一个表内有很多cell,每个cell上有很多个视图,如何解决卡顿问题?

什么是卡顿?苹果官方文章-显示帧率

当你的主线程操作卡顿超过16.67ms以后,你的应用就会出现掉帧,丢帧的情况。也就是卡顿。

一般来说造成卡顿的原因,就是CPU负担过重,响应时间过长。主要原因有以下几种:

  • 隐式绘制 CGContext
  • 文本CATextLayer 和 UILabel
  • 光栅化 shouldRasterize
  • 离屏渲染
  • 可伸缩图片
  • shadowPath
  • 混合和过度绘制
  • 减少图层数量
  • 裁切
  • 对象回收
  • Core Graphics绘制
  • -renderInContext: 方法

其中最常见的问题就是离屏渲染

离屏渲染:离屏绘制发生在基于CPU或者是GPU的渲染,或者是为离屏图 片分配额外内存,以及切换绘制上下文,这些都会降低GPU性能。对于特定图 层效果的使用,比如圆角,图层遮罩,阴影或者是图层光栅化都会强制Core Animation提前渲染图层的离屏绘制。

如果视图绘制超出GPU支持的2048x2048或者4096x4096尺寸的 纹理,就必须要用CPU在图层每次显示之前对图片预处理,同样也会降低性能。

那么如何在需要渲染大量视图的情况下,还能保证流畅度,也就是保证FPS。
在这里推荐阅读郭曜源前辈的iOS 保持界面流畅的技巧
以及indulge_in的YYAsyncLayer剖析
我参考了YYAsyncLayer,他其中的原理大致是这样的:

YYAsyncLayer原理

YYAsyncLayer 是 CALayer 的子类,当它需要显示内容(比如调用了 [layer setNeedDisplay])时,它会向 delegate,也就是 UIView 请求一个异步绘制的任务。在异步绘制时,Layer 会传递一个 BOOL(^isCancelled)() 这样的 block,绘制代码可以随时调用该 block 判断绘制任务是否已经被取消。

当 TableView 快速滑动时,会有大量异步绘制任务提交到后台线程去执行。但是有时滑动速度过快时,绘制任务还没有完成就可能已经被取消了。如果这时仍然继续绘制,就会造成大量的 CPU 资源浪费,甚至阻塞线程并造成后续的绘制任务迟迟无法完成。我的做法是尽量快速、提前判断当前绘制任务是否已经被取消;在绘制每一行文本前,我都会调用 isCancelled() 来进行判断,保证被取消的任务能及时退出,不至于影响后续操作。

AsyncDisplayKit原理

ASDK 在此处模拟了 Core Animation 的这个机制:所有针对 ASNode 的修改和提交,总有些任务是必需放入主线程执行的。当出现这种任务时,ASNode 会把任务用 ASAsyncTransaction(Group) 封装并提交到一个全局的容器去。ASDK 也在 RunLoop 中注册了一个 Observer,监视的事件和 CA 一样,但优先级比 CA 要低。当 RunLoop 进入休眠前、CA 处理完事件后,ASDK 就会执行该 loop 内提交的所有任务。

Tips

优化方案围绕着 使用多线程调用,合理利用CPU计算位置,布局,层次,解压等,再合理调度GPU进行渲染,GPU负担常常要比CPU大,合理调度CPU进行计算可以减轻GPU渲染负担,使应用更加流畅。


Metal渲染引擎

当你现在再去查阅官方文档时,你会发现苹果官方已经使用Metal去替代OpenGL ES作为Core Animation的渲染。

苹果将Metal作为新的渲染引擎,更好的利用了GPU的性能,同时保证了低内存占用和省电,但我个人并没有深入研究Metal,这里可以有兴趣的同学可以看一下落影前辈的文章:
Metal入门教程总结
Metal入门教程(八)Metal与OpenGL ES交互
OpenGL 专题


事件响应链&原理

最后一题:UIView和CALayer的区别?

如果你已经做了几年iOS开发,相比对于这道题可能已经很熟悉。
最直接的回答就是UIView可以响应用户事件,而CALayer不能处理事件

首先要讲一下App中的事件响应链,它分为两部分:Hit-Testing事件传递 & Runloop原理

当用户对屏幕进行了操作,产生了一个用户事件。

苹果注册了一个 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事件都是在这个回调中完成的。
此处引用-->深入理解Runloop-事件响应


当前前台运行中应用接收到UIEvent以后,当用户对屏幕进行了操作,系统先循环调用Hit-test遍历视图栈里的视图,顺序为视图层次的逆顺序,用Responder Chain响应链传递一层层给根视图AppDelegate处理。-->苹果官方文档-使用响应者和响应者链来处理事件

推荐两篇文章:
iOS 事件处理机制与图像渲染过程
iOS事件响应链中Hit-Test View的应用

CALayer的职能

CALayer 并不清楚具体的响应链,所以不能直接处理触摸事件或者手势。但是它提供了-containsPoint:-hitTest:来判断是否一个触点在图层的范围之内。

与UIView不同,CALayer着重于图层的绘制,大致为以下职能:

  • 阴影、圆角、边框、蒙版、拉伸、transform、动画。
  • 寄宿图:你可以给CALayer.contents传递一个CGImage来进行渲染,也可以调用- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;方法进行绘制。但通常我们会使用UIView的drawRect方法
  • CATextLayer:直接将字符串使用Core Graphics写入图层
  • CATransformLayer:能够用于构造一个层级的3D结构

CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。

当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以在优化性能时,应该尽量避免调整视图层次、添加和移除视图。

使用图层关联的视图而不是单独使用 CALayer 的好处在于,你能在使用所
有 CALayer 底层特性的同时,也可以使用 UIView 的高级API(比如自动排版, 布局和事件处理)。做一些对性能特别挑剔的工作,比如对 UIView 一些可忽略不计的操作都会引 起显著的不同

关于UIView动画以及CALayer的动画这里推荐两篇文章:
iOS-UIView与CALayer动画原理
CALayer与iOS动画 讲解及使用

参考

本文大量借助了引用文章的文字描述,在此感谢各位作者的文章对本问题的理解起了很大的帮助。也希望各位能去原文发表自己的看法。谢谢~

总结

iOS开发要学的东西还有很多,因为时间的推移,每年的iOS岗位要求都在提高,导致我们在iOS开发岗位的同学要学习很多知识。例如Runtime、Runloop、音视频处理、视图渲染等,各位一起加油吧。

链接:https://www.jianshu.com/p/39b91ecaaac8

收起阅读 »

【SpringBoot + Mybatis系列】插件机制 Interceptor

【SpringBoot + Mybatis系列】插件机制 Interceptor 在 Mybatis 中,插件机制提供了非常强大的扩展能力,在 sql 最终执行之前,提供了四个拦截点,支持不同场景的功能扩展 Executor (update, q...
继续阅读 »



【SpringBoot + Mybatis系列】插件机制 Interceptor



在 Mybatis 中,插件机制提供了非常强大的扩展能力,在 sql 最终执行之前,提供了四个拦截点,支持不同场景的功能扩展



  • Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)

  • ParameterHandler (getParameterObject, setParameters)

  • ResultSetHandler (handleResultSets, handleOutputParameters)

  • StatementHandler (prepare, parameterize, batch, update, query)


本文将主要介绍一下自定义 Interceptor 的使用姿势,并给出一个通过自定义插件来输出执行 sql,与耗时的 case


I. 环境准备


1. 数据库准备


使用 mysql 作为本文的实例数据库,新增一张表


CREATE TABLE `money` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(20) NOT NULL DEFAULT '' COMMENT '用户名',
`money` int(26) NOT NULL DEFAULT '0' COMMENT '钱',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0',
`create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

2. 项目环境


本文借助 SpringBoot 2.2.1.RELEASE + maven 3.5.3 + IDEA进行开发


pom 依赖如下


<dependencies>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>

db 配置信息 application.yml


spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/story?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password:

II. 实例演示


关于 myabtis 的配套 Entity/Mapper 相关内容,推荐查看之前的系列博文,这里就不贴出来了,将主要集中在 Interceptor 的实现上


1. 自定义 interceptor


实现一个自定义的插件还是比较简单的,试下org.apache.ibatis.plugin.Interceptor接口即可


比如定义一个拦截器,实现 sql 输出,执行耗时输出


@Slf4j
@Component
@Intercepts(value = {@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
})

public class ExecuteStatInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// MetaObject 是 Mybatis 提供的一个用于访问对象属性的对象
MappedStatement statement = (MappedStatement) invocation.getArgs()[0];
BoundSql sql = statement.getBoundSql(invocation.getArgs()[1]);

long start = System.currentTimeMillis();
List<ParameterMapping> list = sql.getParameterMappings();
OgnlContext context = (OgnlContext) Ognl.createDefaultContext(sql.getParameterObject());
List<Object> params = new ArrayList<>(list.size());
for (ParameterMapping mapping : list) {
params.add(Ognl.getValue(Ognl.parseExpression(mapping.getProperty()), context, context.getRoot()));
}
try {
return invocation.proceed();
} finally {
System.out.println("------------> sql: " + sql.getSql() + "\n------------> args: " + params + "------------> cost: " + (System.currentTimeMillis() - start));
}
}

@Override
public Object plugin(Object o) {
return Plugin.wrap(o, this);
}

@Override
public void setProperties(Properties properties) {

}
}

注意上面的实现,核心逻辑在intercept方法,内部实现 sql 获取,参数解析,耗时统计


1.1 sql 参数解析说明


上面 case 中,对于参数解析,mybatis 是借助 Ognl 来实现参数替换的,因此上面直接使用 ognl 表达式来获取 sql 参数,当然这种实现方式比较粗暴


// 下面这一段逻辑,主要是OGNL的使用姿势
OgnlContext context = (OgnlContext) Ognl.createDefaultContext(sql.getParameterObject());
List<Object> params = new ArrayList<>(list.size());
for (ParameterMapping mapping : list) {
params.add(Ognl.getValue(Ognl.parseExpression(mapping.getProperty()), context, context.getRoot()));
}

除了上面这种姿势之外,我们知道最终 mybatis 也是会实现 sql 参数解析的,如果有分析过源码的小伙伴,对下面这种姿势应该比较熟悉了


源码参考自: org.apache.ibatis.scripting.defaults.DefaultParameterHandler#setParameters


BoundSql sql = statementHandler.getBoundSql();
DefaultParameterHandler handler = (DefaultParameterHandler) statementHandler.getParameterHandler();
Field field = handler.getClass().getDeclaredField("configuration");
field.setAccessible(true);
Configuration configuration = (Configuration) ReflectionUtils.getField(field, handler);
// 这种姿势,与mybatis源码中参数解析姿势一直
//
MetaObject mo = configuration.newMetaObject(sql.getParameterObject());
List<Object> args = new ArrayList<>();
for (ParameterMapping key : sql.getParameterMappings()) {
args.add(mo.getValue(key.getProperty()));
}

但是使用上面这种姿势,需要注意并不是所有的切点都可以生效;这个涉及到 mybatis 提供的四个切点的特性,这里也就不详细进行展开,在后面的源码篇,这些都是绕不过去的点


1.2 Intercepts 注解


接下来重点关注一下类上的@Intercepts注解,它表明这个类是一个 mybatis 的插件类,通过@Signature来指定切点


其中的 type, method, args 用来精确命中切点的具体方法


如根据上面的实例 case 进行说明


@Intercepts(value = {@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
})


首先从切点为Executor,然后两个方法的执行会被拦截;这两个方法的方法名分别是query, update,参数类型也一并定义了,通过这些信息,可以精确匹配Executor接口上定义的类,如下


// org.apache.ibatis.executor.Executor

// 对应第一个@Signature
<E> List<E> query(MappedStatement var1, Object var2, RowBounds var3, ResultHandler var4) throws SQLException;

// 对应第二个@Signature
int update(MappedStatement var1, Object var2) throws SQLException;

1.3 切点说明


mybatis 提供了四个切点,那么他们之间有什么区别,什么样的场景选择什么样的切点呢?


一般来讲,拦截ParameterHandler是最常见的,虽然上面的实例是拦截Executor,切点的选择,主要与它的功能强相关,想要更好的理解它,需要从 mybatis 的工作原理出发,这里将只做最基本的介绍,待后续源码进行详细分析



  • Executor:代表执行器,由它调度 StatementHandler、ParameterHandler、ResultSetHandler 等来执行对应的 SQL,其中 StatementHandler 是最重要的。

  • StatementHandler:作用是使用数据库的 Statement(PreparedStatement)执行操作,它是四大对象的核心,起到承上启下的作用,许多重要的插件都是通过拦截它来实现的。

  • ParameterHandler:是用来处理 SQL 参数的。

  • ResultSetHandler:是进行数据集(ResultSet)的封装返回处理的,它非常的复杂,好在不常用。


借用网上的一张 mybatis 执行过程来辅助说明




原文 blog.csdn.net/weixin_3949…



2. 插件注册


上面只是自定义插件,接下来就是需要让这个插件生效,也有下面几种不同的姿势


2.1 Spring Bean


将插件定义为一个普通的 Spring Bean 对象,则可以生效


2.2 SqlSessionFactory


直接通过SqlSessionFactory来注册插件也是一个非常通用的做法,正如之前注册 TypeHandler 一样,如下


@Bean(name = "sqlSessionFactory")
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dataSource);
bean.setMapperLocations(
// 设置mybatis的xml所在位置,这里使用mybatis注解方式,没有配置xml文件
new PathMatchingResourcePatternResolver().getResources("classpath*:mapping/*.xml"));
// 注册typehandler,供全局使用
bean.setTypeHandlers(new Timestamp2LongHandler());
bean.setPlugins(new SqlStatInterceptor());
return bean.getObject();
}

2.3 xml 配置


习惯用 mybatis 的 xml 配置的小伙伴,可能更喜欢使用下面这种方式,在mybatis-config.xml全局 xml 配置文件中进行定义


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE configuration
PUBLIC "-//ibatis.apache.org//DTD Config 3.1//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">

<configuration>
<settings>
<!-- 驼峰下划线格式支持 -->
<setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>
<typeAliases>
<package name="com.git.hui.boot.mybatis.entity"/>
</typeAliases>

<!-- type handler 定义 -->
<typeHandlers>
<typeHandler handler="com.git.hui.boot.mybatis.handler.Timestamp2LongHandler"/>
</typeHandlers>

<!-- 插件定义 -->
<plugins>
<plugin interceptor="com.git.hui.boot.mybatis.interceptor.SqlStatInterceptor"/>
<plugin interceptor="com.git.hui.boot.mybatis.interceptor.ExecuteStatInterceptor"/>
</plugins>
</configuration>

3. 小结


本文主要介绍 mybatis 的插件使用姿势,一个简单的实例演示了如果通过插件,来输出执行 sql,以及耗时


自定义插件实现,重点两步



  • 实现接口org.apache.ibatis.plugin.Interceptor

  • @Intercepts 注解修饰插件类,@Signature定义切点


插件注册三种姿势:



  • 注册为 Spring Bean

  • SqlSessionFactory 设置插件

  • myabtis.xml 文件配置



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

前端这个工种未来会继续拆分么?

作为前端,你和UI撕过逼么?脑中的场景前端:“上线日期定死了,你什么时候出设计稿?你不出稿子后面开发、测试都得加班!”UI:“快了快了,别催~”前端:“做好的先给我吧,我画静态页面”UI:“快了快了,别催~”前端流泪,后端沉默,终究测试承担了所有......你...
继续阅读 »

作为前端,你和UI撕过逼么?

脑中的场景

前端:“上线日期定死了,你什么时候出设计稿?你不出稿子后面开发、测试都得加班!”

UI:“快了快了,别催~”

前端:“做好的先给我吧,我画静态页面”

UI:“快了快了,别催~”

前端流泪,后端沉默,终究测试承担了所有......

你遇到过这种情况么?

您觉得本质原因是什么?如何才能最高效解决这个问题?

本文会提供一种思路以及可借鉴的产品。

欢迎文末就这个问题讨论

问题原因

现代 Web 开发困境与破局一文中,作者牛岱谈到当前前端与UI的配合模式如下:

图片来自“现代 Web 开发困境与破局”

UI在设计软件上完成设计逻辑、绘制页面样式,交付给前端。

前端根据UI绘制的样式重现用CSS+HTML在网页中再绘制一遍样式,绘制完毕后再添加功能逻辑。

为什么UI用设计软件绘制的页面样式,前端还需要重复绘制一次?仅仅因为UI用设计软件,而前端需要编程么?

所以,理想的分工应该如下:

图片来自“现代 Web 开发困境与破局”

UI完成设计逻辑与页面样式(通过设计软件),软件根据规范生成前端可用的静态页面代码,前端基于生成的代码编写功能逻辑。

大白话讲就是:

前端不用画静态页了

虽然这套流程有诸多难点需要解决,比如:

  • 对于UI来说,页面是一张张图层,对于前端则是一个个组件,怎么对齐这两者差异
  • 需要UI了解基本的页面布局(浮动、flex、绝对定位...),才能生成符合响应式规范的静态页

但是,瑕不掩瑜,如果能跑通这套流程,开发效率将极大提升。

mitosis就是这方面的一次大胆尝试。

一次大胆尝试

BuilderIO是一家低代码平台,主做拖拽生成页面。mitosis的作者是BuilderIOCEO

用一张图概括mitosis的定位:

左起第一排分别是:sketchFigmaBuilderIO,前两者是知名设计软件,后者是低代码平台。

UI使用这些软件完成页面设计,经由插件输出到mitosis后,mitosis能将其输出成多种知名前端框架代码。

设计图一步到位变成前端框架代码,前端就不用画静态页了。

他是怎么做到的?

现代前端框架都是以组件作为逻辑、视图的分割单元。而组件是可以被描述的。

比如ReactFiberVueVNode,都是描述组件信息的节点类型。

mitosis将设计图转化为框架无关的JSON,类似这样:

{
"@type": "@builder.io/mitosis/component",
"state": {
"name": "Steve"
},
"nodes": [
{
"@type": "@builder.io/mitosis/node",
"name": "div",
"children": [
{
"@type": "@builder.io/mitosis/node",
"bindings": {
"value": "state.name",
"onChange": "state.name = event.target.value"
}
}
]
}
]
}


这段JSON描述的是一个component类型(即组件),其包含状态namenodes代表组件对应的视图。

如果输出目标是React,那么代码如下:

export function MyComponent() {
const [name, updateName] = useState('Steve');

return (
<div>
<input
value={name}
onChange={(e) => updateName(e.target.value)}
/>
div>
);
}


小小心机

如果你仔细看这张图会发现,mitosis还能反向输出到设计软件。

是的,mitosis本身也是个框架。有意思的是,他更像是个前端框架缝合怪

他采用了:

  • ReactHooks语法
  • Vue的响应式更新
  • Solid.js的静态JSX
  • Svelte的预编译技术
  • Angular的规范

上面的代码例子,如果用mitosis语法写:

export function MyComponent() {
const state = useState({
name: 'Steve',
});

return (
<div>
<input
value={state.name}
onChange={(e) => (state.name = e.target.value)}
/>
div>
);
}

未曾设想的道路?

我们在开篇谈到阻碍前端直接使用设计软件生成静态代码的两个痛点:

  • 对于UI来说,页面是一张张图层,对于前端则是一个个组件,怎么对齐这两者差异
  • 需要UI了解基本的页面布局(浮动、flex、绝对定位...),才能生成复合响应式规范的静态页

我们设想一下,当使用mitosis开启一个新项目,流程如下:

  1. 由懂设计的前端基于mitosis开发初始代码
  2. 代码输出为设计稿
  3. 专业UI基于设计稿(符合组件规范、响应式规范)润色
  4. 设计稿经由mitosis输出为任意前端框架代码
  5. 前端基于框架代码开发

这样,就解决了以上痛点。

总结

在项目开发过程中,前端需要与后端配合。久而久之,一部分前端同学涉足接口转发的中间层,成为业务+Node工程师。

同样,前端也需要与UI配合,会不会如上文所设想,未来会出现一批UI+前端工程师呢?

收起阅读 »

【Web动画】科技感十足的暗黑字符雨动画

本文将使用纯 CSS,带大家一步一步实现一个这样的科幻字符跳动背景动画。类似于这样的字符雨动画: 或者是类似于这样的: 运用在一些类似科技主题的背景之上,非常的添彩。 文字的竖排 首先第一步,就是需要实现文字的竖向排列: 这一步非常的简单,可能方法也很多...
继续阅读 »

本文将使用纯 CSS,带大家一步一步实现一个这样的科幻字符跳动背景动画。类似于这样的字符雨动画:


Digital Char Rain Animation


或者是类似于这样的:


CodePen Home<br />
Matrix digital rain (animated version) By yuanchuan


运用在一些类似科技主题的背景之上,非常的添彩。


文字的竖排


首先第一步,就是需要实现文字的竖向排列:



这一步非常的简单,可能方法也很多,这里我简单罗列一下:



  1. 使用控制文本排列的属性 writing-mode 进行控制,可以通过 writing-mode: vertical-lr 等将文字进行竖向排列,但是对于数字和英文,将会旋转 90° 展示:


<p>1234567890ABC</p>
<p>中文或其他字符ォヶ</p>

p {
writing-mode: vertical-lr;
}


当然这种情况下,英文字符的展示不太满足我们的需求。



  1. 控制容器的宽度,控制每行只能展示 1 个中文字符。


这个方法算是最简单便捷的方法了,但是由于英文的特殊性,要让连续的长字符串自然的换行,我们还需要配合 word-break: break-all


p {
width: 12px;
font-size: 10px;
word-break: break-all;
}

效果如下,满足需求:



使用 CSS 实现随机字符串的选取


为了让我们的效果更加自然。每一行的字符的选取最好是随机的。


但是要让 CSS 实现随机生成每一行的字符可太难了。所以这里我们请出 CSS 预处理器 SASS/LESS 。


而且由于不太可能利用 CSS 给单个标签内,譬如 <p> 标签插入字符,所以我们把标签内的字符展示,放在每个 <p> 元素的伪元素 ::beforecontent 当中。


我们可以提前设置好一组字符串,然后利用 SASS function 随机生成每一次元素内的 content,伪代码如下:


<div>
<p></p>
<p></p>
<p></p>
</div>

$str: 'ぁぃぅぇぉかきくけこんさしすせそた◁▣▤▥▦▧♂♀♥☻►◄▧▨♦ちつってとゐなにぬねのはひふへほゑまみむめもゃゅょゎをァィゥヴェォカヵキクケヶコサシスセソタチツッテトヰンナニヌネノハヒフヘホヱマミムメモャュョヮヲㄅㄉㄓㄚㄞㄢㄦㄆㄊㄍㄐㄔㄗㄧㄛㄟㄣㄇㄋㄎㄑㄕㄘㄨㄜㄠㄤㄈㄏㄒㄖㄙㄩㄝㄡㄥabcdefghigklmnopqrstuvwxyz123456789%@#$<>^&*_+';
$length: str-length($str);

@function randomChar() {
$r: random($length);
@return str-slice($str, $r, $r);
}

@function randomChars($number) {
$value: '';

@if $number > 0 {
@for $i from 1 through $number {
$value: $value + randomChar();
}
}
@return $value;
}

p:nth-child(1)::before {
content: randomChars(25);
}
p:nth-child(2)::before {
content: randomChars(25);
}
p:nth-child(3)::before {
content: randomChars(25);
}

简单解释下上面的代码:



  1. $str 定义了一串随机字符串,$length 表示字符串的长度

  2. randomChar() 中利用了 SASS 的 random() 方法,每次随机选取一个 0 - $length 的整形数,记为 $r,再利用 SASS 的 str-slice 方法,每次从 $str 中选取一个下标为 $r 的随机字符

  3. randomChars() 就是循环调用 randomChar() 方法,从 $str 中随机生成一串字符串,长度为传进去的参数 $number


这样,每一列的字符,每次都是不一样的:




当然,上述的方法我认为不是最好的,CSS 的伪元素的 content 是支持字符编码的,譬如 content: '\3066'; 会被渲染成字符 ,这样,通过设定字符区间,配合 SASS function 可以更好的生成随机字符,但是我尝试了非常久,SASS function 生成的最终产物会在 \3066 这样的数字间添加上空格,无法最终通过字符编码转换成字符,最终放弃...



使用 CSS 实现打字效果


OK,继续,接下来我们要使用 CSS 实现打字效果,就是让字符一个一个的出现,像是这样:


纯 CSS 实现文字输入效果


这里借助了 animation 的 steps 的特性实现,也就是逐帧动画。


从左向右和从上向下原理是一样的,以从左向右为例,假设我们有 26 个英文字符,我们已知 26 个英文字符组成的字符串的长度,那么我们只需要设定一个动画,让它的宽度变化从 0 - 100% 经历 26 帧即可,配合 overflow: hidden,steps 的每一帧即可展出一个字符。


当然,这里需要利用一些小技巧,我们如何通过字符的数量知道字符串的长度呢?


划重点:通过等宽字体的特性,配合 CSS 中的 ch 单位



如果不了解什么是等宽字体族,可以看看我的这篇文章 -- 《你该知道的字体 font-family》



CSS 中,ch 单位表示数字 “0” 的宽度。如果字体恰巧又是等宽字体,即每个字符的宽度是一样的,此时 ch 就能变成每个英文字符的宽度,那么 26ch 其实也就是整个字符串的长度。


利用这个特性,配合 animation 的 steps,我们可以轻松的利用 CSS 实现打字动画效果:


<h1>Pure CSS Typing animation.</h1>

h1 {
font-family: monospace;
width: 26ch;
white-space: nowrap;
overflow: hidden;
animation: typing 3s steps(26, end);
}

@keyframes typing {
0{
width: 0;
}
100% {
width: 26ch;
}
}

就可以得到如下结果啦:


纯 CSS 实现文字输入效果


完整的代码你可以戳这里:


CodePen Demo -- 纯 CSS 实现文字输入效果


改造成竖向打字效果


接下来,我们就运用上述技巧,改造一下。将一个横向的打字效果改造成竖向的打字效果。


核心的伪代码如下:


<div>
<p></p>
<p></p>
<p></p>
</div>

$str: 'ぁぃぅぇぉかきくけこんさしすせそた◁▣▤▥▦▧♂♀♥☻►◄▧▨♦ちつってとゐなにぬねのはひふへほゑまみむめもゃゅょゎをァィゥヴェォカヵキクケヶコサシスセソタチツッテトヰンナニヌネノハヒフヘホヱマミムメモャュョヮヲㄅㄉㄓㄚㄞㄢㄦㄆㄊㄍㄐㄔㄗㄧㄛㄟㄣㄇㄋㄎㄑㄕㄘㄨㄜㄠㄤㄈㄏㄒㄖㄙㄩㄝㄡㄥabcdefghigklmnopqrstuvwxyz123456789%@#$<>^&*_+';
$length: str-length($str);

@function randomChar() {
$r: random($length);
@return str-slice($str, $r, $r);
}

@function randomChars($number) {
$value: '';

@if $number > 0 {
@for $i from 1 through $number {
$value: $value + randomChar();
}
}
@return $value;
}

p {
width: 12px;
font-size: 10px;
word-break: break-all;
}

p::before {
content: randomChars(20);
color: #fff;
animation: typing 4s steps(20, end) infinite;
}

@keyframes typing {
0% {
height: 0;
}
25% {
height: 100%;
}
100% {
height: 100%;
}
}

这样,我们就实现了竖向的打字效果:



当然,这样看上去比较整齐划一,缺少了一定的随机,也就缺少了一定的美感。


基于此,我们进行 2 点改造:



  1. 基于动画的时长 animation-time、和动画的延迟 animation-delay,增加一定幅度内的随机

  2. 在每次动画的末尾或者过程中,重新替换伪元素的 content,也就是重新生成一份 content


可以借助 SASS 非常轻松的实现这一点,核心的 SASS 代码如下:


$n: 3;
$animationTime: 3;
$perColumnNums: 20;

@for $i from 0 through $n {
$content: randomChars($perColumnNums);
$contentNext: randomChars($perColumnNums);
$delay: random($n);
$randomAnimationTine: #{$animationTime + random(20) / 10 - 1}s;

p:nth-child(#{$i})::before {
content: $content;
color: #fff;
animation: typing-#{$i} $randomAnimationTine steps(20, end) #{$delay * 0.1s * -1} infinite;
}

@keyframes typing-#{$i} {
0% {
height: 0;
}
25% {
height: 100%;
}
100% {
height: 100%;
content: $contentNext;
}
}
}

看看效果,已经有不错的改观:



当然,上述由横向打字转变为竖向打字效果其实是有一些不一样的。在现有的竖向排列规则下,无法通过 ch 配合字符数拿到实际的竖向高度。所以这里有一定的取舍,实际放慢动画来看,没个字的现出不一定是完整的。


当然,在快速的动画效果下几乎是察觉不到的。


增加光影与透明度变化


最后一步,就是增加光影及透明度的变化。


最佳的效果是要让每个新出现的字符保持亮度最大,同时已经出现过的字符亮度慢慢减弱。


但是由于这里我们无法精细操控每一个字符,只能操控每一行字符,所以在实现方式上必须另辟蹊径。


最终的方式是借用了另外一个伪元素进行同步的遮罩以实现最终的效果。下面我们就来一步一步看看过程。


给文字增添亮色及高光


第一步就是给文字增添亮色及高光,这点非常容易,就是选取一个黑色底色下的亮色,并且借助 text-shadow 让文字发光。


p::before {
color: rgb(179, 255, 199);
text-shadow: 0 0 1px #fff, 0 0 2px #fff, 0 0 5px currentColor, 0 0 10px currentColor;
}

看看效果,左边是白色字符,中间是改变字符颜色,右边是改变了字体颜色并且添加了字体阴影的效果:



给文字添加同步遮罩


接下来,就是在文字动画的行进过程中,同步添加一个黑色到透明的遮罩,尽量还原让每个新出现的字符保持亮度最大,同时已经出现过的字符亮度慢慢减弱。


这个效果的示意图大概是这样的,这里我将文字层和遮罩层分开,并且底色从黑色改为白色,方便理解:


蒙层遮罩原理图


大概的遮罩的层的伪代码如下,用到了元素的另外一个伪元素:


p::after {
content: '';
background: linear-gradient(rgba(0, 0, 0, .9), transparent 75%, transparent);
background-size: 100% 220%;
background-repeat: no-repeat;
animation: mask 4s infinite linear;
}

@keyframes mask {
0% {
background-position: 0 220%;
}
30% {
background-position: 0 0%;
}
100% {
background-position: 0 0%;
}
}

好,合在一起的最终效果大概就是这样:



通过调整 @keyframes mask 的一些参数,可以得到不一样的字符渐隐效果,需要一定的调试。


完整代码及效果


OK,拆解了一下主要的步骤,最后上一下完整代码,应用了 Pug 模板引擎和 SASS 语法。


完整代码加起来不过 100 行。


.g-container
-for(var i=0; i<50; i++)
p

@import url('https://fonts.googleapis.com/css2?family=Inconsolata:wght@200&display=swap');

$str: 'ぁぃぅぇぉかきくけこんさしすせそた◁▣▤▥▦▧♂♀♥☻►◄▧▨♦ちつってとゐなにぬねのはひふへほゑまみむめもゃゅょゎをァィゥヴェォカヵキクケヶコサシスセソタチツッテトヰンナニヌネノハヒフヘホヱマミムメモャュョヮヲㄅㄉㄓㄚㄞㄢㄦㄆㄊㄍㄐㄔㄗㄧㄛㄟㄣㄇㄋㄎㄑㄕㄘㄨㄜㄠㄤㄈㄏㄒㄖㄙㄩㄝㄡㄥabcdefghigklmnopqrstuvwxyz123456789%@#$<>^&*_+';
$length: str-length($str);
$n: 50;
$animationTime: 4;
$perColumnNums: 25;

@function randomChar() {
$r: random($length);
@return str-slice($str, $r, $r);
}

@function randomChars($number) {
$value: '';

@if $number > 0 {
@for $i from 1 through $number {
$value: $value + randomChar();
}
}
@return $value;
}

body, html {
width: 100%;
height: 100%;
background: #000;
display: flex;
overflow: hidden;
}

.g-container {
width: 100vw;
display: flex;
justify-content: space-between;
flex-wrap: nowrap;
flex-direction: row;
font-family: 'Inconsolata', monospace, sans-serif;
}

p {
position: relative;
width: 5vh;
height: 100vh;
text-align: center;
font-size: 5vh;
word-break: break-all;
white-space: pre-wrap;

&::before,
&::after {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
overflow: hidden;
}
}

@for $i from 0 through $n {
$content: randomChars($perColumnNums);
$contentNext: randomChars($perColumnNums);
$delay: random($n);
$randomAnimationTine: #{$animationTime + random(20) / 10 - 1}s;

p:nth-child(#{$i})::before {
content: $content;
color: rgb(179, 255, 199);
text-shadow: 0 0 1px #fff, 0 0 2px #fff, 0 0 5px currentColor, 0 0 10px currentColor;
animation: typing-#{$i} $randomAnimationTine steps(20, end) #{$delay * 0.1s * -1} infinite;
z-index: 1;
}

p:nth-child(#{$i})::after {
$alpha: random(40) / 100 + 0.6;
content: '';
background: linear-gradient(rgba(0, 0, 0, $alpha), rgba(0, 0, 0, $alpha), rgba(0, 0, 0, $alpha), transparent 75%, transparent);
background-size: 100% 220%;
background-repeat: no-repeat;
animation: mask $randomAnimationTine infinite #{($delay - 2) * 0.1s * -1} linear;
z-index: 2;
}

@keyframes typing-#{$i} {
0% {
height: 0;
}
25% {
height: 100%;
}
100% {
height: 100%;
content: $contentNext;
}
}
}

@keyframes mask{
0% {
background-position: 0 220%;
}
30% {
background-position: 0 0%;
}
100% {
background-position: 0 0%;
}
}

最终效果也就是题图所示:


Digital Char Rain Animation


完整的代码及演示效果你可以戳这里:


CodePen Demo -- Digital Char Rain Animation



链接:https://juejin.cn/post/6991657194282450951
收起阅读 »

前端button组件之涟漪效果

前言 在前端项目中,我们常常会使用到button组件进行事件的触发,而一些项目为了更好的交互效果,加入了一系列的动画,例如:脉冲、果冻、涟漪、滑箱等特效。 今天我们来讲讲如何使用HTML CSS和JavaScript来实现涟漪效果,我们先看下成品: 看完是...
继续阅读 »

前言


在前端项目中,我们常常会使用到button组件进行事件的触发,而一些项目为了更好的交互效果,加入了一系列的动画,例如:脉冲、果冻、涟漪、滑箱等特效。


今天我们来讲讲如何使用HTML CSSJavaScript来实现涟漪效果,我们先看下成品:


1.gif


5.png


看完是不是也想给自己项目整一个这样子的效果😎😎


原理


如图,我们需要两个元素来实现这个涟漪效果,当button被点击时,在button元素中放置一个元素,执行一个绽开动画效果,执行完毕后把buttion里的元素移除。


2.png


用码实现


码出基本样式

先创建一对div标签,作为一个基础按钮元素。后面我们将这对div称之为按钮。


<div id="btn" class="button">Click me</div>

为按钮添加基本样式,这里需要给按钮设定position:relative,后续我们涟漪效果是通过绝对定位来实现的。


.button {
   -webkit-user-select: none;
   -moz-user-select: none;
   -ms-user-select: none;
   user-select: none;
   position: relative;
   display: inline-block;
   color: #fff;
   padding: 14px 40px;
   background: linear-gradient(90deg, #0bc7f1, #c471ed);
   border-radius: 45px;
   margin: 0 15px;
   font-size: 24px;
   font-weight: 400;
   text-decoration: none;
   overflow: hidden;
   box-shadow: 1px 1px 3px #7459e9;
}

3.png


当样式写完之后我们按钮的样式就跟效果图上的按钮一模一样了,由于我们JavaScript部分还没有写以及实现涟漪效果还没有实现,此时我们点击按钮是没有涟漪效果的,接下来我们要就添加涟漪效果了。


👇 👇 👇 继续往下看 👇 👇 👇


码出链漪

给按钮添加一个涟漪效果,在按钮div中添加一个span标签,并绑定一个overlay


<div id="btn" class="button">
  Click me
   <span class="overlay"></span>
</div>

这个span标签是我们要实现涟漪效果的元素,给元素设置绝对定位,让元素脱离文件流,不为该元素预留出空间。默认我们定义在top:0left:0,再通过transform属性将元素偏移居中对齐。透明度设置0.5,绑定一个blink帧动画函数。


.overlay {
   position: absolute;
   height: 400px;
   width: 400px;
   background-color: #fff;
   top: 0;
   left: 0;
   transform: translate(-50%, -50%);
   border-radius: 50%;
   opacity: .5;
   animation: blink .5s linear infinite;
}

添加一个帧动画,命名为blink,将span元素的宽度,高度从0px过渡到400px,及透明度从设定的0.5过渡到0,渐渐向外绽开,这样子就形成了涟漪效果了,当我们把span元素挂载上去我们可以看下效果,接下来我们将通过JavaScript来获取鼠标点击位置来决定绽开的位置。


4.gif


注意


div中的span标签删除或者注释掉,后面我们将使用JavaScript来添加这个span标签


div中的span标签删除或者注释掉,后面我们将使用JavaScript来添加这个span标签


div中的span标签删除或者注释掉,后面我们将使用JavaScript来添加这个span标签


码出点击效果

这里我们先引入jQuery这个库,为了方便使用,这里我就使用cdn方式来引入。



这里给大家推荐一个国内的CDN库:http://www.bootcdn.cn



<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>

创建一个addRipple方法,先创建一个绑定overlay类的span标签,获取鼠标点击页面的xy值,绑定对应的left值和top值,绑定之后把span元素添加到div中。


设定一个定时器,当动画执行完毕后把span元素移除掉,减少内存的占用。


const addRipple = function (e) {
   let overlay = $("<span></span>")
   const x = e.clientX - e.target.offsetLeft
   const y = e.clientY - e.target.offsetTop;
   overlay.css(
      {
           left: x + 'px',
           top: y + 'px'
      }
  )
   $(this).append(overlay)
   setTimeout(() => {
       overlay.remove()
  }, 500)
}

div绑定addRipple事件,按钮就实现跟开头效果图一样的页面啦!


$('#btn').click(addRipple);

1.gif


5.png


链接:https://juejin.cn/post/6991752850644664333

收起阅读 »

我给公司封装的组件帮公司提效了60%

前置内容 在公司开发中或多或少都会有几个管理系统的项目,而对于这些系统大多页面都是表单、表格组成,为了不花费太多精力在这些不那么需要定制化的页面上,一般都会选择去用组件库完成,这个时候就如果将这些简单、枯燥的事情用配置项完成,把精力放在更有挑战的事情上,那么工...
继续阅读 »

前置内容


在公司开发中或多或少都会有几个管理系统的项目,而对于这些系统大多页面都是表单、表格组成,为了不花费太多精力在这些不那么需要定制化的页面上,一般都会选择去用组件库完成,这个时候就如果将这些简单、枯燥的事情用配置项完成,把精力放在更有挑战的事情上,那么工作摸鱼的时间又多了不少。下面就分享下我花了近一个月的时间为公司封装的组件。


涉及到的技术:



  • vue

  • element-ui


就基于上面两个来实现的,使用起来也非常简单,并不需要你去记太多prop,就是element哪些。你只需要这么简单的配置,表单项就出来了







1.gif


选择之后, testFormModel对象就会是这样


{
 treeProp: '三级 1-1-1',
}

为了方便使用, 我需要下拉提供这么几个功能:



  • 根据url动态请求数据(可携带参数,且参数变动之后重新发起请求)

  • 需要获取动态请求回来的数据

  • 需要提供数据格式化的能力(数据格式化显示是为了不变动数据的情况正确显示页面)

  • 需要实现过滤功能


{
 label: '树形下拉',
 // formModel绑定的属性
 prop: 'treeProp',
 type: 'treeSelect',
 url: 'xxxx',
 params: {
   query: 'all',
},
 resolveData: (data) => {
   this.xxx = data
}
 nodeKey: 'dyId',
 props: {
   label: 'name',
   children: 'sublevel'
},
 multiple: true,
 checkStrictly: false,
 filterable: true,
}

效果图💗


2.gif



简单的配置下这树形功能就非常的强大了,同样也支持用户自己去配置懒加载数据



{
 lazy: true,
 load: this.loadNode,
}

这个时候就不需要去配置动态请求的哪些的配置,由用户自己实例接口的懒加载数据请求


我们需要完成什么样的东西?


整体效果图💗


3.gif



  • 配合侧边栏,校验失败右侧对应label标红

  • 点击右侧label视图自动滚动到对应表单项,并激活表单项

  • 侧边栏可配置(可以需要,也可以不配置)



这就是后面我们要实现的组件库,本文只是抛转引玉,对后面要做的事件大概说下,后续文章会很详细的分享整个组件封装的架构和思路,对于表单的一些「原子组件」的实现,之前也分享过一些,感兴趣的可以关注我的专栏:组件封装最佳实践



表单支持的组件



  • el-input/el-autocomplete

  • el-select

  • treeSelect 「集成」

  • el-switch

  • el-checkbox/el-radio/el-raiod-group/el-checkbox-group

  • el-date-picker/el-time-picker

  • el-cascader/el-cascader-panel

  • table 「集成」


有些功能还在完善,暂时就没有共享代码。后续会发布到npm提供下载,通过Vue.use()使用插件的方式使用


4.png


如何使用?


main.js


// 引用插件
import './plugins'

plugins.js


import './element-ui'
import './dynamic-ui'


这套组件是依赖element-ui封装的,所以前提是需要使用element



dynamic-ui.js


import Vue from 'vue'
import dynamicUI from 'dynamic-ui'
import 'dynamic-ui/lib/index.scss'
// 向表单添加组件类型
import DynamicTable from '@/components/DynamicTable/src/index.vue'

import { getToken } from '@/utils/auth'
import request from '@/utils/request'

Vue.component(DynamicTable.name, DynamicTable)
Vue.use(dynamicUI, {
 request, // 动态请求数据的方法
 baseURI: process.env.VUE_APP_BASE_API,
 parseData: () => {}, // 解析接口返回数据的方法
 requestHeaders: { // 请求头
   Authorization: getToken()
},
 // 需要动态添加到表单组件的类型
 addFormComponent: [
  {
     type: 'table',
     name: DynamicTable.name
  }
]
})

组件库提供的功能



  • 传入[全局, 局部]的request

  • 传入[全局, 局部]的parseData

  • 传入requestHeaders请求头参数

  • 动态添加组件作为表单项


支持动态请求的数据组件



  • select

  • treeSelect

  • checkbox/radio

  • table

  • cascader/cascader-panel


以上几个组件类型在element基础上进行了扩展,允许用户动态请求数据,统一prop这样



三者的使用场景


这里分别说下parseData/formatter/resoveData的使用场景


parsseData


一般我们在使用axios都会封装响应拦截器,做业务码的统一处理,但一般不会去变动data,现在有个问题是这样的,后端返回的数据是这样的


{
code: '200',
message: 'xxx',
data: {
...
pageData: [] // 这个才是我们要的数据
}
}

只有个别数据是这样的格式,这个时候全局配置就不去动,可以允许去传递单个组件的parseData来解决这个问题


formatter


用于在不影响原有数据的情况下格式化数据以正确显示页面,比如这样


formatter: (value) => {
return `dy-${value}`
},

5.png


resolveData


获取响应式的数据:直接变动数据,可直接影响页面。


resolveData: (data) => {
console.log(data)
data[0].name = '欢迎关注:前端自学驿站'
},

6.png



以上所有类型都会在请求参数变动之后重新请求数据,所以如果后端提供分页接口,前端也就能实现分页懒加载的功能



表格组件支持的类型


表格会支持



  • 展示模式

  • 编辑模式(支持所有表单组件类型,包括用户动态添加的)



本来打算将组件的功能大致的过一遍,但是写到这太晚了,有点肝不动了~,目前就是表格编辑模式这块还有些地方需要完善,后续完善测试通过之后就会立马发布,欢迎大家持续关注~



写在最后


如果文章中有那块写的不太好或有问题欢迎大家指出,我也会在后面的文章不停修改。也希望自己进步的同时能跟你们一起成长。喜欢我文章的朋友们也可以关注一下,我会很感激第一批关注我的人。此时,年轻的我和你,轻装上阵;而后,富裕的你和我,满载而归。


业精于勤,荒于嬉



链接:https://juejin.cn/post/6991481121540145160

收起阅读 »

面对 this 指向丢失,尤雨溪在 Vuex 源码中是怎么处理的

1. 前言简单再说说 this 指向和尤大在 Vuex 源码中是怎么处理 this 指向丢失的。 2. 对象中的this指向 var person = { name: '若川', say: function(text){ console.log...
继续阅读 »

1. 前言简单再说说 this 指向和尤大在 Vuex 源码中是怎么处理 this 指向丢失的。


2. 对象中的this指向


var person = {
name: '若川',
say: function(text){
console.log(this.name + ', ' + text);
}
}
console.log(person.name);
console.log(person.say('在写文章')); // 若川, 在写文章
var say = person.say;
say('在写文章'); // 这里的this指向就丢失了,指向window了。(非严格模式)

3. 类中的this指向


3.1 ES5


// ES5
var Person = function(){
this.name = '若川';
}
Person.prototype.say = function(text){
console.log(this.name + ', ' + text);
}
var person = new Person();
console.log(person.name); // 若川
console.log(person.say('在写文章'));
var say = person.say;
say('在写文章'); // 这里的this指向就丢失了,指向 window 了。

3.2 ES6


// ES6
class Person{
construcor(name = '若川'){
this.name = name;
}
say(text){
console.log(`${this.name}, ${text}`);
}
}
const person = new Person();
person.say('在写文章')
// 解构
const { say } = person;
say('在写文章'); // 报错 this ,因为ES6 默认启用严格模式,严格模式下指向 undefined

4. 尤大在Vuex源码中是怎么处理的


先看代码


class Store{
constructor(options = {}){
this._actions = Object.create(null);
// bind commit and dispatch to self
// 给自己 绑定 commit 和 dispatch
const store = this
const { dispatch, commit } = this
// 为何要这样绑定 ?
// 说明调用commit和dispach 的 this 不一定是 store 实例
// 这是确保这两个函数里的this是store实例
this.dispatch = function boundDispatch (type, payload) {
return dispatch.call(store, type, payload)
}
this.commit = function boundCommit (type, payload, options) {
return commit.call(store, type, payload, options)
}
}
dispatch(){
console.log('dispatch', this);
}
commit(){
console.log('commit', this);
}
}
const store = new Store();
store.dispatch(); // 输出结果 this 是什么呢?

const { dispatch, commit } = store;
dispatch(); // 输出结果 this 是什么呢?
commit(); // 输出结果 this 是什么呢?

输出结果截图


结论:非常巧妙的用了calldispatchcommit函数的this指向强制绑定到store实例对象上。如果不这么绑定就报错了。


4.1 actions 解构 store


其实Vuex源码里就有上面解构const { dispatch, commit } = store;的写法。想想我们平时是如何写actions的。actions中自定义函数的第一个参数其实就是 store 实例。


这时我们翻看下actions文档https://vuex.vuejs.org/zh/guide/actions.html


const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment (state) {
state.count++
}
},
actions: {
increment (context) {
context.commit('increment')
}
}
})

也可以用解构赋值的写法。


actions: {
increment ({ commit }) {
commit('increment')
}
}

有了Vuex源码构造函数里的call绑定,这样this指向就被修正啦~不得不说祖师爷就是厉害。这一招,大家可以免费学走~


接着我们带着问题,为啥上文中的context就是store实例,有dispatchcommit这些方法呢。继续往下看。


4.2 为什么 actions 对象里的自定义函数 第一个参数就是 store 实例。


以下是简单源码,有缩减,感兴趣的可以看我的文章 Vuex 源码文章


class Store{
construcor(){
// 初始化 根模块
// 并且也递归的注册所有子模块
// 并且收集所有模块的 getters 放在 this._wrappedGetters 里面
installModule(this, state, [], this._modules.root)
}
}

接着我们看installModule函数中的遍历注册 actions 实现


function installModule (store, rootState, path, module, hot) {
// 省略若干代码
// 循环遍历注册 action
module.forEachAction((action, key) => {
const type = action.root ? key : namespace + key
const handler = action.handler || action
registerAction(store, type, handler, local)
})
}

接着看注册 actions 函数实现 registerAction


/**
* 注册 mutation
* @param {Object} store 对象
* @param {String} type 类型
* @param {Function} handler 用户自定义的函数
* @param {Object} local local 对象
*/
function registerAction (store, type, handler, local) {
const entry = store._actions[type] || (store._actions[type] = [])
// payload 是actions函数的第二个参数
entry.push(function wrappedActionHandler (payload) {
/**
* 也就是为什么用户定义的actions中的函数第一个参数有
* { dispatch, commit, getters, state, rootGetters, rootState } 的原因
* actions: {
* checkout ({ commit, state }, products) {
* console.log(commit, state);
* }
* }
*/
let res = handler.call(store, {
dispatch: local.dispatch,
commit: local.commit,
getters: local.getters,
state: local.state,
rootGetters: store.getters,
rootState: store.state
}, payload)
// 源码有删减
}

比较容易发现调用顺序是 new Store() => installModule(this) => registerAction(store) => let res = handler.call(store)


其中handler 就是 用户自定义的函数,也就是对应上文的例子increment函数。store实例对象一路往下传递,到handler执行时,也是用了call函数,强制绑定了第一个参数是store实例对象。


actions: {
increment ({ commit }) {
commit('increment')
}
}

这也就是为什么 actions 对象中的自定义函数的第一个参数是 store 对象实例了。


好啦,文章到这里就基本写完啦~相对简短一些。应该也比较好理解。


5. 最后再总结下 this 指向


摘抄下面试官问:this 指向文章结尾。


如果要判断一个运行中函数的 this 绑定, 就需要找到这个函数的直接调用位置。 找到之后
就可以顺序应用下面这四条规则来判断 this 的绑定对象。



  1. new 调用:绑定到新创建的对象,注意:显示return函数或对象,返回值不是新创建的对象,而是显式返回的函数或对象。

  2. call 或者 apply( 或者 bind) 调用:严格模式下,绑定到指定的第一个参数。非严格模式下,nullundefined,指向全局对象(浏览器中是window),其余值指向被new Object()包装的对象。

  3. 对象上的函数调用:绑定到那个对象。

  4. 普通函数调用: 在严格模式下绑定到 undefined,否则绑定到全局对象。


ES6 中的箭头函数:不会使用上文的四条标准的绑定规则, 而是根据当前的词法作用域来决定this, 具体来说, 箭头函数会继承外层函数,调用的 this 绑定( 无论 this 绑定到什么),没有外层函数,则是绑定到全局对象(浏览器中是window)。 这其实和 ES6 之前代码中的 self = this 机制一样。




链接:https://juejin.cn/post/6991701614612447239


收起阅读 »

搞懂Objective-C中的ARC

写这篇文章的背景前段时间招人,面试了一个多月,有关内存的基础问题,能完全答出来的竟无一人,回答出百分之80的人也寥寥无几,于是决定写这篇文章,简单业务流水线道友们一般都能写出符合需求,可以正常工作的代码,稍微复杂点的也许也不再话下,一旦涉及到性能、鲁棒性等要求...
继续阅读 »

写这篇文章的背景

前段时间招人,面试了一个多月,有关内存的基础问题,能完全答出来的竟无一人,回答出百分之80的人也寥寥无几,于是决定写这篇文章,简单业务流水线道友们一般都能写出符合需求,可以正常工作的代码,稍微复杂点的也许也不再话下,一旦涉及到性能、鲁棒性等要求很高的项目,不能真正理解内存的程序员将给整个项目带来灾难和隐藏的坑,所以本文旨在让道友们真正理解内存,这是再基础不过的东西,然而又是必须知道的东西,让我们一起,重温下基础吧,本文不打算大量罗列源码,而是从显而易见的东西开始

先从一个小问题开始

面试官:alloc的对象都存储在堆上是吗?
候选人:是的
面试官:好的,静态变量存储在数据段是吗?
候选人:是的,未初始化的存储在bss段,初始化的存储在data段
面试官:很好,不错,看一段代码,这两行代码可以写成一行吗

static NSObject *obj = nil;
obj = [[NSObject alloc] init];

像这样:

static NSObject *obj = [[NSObject alloc] init];
候选人:应该可以吧(get不到问题的点)
面试官:那内存是如何分布的呢?
候选人:不可能同时存储在数据段和堆区吧(小声嘀咕)
面试官:顺着这个思路再思考下
候选人:。。。(过去三分钟)
面试官:好的,换种问法,单例很常见吧,那么它可以手动释放吗?
候选人:既然用单例来实现,说明整个程序生命周期需要共享一个实例,不会存在需要释放的场景
面试官:比如一个app里面有很多业务线,业务线退出的时候,需要清理业务线所占内存,如若有单例存在,这个时候可以手动释放吗?
候选人:把指针置为nil?,应该可以吧(试图得到面试官的提示)
面试官:那这以后呢,单例就不占用内存了吗?
候选人:。。。(彻底卡住)

Objective-C中的指针

可曾听过一句话,一切OC对象皆指针,嗯~这句话很对,我想开讲之前有必要说下究竟OC中的指针是什么,对象又是什么,它们不是一个东西嘛?iOS操作系统是基于unix的一个分支开发的,自然继承了unix内核的部分功能,内存分区为:栈、堆、数据段、常量区、代码段,本文不打算枯燥的讲理论,我们设计几个demo直接从表象出发,去探寻理论,会不会记忆更深刻呢?!

还是从上面的代码出发

static NSObject *obj = [[NSObject alloc] init];

这样是无法通过编译的,编译器提示Initializer element is not a compile-time constant,意思是初始化的元素不是编译期分配的常量,obj指针即是分配在数据段的变量,在编译时就需要分配内存,alloc的对象内存开辟在堆上并且是运行时分配的,用运行时的对象去初始化编译期的指针是没有办法做到的,所以编译器提示我们这样做是不对的
以上得出个结论:

  • 栈、堆内存是运行时分配的
  • 数据段内存是编译时分配的(这么说并不完全准确,往下看)

(注:app的可执行文件二进制里面包括静态库和源码,动态库和资源文件会单独存储,系统的动态库整个操作系统共享一份)
注意我们讲的是内存,我们的可执行程序是以二进制的形式存在于手机上的,这里说的代码段并非用于存储二进制文件,而是存储程序启动时候被载入内存中的可执行代码,紧随其后,操作系统会为程序中的全局变量和静态变量在数据段开辟内存(起初会存储在bss段,初始化后会清空bss段,存储在data段),常量的内存空间开辟和初始化是一起执行的,初始化后不再有机会改变,所以准确的说:

  • 代码段内存是装载时分配的,数据段和常量区紧随其后,这些都发生在动态链接之前

看一个demo:环境是x86模拟器,嗯~64位架构

@interface Person : NSObject

@property (nonatomic, assign) int a;
@property (nonatomic, assign) int b;
@property (nonatomic, assign) int c;

@end
Person *obj = nil;
NSLog(@"%lu", sizeof(obj));
NSLog(@"%lu", malloc_size((__bridge const void *)obj));
obj = [[Person alloc] init];
NSLog(@"%lu", malloc_size((__bridge const void *)obj));
NSLog(@"%lu", class_getInstanceSize(Person.class));

2021-06-05 16:41:36.982538+0800 test[69294:38540223] 8
2021-06-05 16:41:36.982640+0800 test[69294:38540223] 0
2021-06-05 16:41:36.982712+0800 test[69294:38540223] 32
2021-06-05 16:41:36.982772+0800 test[69294:38540223] 24

首先我们看Person的实例对象有哪些成员需要在堆上开辟内存空间,一个isa指针8个字节,三个int类型变量12个字节,总共20个字节
控制台输出sizeof是8,证明指针本身在64位系统占用8个字节,紧接着malloc_size输出0,证明只是一个指向nil的指针,还没有在堆区分配内存,malloc_size然后输出32证明在堆区开辟了32字节的内存,16字节为一个开辟单元是iOS系统的规范,所以要想存储20个字节就需要开辟两个单元的大小,就是32字节,最后class_getInstanceSize输出24证明对象实际占用24字节,是因为iOS系统内存存储是按照8字节对齐的,所以20个字节之后需要补齐4个字节的0用于内存对齐,无论是开辟空间对齐,还是存储对齐都是操作系统设计之初的效率考虑

好的~我们回到上面说的单例释放问题,是否可手动释放呢?答案是部分可以,部分不能,原因是,堆栈的内存动态分配,动态释放,而数据段、常量区、代码段内存直到app进程退出才会释放,所以单例指针置为nil的时候,堆区对象的引用计数为0会自动释放,而还有一个指针存储在数据段,占用8个字节

什么是ARC,引用计数存储在哪里,哪些对象是通过引用计数来管理内存的

面试官:如下代码在MRC环境会有内存泄漏,为什么?

- (void)viewDidLoad {
[super viewDidLoad];
NSObject *obj = [[NSObject alloc] init];
}
候选人:因为obj没有调用release或者autorelease
面试官:嗯,那还是MRC环境,下面的代码会泄露吗?

- (void)viewDidLoad {
[super viewDidLoad];
static NSObject *obj = nil;
obj = [[NSObject alloc] init];
}

候选人:嗯~~~会吧
面试官:你怎样理解内存泄漏,什么叫内存泄漏
候选人:就是一个对象,没有释放掉,就泄露了
面试官:啊~~~,那么能在ARC环境举个内存泄漏的例子吗
候选人:比如block是self的属性,然后里面引用了self,没有加__weak
面试官:这个是循环引用吧,所以循环引用会内存泄漏是吗
候选人:是的(over)

只有堆区对象才有引用计数,引用计数存储在对象本身的结构里,嗯~可以通过isa指针辗转访问到
ARC是自动引用计数,即编译器在编译期在合适的位置自动插入release或是autorelease

回到上面问题

- (void)viewDidLoad {
[super viewDidLoad];
NSObject *obj = [[NSObject alloc] init];
}

MRC下如上代码泄漏的根本原因是,obj是声明在栈上的指针,作用域之外自动释放,即大括号之外指针已经不存在了,但是alloc的对象引用计数是1,但是已经没有指针引用它了,所以这块堆内存将没有机会释放了,这就是内存泄漏

那么下面的代码在MRC下为什么就没有泄漏呢

- (void)viewDidLoad {
[super viewDidLoad];
static NSObject *obj = nil;
obj = [[NSObject alloc] init];
}

原因是这个obj指针声明在数据段,生命周期和app进程生命周期一致,虽然alloc的对象引用计数也始终为1,但是有个static指针一直引用它,所以这块堆内存没有泄漏

以上明确几个常见内存问题概念:

  • 野指针:堆内存已经释放,但是还有指针指向这块内存,就是野指针,访问野指针crash
  • 内存泄漏:堆区内存引用计数不为0,但是没有指针指向这块内存,内存碎片
  • 循环引用:堆内存之间存在相互强引用,并且没有第三种力量打破这个环,内存碎片
  • OOM:堆内存开辟大小不固定,超过系统的限制,crash
  • 栈溢出:栈内存大小是固定的,超过系统限制,crash

ARC下哪些对象是autorelease对象

面试官:ARC下除了__autoreleasing显式创建autorelease对象的方式,还有哪些情况会生成autorelease对象
候选人:alloc和new出来的对象都会加入默认的autoreleasePool中,所以都是autorelease对象
面试官:哦?那ARC下release关键字是被弃用了吗?
候选人:是的(斩钉截铁)

这么回答的人占3成,有些恐怖~,我们还是通过一个demo来探索下,__autoreleasing显式的创建autorelease对象比较明显,我们来聊下隐式的情况


__weak NSString *weak_String;
__weak NSString *weak_StringRelease;
__weak NSString *weak_StringAutorelease;

- (void)testArc {
[self createString];
NSLog(@"------%s------", __func__);
NSLog(@"%@", weak_String);
NSLog(@"%@\n\n", weak_StringRelease);
NSLog(@"%@\n\n", weak_StringAutorelease);
}

- (void)createString {
NSString *constAreaString = @"字面量string";
NSString *heapAreastring = [[NSString alloc] initWithFormat:@"堆区string-release"];
NSString *stringAutorelease = [NSString stringWithFormat:@"堆区string-autorelease"];
NSLog(@"%lu", malloc_size((__bridge const void *)constAreaString));
NSLog(@"%lu", malloc_size((__bridge const void *)heapAreastring));
NSLog(@"%lu", malloc_size((__bridge const void *)stringAutorelease));

weak_String = constAreaString;
weak_StringRelease = heapAreastring;
weak_StringAutorelease = stringAutorelease;

NSLog(@"------%s------", __func__);
NSLog(@"%@", weak_String);
NSLog(@"%@\n\n", weak_StringRelease);
NSLog(@"%@\n\n", weak_StringAutorelease);
}

- (void)viewDidLoad {
[super viewDidLoad];
[self testArc];
}

- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
NSLog(@"------%s------", __func__);
NSLog(@"%@", weak_String);
NSLog(@"%@\n\n", weak_StringRelease);
NSLog(@"%@\n\n", weak_StringAutorelease);
}

结果如下:

2021-06-06 00:25:44.981838+0800 test[81234:39339960] 0
2021-06-06 00:25:44.981936+0800 test[81234:39339960] 64
2021-06-06 00:25:44.982022+0800 test[81234:39339960] 64
2021-06-06 00:25:44.982116+0800 test[81234:39339960] -------[ViewController createString]------
2021-06-06 00:25:44.982213+0800 test[81234:39339960] 字面量string
2021-06-06 00:25:44.982278+0800 test[81234:39339960] 堆区string-release
2021-06-06 00:25:44.982357+0800 test[81234:39339960] 堆区string-autorelease
2021-06-06 00:25:44.982428+0800 test[81234:39339960] -------[ViewController testArc]------
2021-06-06 00:25:44.982508+0800 test[81234:39339960] 字面量string
2021-06-06 00:25:44.982578+0800 test[81234:39339960] (null)
2021-06-06 00:25:44.982656+0800 test[81234:39339960] 堆区string-autorelease
2021-06-06 00:25:44.992637+0800 test[81234:39339960] -------[ViewController viewDidAppear:]------
2021-06-06 00:25:44.992753+0800 test[81234:39339960] 字面量string
2021-06-06 00:25:44.992830+0800 test[81234:39339960] (null)
2021-06-06 00:25:44.992899+0800 test[81234:39339960] (null)
首先看字面量的方式创建的字符串malloc_size为0,说明它不在堆上,嗯~在常量区,另外两个malloc_size都是正数,证明是堆区对象,createString函数三个对象都有值,而当testArc的时候weak_StringRelease的值已经为空,即离开了createString函数的作用域就释放了,此时weak_StringAutorelease还有值,直到viewDidAppear的时候只有字面量创建的对象才能够打印出来,这个结果说明了什么呢,说明编译器做了如下优化:

- (void)createString {
//这行类型变成了__NSCFConstantString
__NSCFConstantString *constAreaString = @"字面量string";
NSString *heapAreastring = [[NSString alloc] initWithFormat:@"堆区string-release"];
//这行在末尾插入了autorelease
NSString *stringAutorelease = [[NSString stringWithFormat:@"堆区string-autorelease"] autorelease];
NSLog(@"%lu", malloc_size((__bridge const void *)constAreaString));
NSLog(@"%lu", malloc_size((__bridge const void *)heapAreastring));
NSLog(@"%lu", malloc_size((__bridge const void *)stringAutorelease));

weak_String = constAreaString;
weak_StringRelease = heapAreastring;
weak_StringAutorelease = stringAutorelease;

NSLog(@"------%s------", __func__);
NSLog(@"%@", weak_String);
NSLog(@"%@\n\n", weak_StringRelease);
NSLog(@"%@\n\n", weak_StringAutorelease);

//在作用域末尾插入了release
[heapAreastring release];
}

注意关键点,有三处变化__NSCFConstantString *constAreaString、[[NSString stringWithFormat:@"堆区string-autorelease"] autorelease]、[heapAreastring release];

  • 字面量创建的直接存储在常量区
  • alloc出来的存储在堆区并且作用域结束前直接插入release
  • 通过stringWithFormat工厂方法创建的对象则在其后插入autorelease,这是因为工厂方法里面通过alloc分配堆内存,到返回出来以后其作用域已经结束,所以只能延迟释放了,否则没有办法返回非空对象

同样是这个demo把字符串的长度缩短,结果会很不一样

NSString *constAreaString = @"字面量";
NSString *heapAreastring = [[NSString alloc] initWithFormat:@"release"];
NSString *stringAutorelease = [NSString stringWithFormat:@"autorelease"];

2021-06-06 00:51:27.827647+0800 test[82761:39451059] 0
2021-06-06 00:51:27.827750+0800 test[82761:39451059] 0
2021-06-06 00:51:27.827843+0800 test[82761:39451059] 0
2021-06-06 00:51:27.827941+0800 test[82761:39451059] -------[ViewController createString]------
2021-06-06 00:51:27.828040+0800 test[82761:39451059] 字面量
2021-06-06 00:51:27.828139+0800 test[82761:39451059] release
2021-06-06 00:51:27.828224+0800 test[82761:39451059] autorelease
2021-06-06 00:51:27.828292+0800 test[82761:39451059] -------[ViewController testArc]------
2021-06-06 00:51:27.828369+0800 test[82761:39451059] 字面量
2021-06-06 00:51:27.828444+0800 test[82761:39451059] release
2021-06-06 00:51:27.828535+0800 test[82761:39451059] autorelease
2021-06-06 00:51:27.838554+0800 test[82761:39451059] -------[ViewController viewDidAppear:]------
2021-06-06 00:51:27.838684+0800 test[82761:39451059] 字面量
2021-06-06 00:51:27.838765+0800 test[82761:39451059] release
2021-06-06 00:51:27.838853+0800 test[82761:39451059] autorelease
我们发现他们已经都不在堆区了,而是存储在常量区,这是一项优化叫做NSTagged Pointer,即指针和对象存储在一起,这项技术是苹果公司对小对象做的优化NSString、NSNumber、NSDate。所以上述代码经过编译器的优化,就变成了下面这样

__NSCFConstantString *constAreaString = @"字面量";
NSTaggedPointerString *heapAreastring = [[NSString alloc] initWithFormat:@"release"];
NSTaggedPointerString *stringAutorelease = [[NSString stringWithFormat:@"autorelease"];

以上结论:

  • 字面量创建的直接存储在常量区
  • alloc出来的存储在堆区并且作用域结束前直接插入release(符合NSTagged Pointer的会直接分配在常量区,类型是NSTaggedPointer_接类型名,标识指针和对象存储在一起)
  • 通过stringWithFormat工厂方法创建的对象则在其后插入autorelease,这是因为工厂方法里面通过alloc分配堆内存,到返回出来以后其作用域已经结束,所以只能延迟释放了,否则没有办法返回非空对象(符合NSTagged Pointer的会直接分配在常量区,类型是NSTaggedPointer_接类型名,标识指针和对象存储在一起)

objc_autoreleaseReturnValue 和 objc_retainAutoreleasedReturnValue的纠正

值得一提的是,即便编译器插入autorelease关键字,也不一定会将这个对象放入autoreleasePool,为了减轻autoreleasePool的负担,苹果做了一项优化,objc_autoreleaseReturnValue 和 objc_retainAutoreleasedReturnValue,这里不分析源码,直接给出上层的解释,如下,对象在加入autoreleasePool之前会调用objc_autoreleaseReturnValue,这个方法会检测后面串行的代码是否调用了objc_retainAutoreleasedReturnValue(就是一次引用计数+1的操作),如果有则不加入autoreleasePool,直接返回对象,❌引用计数不会+1并且在当前线程存储区域做个标记,待到执行到objc_retainAutoreleasedReturnValue的时候检测标志位,如在优化流程中则直接返回对象并且重置标志位,否则加入autoreleasePool,❌引用计数+1

注意:上面一段话❌部分都是错误的理解,实际上引用计数在对象初始化后就已经存在,是对象相关联的东西,+1与否和自动释放池没有半点关系,-1与否才有关系

错误的理解如下:
优化前

id obj = objc_msgSend(objc_msgSend(NSMutableString, @selector(string)));
objc_autorelease(obj);
objc_retain(obj);
// 这里引用计数为2
objc_release(obj);

优化后

id obj = objc_msgSend(objc_msgSend(NSMutableString, @selector(string)));
// 这里引用计数为1
objc_release(obj);

而实际上呢:

NSMutableString *str = [NSMutableString string];
NSMutableString *strRetain = str;
NSLog(@"%li", CFGetRetainCount((__bridge CFTypeRef)str));

优化后retainCount的结果是2

021-06-07 00:53:55.610395+0800 test[92513:40083306] 2

所以结论是:

编译器优化后,会在执行到objc_retainAutoreleasedReturnValue的时候,不会将对象加入autoreleasePool,而是在这次引用计数+1操作之后作用域结束之前再加入一个release操作


对象是何时被加入autoreleasepool的

运行时,对象在调用autorelease的时候就开始检测后续串行代码是否有引用计数加1操作,没有的话就会直接调用AutoreleasePoolPage的add函数添加到双向链表
源码级别的回答:

id *add(id obj)
{
ASSERT(!full());
unprotect();
id *ret = next; // faster than `return next-1` because of aliasing
*next++ = obj;
protect();
return ret;
}

在NSObject.mm可以查到源码,AutoreleasePoolPage定义了一系列工具函数,其中添加到autoreleasePool中的操作是add函数,这个函数的调用栈如下:

  • add
  • autoreleaseFast
  • autorelease
  • objc_autorelease
  • objc_autoreleaseReturnValue

程序的最小执行流是线程,iOS系统为每个线程定义了一系列的数据结构,在线程初始化的时候就初始化相关结构,一个栈、一个autoreleasepool、一个runloop还有一个线程局部存储区域(很小),运行时执行到autorelease语句的时候,会优先检测对象是否符合NSTaggedPointer,如果符合就抛出异常,证明程序不应该进入autorelease环节,如果不符合就往下走流程,调用objc_autoreleaseReturnValue函数,优先进行检测后续串行代码是否调用了objc_retainAutoreleasedReturnValue函数,如果没有调用,就会直接调用add函数添加到双向链表,如果有引用计数+1操作,则会把一个标记存储在TLS(线程局部存储),待到执行到objc_retainAutoreleasedReturnValue的时候检测标志位,如在优化流程中则直接返回对象并且重置标志位



作者:野码道人
链接:https://www.jianshu.com/p/ed84101e0efe
收起阅读 »

iOS 控制器生命周期

1,单个viewController的生命周期①,initWithCoder:(NSCoder *)aDecoder:(如果使用storyboard或者xib)②,loadView:加载view③,viewDidLoad:view加载完毕④,viewWillA...
继续阅读 »

1,单个viewController的生命周期

①,initWithCoder:(NSCoder *)aDecoder:(如果使用storyboard或者xib)

②,loadView:加载view

③,viewDidLoad:view加载完毕

④,viewWillAppear:控制器的view将要显示

⑤,viewWillLayoutSubviews:控制器的view将要布局子控件

⑥,viewDidLayoutSubviews:控制器的view布局子控件完成

这期间系统可能会多次调用viewWillLayoutSubviews 、 viewDidLayoutSubviews 俩个方法

⑦,viewDidAppear:控制器的view完全显示

⑧,viewWillDisappear:控制器的view即将消失的时候

这期间系统也会调用viewWillLayoutSubviews 、viewDidLayoutSubviews 两个方法

⑨,viewDidDisappear:控制器的view完全消失的时候

⑩,didReceiveMemoryWarning(内存满时)

当程序发出一个内存警告--->

系统询问控制器有View吗--->如果有View

系统询问这个View能够销毁吗---->通过判断View是否在Windown上面,如果不在,就表示可以销毁

如果可以销毁,就执行viewWillUnLoad()----->对你的View进行一次release,此时View就为nil

然后调用viewDidUnLoad()----->一般还会在这个方法里将一些不需要属性清空

2,多个viewControllers跳转

当我们点击push的时候首先会加载下一个界面然后才会调用界面的消失方法

initWithCoder:(NSCoder *)aDecoder:ViewController2(如果用xib创建的情况下)

loadView:ViewController2

viewDidLoad:ViewController2

viewWillDisappear:ViewController1将要消失

viewWillAppear:ViewController2将要出现

viewWillLayoutSubviewsViewController2

viewDidLayoutSubviewsViewController2

viewWillLayoutSubviews:ViewController1

viewDidLayoutSubviews:ViewController1

viewDidDisappear:ViewController1完全消失

viewDidAppear:ViewController2完全出现

3,相关解释

①,loadView()

若控制器有关联的 Nib 文件,该方法会从 Nib 文件中加载 view;如果没有,则创建空白 UIView 对象。

自定义实现不应该再调用父类的该方法。

②,viewDidLoad()

view 被加载到内存后调用viewDidLoad()。

重写该方法需要首先调用父类该方法。

该方法中可以额外初始化控件,例如添加子控件,添加约束。

该方法被调用意味着控制器有可能(并非一定)在未来会显示。

在控制器生命周期中,该方法只会被调用一次。

③,viewWillAppear()

该方法在控制器 view 即将添加到视图层次时以及展示 view 时所有动画配置前被调用。

重写该方法需要首先调用父类该方法。

该方法中可以进行操作即将显示的 view,例如改变状态栏的取向,类型。

该方法被调用意味着控制器将一定会显示。

在控制器生命周期中,该方法可能会被多次调用。

注意:

如果A控制器present到B控制器,B控制器dismiss的时候,A的viewWillAppear不会被调用

④,viewWillLayoutSubviews()

该方法在通知控制器将要布局 view 的子控件时调用。

每当视图的 bounds 改变,view 将调整其子控件位置。

该方法可重写以在 view 布局子控件前做出改变。

该方法的默认实现为空。

该方法调用时,AutoLayout 未起作用。

在控制器生命周期中,该方法可能会被多次调用。

⑤,viewDidLayoutSubviews()

该方法在通知控制器已经布局 view 的子控件时调用。

该方法可重写以在 view 布局子控件后做出改变。

该方法的默认实现为空。

该方法调用时,AutoLayout 已经完成。

在控制器生命周期中,该方法可能会被多次调用。

⑥,viewDidAppear()

该方法在控制器 view 已经添加到视图层次时被调用。

重写该方法需要首先调用父类该方法。

该方法可重写以进行有关正在展示的视图操作。

在控制器生命周期中,该方法可能会被多次调用。

⑦,viewWillDisappear()

该方法在控制器 view 将要从视图层次移除时被调用。

类似 viewWillAppear()。

该方法可重写以提交变更,取消视图第一响应者状态。

⑧,viewDidDisappear()

该方法在控制器 view 已经从视图层次移除时被调用。

类似 viewDidAppear()

该方法可重写以清除或隐藏控件。

⑨,didReceiveMemoryWarning()

当内存预警时,该方法被调用。

不能直接手动调用该方法。

该方法可重写以释放资源、内存。

⑩,deinit

控制器销毁时(离开堆),调用该方法。

可以移除通知,调试循环测试

总结:

当屏幕旋转,view 的 bounds 改变,其内部的子控件也需要按照约束调整为新的位置,因此也调用了 viewWillLayoutSubviews() 和 viewDidLayoutSubviews()。

当在一个控制器内 Present 新的控制器,原先的控制器并不会销毁,但会消失,因此调用了viewWillDisappear和viewDidDisappear方法。

如果新的控制器 Dismiss,即清除自己,原先的控制器会再一次出现,因此调用了其中的viewWillAppear和viewDidAppear方法。

若 loadView() 没有加载 view,viewDidLoad() 会一直调用 loadView() 加载 view,因此构成了死循环,程序即卡死。原文

附加面试题:load和initialize的区别

******:load是只要类所在的文件被引用就会被调用,而initialize是在类或者其子类的第一个方法被调用前调用。所以如果类没有被引用进项目,就不会调用load方法,即使类文件被引用进来,如果没有使用,那么initialize不会被调用。

调用方式
1、load是根据函数地址直接调用
2、initialize是通过objc_msgSend调用
调用时刻
1、load是runtime加载类、分类的时候调用(只会调用一次)
2、initialize是类第一次接收到消息的时候调用, 每一个类只会initialize一次(如果子类没有实现initialize方法, 会调用父类的initialize方法, 所以父类的initialize方法可能会调用多次)

load和initializee的调用顺序

1、load:
先调用类的load, 在调用分类的load
先编译的类, 优先调用load, 调用子类的load之前, 会先调用父类的load
先编译的分类, 优先调用load

2、initialize
先初始化分类, 后初始化子类
通过消息机制调用, 当子类没有initialize方法时, 会调用父类的initialize方法, 所以父类的initialize方法会调用多次


作者:大宝的爱情
链接:https://www.jianshu.com/p/bd2197d5e547



收起阅读 »

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

收起阅读 »

UIKit -大话 iOS Layout

大话 iOS Layout在iOS的开发中,我们绝大部分的时间都是在跟UI打交道,例如UI怎么布局,UI怎么刷新,以及对复杂UI的优化,使我们的APP更加流畅。对于UI的布局,xcode提供了可视化的布局方式:xib、storyboard,这是非常便捷的布局方...
继续阅读 »

大话 iOS Layout

在iOS的开发中,我们绝大部分的时间都是在跟UI打交道,例如UI怎么布局,UI怎么刷新,以及对复杂UI的优化,使我们的APP更加流畅。

对于UI的布局,xcode提供了可视化的布局方式:xib、storyboard,这是非常便捷的布局方式,所见即所得,门槛也非常低,但占用的资源相对代码来说更多,而且在多人协作开发的过程中,处理xml格式的文件冲突是非常困难的,所以很多团队都不推荐使用这类方式的布局,适合需求相对简单的团队、需要快速迭代的项目。

纯代码方式的布局是我们必修课,苹果有提供 Frame 和 Auto Layout 两种方式的布局。Auto Layout是苹果为我们提供的一整套布局引擎(Layout Engine),这套引擎会将视图、约束、优先级、大小通过计算转化成对应的 frame,而当约束改变的时候,会再次触发该系统重新计算。Auto Layout本质就是一个线性方程解析Engine。基于Auto Layout,不再需要像frame时代一样,关注视图的尺寸、位置相关参数,转而关注视图之间的关系,描述一个表示视图间布局关系的约束集合,由Engine解析出最终数值。

在混合开发的布局中,同样都会有一个虚拟DOM机制,当布局发生改变的时候,框架会将修改提交到虚拟DOM虚拟DOM先进行计算,计算出各个节点新的相对位置,最后提交到真实DOM,以完成渲染,当多个修改同时被提交的时候,框架也会对这些修改做一个合并,避免每次修改都要刷新。这种机制跟iOS中的run loop的渲染机制非常类似。Layout Engine计算出视图的frame,等到下一次run loop到来的时候,将结果提交到渲染层以完成渲染,同样,也会对一些修改进行“合并”,直到下一次运行循环到来时,才将结果渲染出来。

这篇文章主要讲解在布局的过程中,视图分别在Auto Layout以及Frame的方式下,如何完成刷新。

Main run loop of an iOS app

主运行循环是iOS应用中用来处理所有的用户输入和触发合适的响应事件。iOS应用的所有用户交互都会被添加到一个事件队列(event queue)里面。UIApplication object会将所有的事件从这个队列中取出,然后分发到应用中的其他对象。它通过解释用户的输入事件并在application’s core objects中调用相似的处理,以执行运行循环。这些处理事件可以被开发者重写。一旦这这些方法调用结束,程序就会回到运行循环,然后开始更新周期(update cycle)更新周期(update cycle)负责视图的布局和重绘



Update cycle

更新周期(update cycle)开始的时间点:应用程序执行完所有事件处理后,控制权返回main run loop。在这个时间点后,系统开始布局、显示和约束。如果在系统正在执行事件处理时需要更改某个视图,系统会将这个视图标记为需要重绘。在下一个更新周期,系统将会执行这个视图的所有修改。为了让用户交互和布局更新之间的延迟不被用户察觉到。iOS应用程序以60fps的帧率刷新界面,也就是每一个更新周期的时间是1/60秒。由于更新周期存在一定的时间间隔,所以我们在布局界面的过程中,会遇到某些视图并不是我们想要实现的效果,拿到的其实是上一次运行循环的效果。(这里YY一下,大家可能都会在业务代码中遇到过这种问题,某个视图布局不对,我们加个0.5的延迟,然后就正确了,或者是异步添加到主队列,界面布局也正常了。这些都是取巧的操作,刷新相关的问题仍然存在,可能在你的这个界面不会出问题,但有可能会影响到别人的、或者其他的界面。)所以说,“出来混,迟早都是要还的”,问题也迟早都是要解决的。接下来将会介绍如何准确的知道视图的布局、绘制、约束触发的时间点,如何正确的去刷新视图。

60fps 的 1/60秒
1/60秒CPU+GPU整个计算+绘制的时间间隔,如果在这个时间段内,并没有完成显示数据的准备,那iOS应用将显示上一帧画面,这个就是所谓的掉帧。CPU中有大量的逻辑控制单元,而GPU中有大量的数据计算单元,所以GPU的计算效率远高于GPU。为了提高效率,我们可以尽量将计算逻辑交给GPU。关于具体GPU的绘制流程相关的文章,可以参考OpenGL专题

下图可以看出来,在 main run loop 结束后开始 更新周期(update cycle)



Layout

视图的布局指的它在屏幕上的位置和大小。每一个视图都有一个frame,用于定义它在父视图坐标系中的位置和大小。UIView会提供一些方法以通知视图的布局发生改变,同样也提供一系列方法供开发者重写,用来处理视图布局完成之后的操作。

layoutSubviews()

  • 这个方法用来处理一个视图和它所有的子视图的重新布局(位置、大小),它提供了当前视图和所有子视图的frame。

  • 这个方法的开销是昂贵的,因为它作用于所有的子视图,并逐级调用所有子视图的layoutSubviews()方法

  • 系统在重新计算frame的时候会调用这个方法,所以当我们需要设置特定的frame的时候,可以重写这个方法。

  • 永远不要直接调用这个方法来刷新frame。在运行循环期间,我们可以通过调用其他方法来触发这个方法,这样造成的开销会小的多。

  • 当UIView的layoutSubviews()调用完成之后,就会调用它的ViewController的viewDidLayoutSubviews()方法,而layoutSubviews()方法是视图布局更新之后的唯一可靠方法。所以对于依赖视图frame相关的逻辑代码应该放在viewDidLayoutSubviews()方法中,而不是viewDidLoadviewDidAppear中,这样就可以避免使用陈旧的布局信息。

layoutSubviews 是在系统重新计算frame之前调用,还是在重新计算frame之后调用。(初步估计是计算之后)

Automatic refresh triggers

有很多事件会自动标记一个视图已经更改了布局,以便于layoutSubviews()方法在下一次执行的时候调用,而不是由开发者手动去做这些事。

一些自动通知系统布局已经更改的方法有:

  • 调整视图的大小
  • 添加一个子视图
  • 用户滑动UIScrollView(UIScrollView和它的父视图将会调用layoutSubviews()
  • 用户旋转设备
  • 更新视图的约束

这些方法都会告诉系统需要重新计算视图的位置,而且最终也会自动调用到layoutSubviews()方法。除此之外,有方法可以直接触发layoutSubviews()的调用。

setNeedsLayout()

  • setNeedsLayout() 是触发 layoutSubviews() 造成开销最小的方法。它会直接告诉系统,view 的布局需要重新计算。setNeedsLayout()会立即执行并返回,而且在返回之前,是不会去更新 view。
  • 当系统逐级调用 layoutSubviews() 之后,view 会在下一个 更新周期(update cycle) 更新。尽管在 setNeedsLayout() 与视图的重绘和布局之间有一定的时间间隔,但这个时间间隔不会长到影响到用户交互。

setNeedsLayout() 是在什么时候 return

layoutIfNeeded()

  • 执行 layoutIfNeeded() 之后,如果 view 需要更新布局,系统会立刻调用 layoutSubviews() 方法去更新,而不是将 layoutSubviews() 方法加入队列,等待下一次 更新周期(update cycle) 再去调用;
  • 当我们在调用 setNeedsLayout() 或者是其他自动触发刷新的事件之后,执行 layoutIfNeeded() 方法,可以立即触发 layoutSubviews() 方法。
  • 如果一个 view 不需要更新布局,执行 layoutIfNeeded() 方法也不会触发 layoutSubviews() 方法。例如,当我们连续执行两次 layoutIfNeeded() 方法,第二次执行将不会触发 layoutSubviews() 方法。

使用 layoutIfNeeded() 方法,子视图的布局和重绘将立即发生,并且在该方法返回之前就可以完成(除非视图正在做动画)。如果需要依赖一个新的视图布局,并且不想等视图的更新到下一个 更新周期(update cycle) 才完成,使用 layoutIfNeeded() 方法时非常有用的。除了这种场景,一般使用 setNeedsLayout() 方法等到下一个 更新周期(update cycle) 去更新视图就可以了,这样可以保证在一次 run loop 里面只更新视图一次。

在使用约束动画的时候,这个方法是非常有用的。一般操作是,在动画开始前调用一次 layoutIfNeeded() 方法,以保证在动画之前布局的更新都已经完成。配置完我们的动画后,在 animation block 里面,再调一次 layoutIfNeeded() 方法,就可以更新到新的状态。

Display

视图的展示包含的属性不涉及视图及子视图的 size 和 position,例如:颜色、文本、图片和 Core Graphic drawing。显示通道包含于触发更新的布局通道类似的方法,它们都是当系统检测到有变更时,由系统调用,而且我们也都能手动的去触发刷新。

draw(_:)

UIView 的 draw(Objective-C里面是drawRect)方法,作用于视图的内容,就像 layoutSubviews() 作用于视图的 size 和 position。但是,这个方法不会触发子视图的 draw(Objective-C里面是drawRect)方法。这个方法也不能由开发者直接调用,我们应该在 run loop 期间,调用可以触发 draw方法的其他方法来触发draw方法。

setNeedsDisplay()

setNeedsDisplay() 方法等同于 setNeedsLayout() 方法。它会设置一个内部的标记来标记这个视图的内容需要更新,但它在视图重绘之前返回。然后,在下一个 更新周期(update cycle) 系统会遍历所有有这个标记的视图,然后调用它们的 draw 方法。如果只需要在下一个更新周期(update cycle)重绘视图的部分内容,可以调用setNeedsDisplay() 方法,并通过rect属性来设置我们需要重绘的部分。

大多数情况下,想要在下一个更新周期(update cycle)更新一个视图上的UI组件,通过自动设置内部的内容更新标记而不是手动调用setNeedsDisplay()方法。但是,如果一个视图(aView)并不是直接绑定到UI组件上的,但是我们又希望每次更新的时候都可以重绘这个视图,我们可以通过观察视图(aView)属性的setter方法(KVO),来调用setNeedsDisplay()方法以触发适当的视图更新。

当需要执行自定义绘制时,可以重写draw方法。下面可以通过一个例子来理解。

  • numberOfPointsdidSet方法中调用setNeedsDisplay()方法,可以触发draw方法。
  • 通过重写draw方法,以达到在不同情况下,绘制不同的样式的效果。
class MyView: UIView {
var numberOfPoints = 0 {
didSet {
setNeedsDisplay()
}
}

override func draw(_ rect: CGRect) {
switch numberOfPoints {
case 0:
return
case 1:
drawPoint(rect)
case 2:
drawLine(rect)
case 3:
drawTriangle(rect)
case 4:
drawRectangle(rect)
case 5:
drawPentagon(rect)
default:
drawEllipse(rect)
}
}
}


不像layoutIfNeeded可以立刻触发layoutSubviews那样,没有方法可以直接触发一个视图的内容更新。视图内容的更新必须等到下一个更新周期去重绘视图。

Constraints

在自动布局中,视图的布局和重绘需要三个步骤:

  1. 更新约束:系统会计算并设置视图上所有必须的约束。
  2. 布局阶段layout engine计算视图的frame,并将它们布局。
  3. 显示过程:结束更新循环并重绘视图内容,如果有必要,会调用draw方法。

updateConstraints()

  • updateConstraints在自动布局中的作用就像layoutSubviews在frame布局、以及draw在内容重绘中的作用一样。
  • updateConstraints方法只能被重写,不能被直接调用。
  • updateConstraints方法中一般只实现那些需要改变的约束,对于不需要改变的约束,我们尽可能的别写在里面。
  • Static constraints也应该在接口构造器、视图的初始化方法、或viewDidLoad()方法中实现,而不是放在updateConstraints方法中实现。

有以下一些方式可以自动触发约束的更新。在视图内部设置一个update constraints的标志,该标志会在下一个update cycle中,触发updateConstraints方法的调用。

  • 激活或停用约束;
  • 更改约束的优先级、常量值;
  • 移除约束;

除了自动触发约束的更新之外,同样也有以下方法可以手动触发约束的更新。

setNeedsUpdateConstraints()

调用setNeedsUpdateConstraints方法可以保证在下一个更新周期进行约束的更新。它触发updateConstraints方法的方式是通过标记视图的某个约束已经更新。这个方法的工作方式跟setNeedsLayoutsetNeedsDisplay类似。

updateConstrainsIfNeeded()

这个方法等同于layoutIfNeeded,但是在自动布局中,它会检查constraint update标记(这个标记可以被自动设置、也可以通过setNeedsUpdateConstraintsinvalidateInstinsicContentSize方法手动设置)。如果它确定约束需要更新,就会立即触发updateConstraints方法,而不是等到 run loop 结束。

invalidateInstinsicContentSize()

自动布局中某些视图拥有intrinsicContentSize属性,这是视图根据它的内容得到的自然尺寸。一个视图的intrinsicContentSize通常由所包含的元素的约束决定,但也可以通过重载提供自定义行为。调用invalidateIntrinsicContentSize()会设置一个标记表示这个视图的intrinsicContentSize已经过期,需要在下一个布局阶段重新计算。

How it all connects

布局、显示和约束都遵循着相似的模式,例如:他们更新的方式以及如何在 run loop 的不同时间点上强制更新。任一组件都有一个实际去更新的方法(layoutSubviewsdraw, 和updateConstraints),这些方法可以通过重写来手动操作视图,但任何情况下都不要显式调用。这个方法只在 run loop 的末端会被调用,如果视图被标记了告诉系统该视图需要被更新的标记话。有一些操作会自动设置这个标记,也有一些方法允许显式地设置它。对于布局和约束相关的更新,如果等不到在 run loop 结束才更新的话(例如:其他行为依赖于新布局),也有方法可以让你立即更新,并保证 update layout能被正确标记。下面的表格列出了任意组件会怎样更新及其对应方法。

LayoutDisplayConstraints方法意图
layoutSubviewsdrawupdateConstraints执行更新的方法,可以被重
写,但不能被调用
setNeedsLayoutsetNeedDisplaysetNeedsUpdateConstaints
invalidateInstrinsicContentSize
显示的标记视图需要在下一个更新循环更新
layoutIfNeeded--updateConstraintsIfNeeded立刻更新被标记的视图
添加视图
重设size
设置frame(需要改变bounds)
滑动ScrollView
旋转设备
发生在视图的bounds内部的改变激活、停用约束
修改约束的优先级和常量值
移除约束
隐式触发视图更新的事件

下图总结了更新周期(update cycle)事件循环(event loop)之间的交互,并且指示了上面这些方法在周期中下一步指向的位置。你可以现实的调用layoutIfNeededupdateConstraintsIfNeeded在run loop的任何地方,但需要注意的是,这两个方式是有潜在开销的。如果update constrintsupdate layoutneeds display标记被设置,在 run loop 的结尾处的更新周期就会更新约束、布局、显示内容。一但这些更新全部完成,run loop就会重新开始。






作者:修_远
链接:https://www.jianshu.com/p/98dec55a06c8

收起阅读 »

短视频源码开发,短视频源码应该从哪些方面进行优化?

短视频作为更加符合移动互联网时代用户触媒习惯的视频内容形式,在内容上和功能上本身就具有很大的想象空间。通过“短视频+”的方式现在有不少平台上搭建和嵌入短视频源码,是一个不错的入局途径。短视频压缩短视频的压缩问题是短视频源码的难点之一。视频拍摄、上传完成后,要在...
继续阅读 »

短视频作为更加符合移动互联网时代用户触媒习惯的视频内容形式,在内容上和功能上本身就具有很大的想象空间。通过“短视频+”的方式现在有不少平台上搭建和嵌入短视频源码,是一个不错的入局途径。

短视频压缩

短视频的压缩问题是短视频源码的难点之一。视频拍摄、上传完成后,要在不影响用户体验的情况下完成短视频帧率的统一、格式统一、分辨率处理、短视频压缩等处理。

短视频不进行压缩处理的情况下上传,会造成资源的浪费,对运营商来说,长期以往的带宽浪费会非常费钱。

实现秒播

短视频app源码中的短视频列表在打开时,就会主动扣留列表中的最后一个视频到内存中,然后再对其他视频进行预加载,当进行下拉刷新操作时,会将上次扣留的短视频作为刷新后的第一个视频进行展示,再去预加载其他视频内容,通过这样的方式,优化小视频app源码中短视频秒播的问题。

短视频源码开发,短视频源码应该从哪些方面进行优化?

大数据分析

短视频源码的数据分析功能非常重要,在推荐上、获取用户喜好上都能发挥很大的作用。百万级的用户,想要做到精准的推荐,就离不开对用户数据的分析,通过平时观看的喜好,充分了解用户的观看习惯。

短视频的录制

短视频录制功能通常能够设置视频的录制时长,可以录制的时间长一点或者短一点,配置各类的视频参数,像视频分辨率、码率等等。此外,短视频之所以这么火爆,还在于短视频中加了很多特效和滤镜,像有一个可爱的、搞怪的、清新的等等,还有很多动态的贴纸,面具,动态的在线染发,更关键的话还有很多小游戏,让人沉迷在短视频中。

除了以上短视频源码开发功能之外,管理后台也在全套方案中扮演着重要的角色。除了能够配置APP客户端相对应的功能外,还需要有良好的操作体验,方便短视频平台运营商的各项实际操作。

消息也是短视频源码当中重要的一环,是内容互动的前提条件。除了系统及时推送消息之外,粉丝和主播之间、粉丝和粉丝之间都可以进行私信的互动,也可以针对自己喜欢的视频进行评论。

收起阅读 »

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

收起阅读 »

iOS - 图片显示类似LED的效果

iOS
LED灯的效果展示。整理了一下,自己所了解的知识。通过一些其他方式。在App界面展示出现LED的效果。屏幕快照 2020-10-27 上午9.43.30.png1.绘制图片 (或者是图片)2.通过获取到像素点的颜色去进行展示。每一个像素点有 RGB A 这个四...
继续阅读 »

LED灯的效果展示。
整理了一下,自己所了解的知识。通过一些其他方式。
在App界面展示出现LED的效果。



屏幕快照 2020-10-27 上午9.43.30.png

1.绘制图片 (或者是图片)
2.通过获取到像素点的颜色去进行展示。每一个像素点有 RGB A 这个四个
3.通过获取到的 bitMap 展示出LED的效果

第一步随意找一张图片 方便获取到他的像素点

第二步

//2.获取到图片的 bitMap
/**
- 传入图片的信息,返回图片的像素点信息
- 返回的数据排列。 R G B ,A :亮度, row:行数, col 列数
*/

func getImagePixel(_ image:UIImage) -> Array<Any>{
//存储像素的数据
let grayScale: [Pixel] = (image.pixelData.map {
//将RGB的颜色记录下来
return $0

})
//返回像素的数据
return grayScale
}


//MARK: 创建接受像素点的model
struct Pixel {
var r: Float
var g: Float
var b: Float
var a: Float
var row: Int
var col: Int
init(r: UInt8, g: UInt8, b: UInt8, a: UInt8, row: Int, col: Int) {
self.r = Float(r)
self.g = Float(g)
self.b = Float(b)
self.a = Float(a)
self.row = row
self.col = col
}
var color: UIColor {
return UIColor(
red: CGFloat(r/255.0),
green: CGFloat(g/255.0),
blue: CGFloat(b/255.0),
alpha: CGFloat(a/255.0)
)
}
var description: String {
return "\(r), \(g), \(b), \(a) ,\(row) ,\(col)"
}
}


//MARK: 读取像素点的方法
extension UIImage{

var pixelData: [Pixel] {

var pixelS = [Pixel]()
for row in 0 ..< Int(self.size.width){
for col in 0 ..< Int(self.size.height){
let coloR = self.cxg_getPointColor(withImage: self, point: CGPoint(x: row, y: col))
pixelS.append(Pixel(r: coloR![0], g: coloR![1], b: coloR![2], a: coloR![3], row: row, col: col))
}
}
//返回取出颜色的数组 返回RGB 亮度 行数、列数
return pixelS
}
/// - Parameters:
/// - image: 要获取颜色的图片
/// - point: 每一次要获取到的点的颜色
/// - Returns: 获取到的颜色
func cxg_getPointColor(withImage image: UIImage, point: CGPoint) -> [ UInt8]? {
guard CGRect(origin: CGPoint(x: 0, y: 0), size: image.size).contains(point) else {
return nil
}
let pointX = trunc(point.x);
let pointY = trunc(point.y);

let width = image.size.width;
let height = image.size.height;
let colorSpace = CGColorSpaceCreateDeviceRGB();
var pixelData: [UInt8] = [0, 0, 0, 0]

pixelData.withUnsafeMutableBytes { pointer in
if let context = CGContext(data: pointer.baseAddress, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, space: colorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue), let cgImage = image.cgImage {
context.setBlendMode(.copy)
context.translateBy(x: -pointX, y: pointY - height)
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
}
}
return pixelData
}
}

第三步

//3.通过获取到的 点阵的位置绘制出来点阵图
class TestView:UIView{
override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)
}
var x:CGFloat = 0.0
var y:CGFloat = 0.0

var imagePixel_Array:Array<Pixel>?
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
super.draw(rect)
//获取绘图上下文
guard let context = UIGraphicsGetCurrentContext() else {
return
}
self.backgroundColor = #colorLiteral(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0)
if imagePixel_Array != nil{
//控制小格子有多大
let size: CGSize = .init(width: self.frame.size.width / 18, height: self.frame.size.height/12)
for (index, pixel) in (imagePixel_Array?.enumerated())! {

//创建一个矩形,它的所有边都内缩3点
let drawingRect = CGRect(x: x, y: y, width: size.width, height: size.height)
//创建并设置路径
let path = CGMutablePath()
//绘制矩形
path.addRect(drawingRect)
//添加路径到图形上下文
context.addPath(path)
//设置填充颜色
context.setFillColor(UIColor.init(red: CGFloat(pixel.r / 255), green: CGFloat(pixel.g / 255), blue: CGFloat(pixel.b / 255), alpha: CGFloat(pixel.a / 255)).cgColor)
//绘制路径并填充
context.drawPath(using: .fillStroke)
print("---\(pixel.description)")

y += size.height
if index % 12 == 0 && index != 0{
x += size.width
y = 0
}
}
}
}
}

如何调用

        let image_Png = UIImage.init(named: "1111")!
let imagePixel_Array_2 = self.getImagePixel(image_Png!)
let testView_2 = TestView.init(frame: CGRect(x: 100, y: 400, width: 18 * 12, height: 12 * 12))
testView_2.imagePixel_Array = (imagePixel_Array_2 as! Array<Pixel>)
testView_2.setNeedsDisplay()
self.view.addSubview(testView_2)

现在只是效果只是完成了一丢丢
后面接着去研发,去研究这个展示 led效果的功能
如果有疑惑,评论。我就会去和大家讨论


1人点赞
收起阅读 »

iOS-Cocoapods 的正确安装姿势

iOS
在安装过程中出现curl: (7) Failed to connect to raw.githubusercontent.com port 443: Connection refused 问题访问我的处理方式可能会对你有帮助. 文末附带rvm 无法在线安装的解...
继续阅读 »

在安装过程中出现curl: (7) Failed to connect to raw.githubusercontent.com port 443: Connection refused 问题访问我的处理方式可能会对你有帮助.

文末附带rvm 无法在线安装的解决办法.

文末还提供了pod install或者serach 过程中[!]CDN: trunk URL couldn't be downloaded:的解决办法.


1. Mac环境下 Cocoapods 的安装


1.1 总体步骤



下载Xcode —>安装rvm —>安装ruby —>安装home-brew —>安装cocoapods


1.2 安装前,先检查是否有安装残留


1. 如果之前装过cocopods,最好先卸载掉,卸载命令:
$ sudo gem uninstall cocoapods

2. 先查看本地安装过的cocopods相关东西,命令如下:
$ gem list --local | grep cocoapods
会显示如下:
cocoapods (1.7.2)
cocoapods-core (1.7.2)
cocoapods-deintegrate (1.0.4)
cocoapods-downloader (1.2.2)
cocoapods-plugins (1.0.0)
cocoapods-search (1.0.0)
cocoapods-stats (1.1.0)
cocoapods-trunk (1.3.1)
cocoapods-try (1.1.0)

3. 使用删除命令, 逐个删除:
$ sudo gem uninstall cocoapods-core

1.3 Mac文件夹的显示隐藏命令行:


隐藏:defaults write com.apple.finder AppleShowAllFiles -bool true
显示:defaults write com.apple.finder AppleShowAllFiles -bool false

这里选择将隐藏文件显示出来; 退出终端,重启Finder. 如果不确定,可以把主目录下的隐藏文件都给删了.

1.4. RVM



  • Ruby Version Manager,Ruby版本管理器,包括Ruby的版本管理和Gem库管理(gemset)


1. 安装RVM
$ curl -sSL https://get.rvm.io | bash -s stable
期间可能需要管理员密码, 以及自动通过homebrew安装依赖包,等待一段时间就安装好了.

2. 载入 RVM 环境
$ source ~/.rvm/scripts/rvm

3. 检查一下是否安装正确
$ rvm -v
会显示如下:
rvm 1.29.8 (latest) by Michal Papis, Piotr Kuczynski, Wayne E. Seguin [https://rvm.io]
表示安装正确.

注意: 也可使用 ($ rvm -v) 来判断是否安装了rvm
// 结果类似如下代表没有安装rvm
zsh: command not found: rvm

1.5 用RVM安装Ruby环境


1. 列出已知的ruby版本
$ rvm list known

2. 选择最新版本进行安装(这里以2.6.0为例)
$ rvm install 2.6.0

同样继续等待漫长的下载,编译过程,完成以后,Ruby, Ruby Gems 就安装好了。

3. 查询已经安装的ruby
$ rvm list

卸载一个已安装版本的命令
$ rvm remove + 要卸载的版本号

4. RVM 装好以后,需要执行下面的命令将指定版本的 Ruby 设置为系统默认版本
$ rvm 2.6.0 --default

5. 测试操作是否正确(分 2 步)
$ ruby -v
会显示如下:
ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-darwin18]

$ gem -v
会显示如下:
3.0.4

注: RubyGems(简称 gems)是一个用于对 Ruby组件进行打包的 Ruby 打包系统。 它提供一个分发 Ruby 程序和库的标准格式,还提供一个管理程序包安装的工具。

1.6 更换镜像源


1. 查看当前镜像源
$ gem sources -l
会显示如下:
*** CURRENT SOURCES ***
http://rubygems.org/

2. 先删除, 再添加
$ gem sources --remove https://rubygems.org/
$ gem sources -a https://gems.ruby-china.com/

3. 再次查看, 测试是否成功
$ gem sources -l
会显示如下:
*** CURRENT SOURCES ***
https://gems.ruby-china.com/

到这里就已经把Ruby环境成功的安装到了Mac OS X上,接下来就可以进行相应的开发使用了。

1.7 安装home-brew




  • 也可选择跳过这步, 直接安装cocoapods, 引入库文件时, 会提示你自动安装home-brew

  • Homebrew: 是一个包管理器,用于在Mac上安装一些OS X没有的UNIX工具。

  • 官方网址: https://brew.sh/index_zh-cn

  • Homebrew是完全基于 Git 和 ruby.



1. 安装
$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)”
安装过程中需要按回车键确认

2. 检测是否存在冲突
$ brew doctor

3. 检查是否安装成功, 出现版本号就成功了.
$ brew --version

1.8 安装Cocoapods (步骤有点慢,不要急)


1. 坑点:
使用$ sudo gem install cocoapods安装cocoapods 极有可能报error: RPC failed / early EOF

2. 正确的使用方法:
A. 看到报这个错之后,需要在终端执行$ sudo xcode-select -switch /Applications/Xcode.app/Contents/Developer 这句,具体如下: 先找到xcode,显示包内容,在Contents里找到Developer文件,然后在终端输入sudo xcode-select -switch ,把找到的Developer文件夹拖进终端,就得到后边的路径啦,然后执行。因为xcode位置和版本安装的不一样,可能路径会有所不同。我的最终是sudo xcode-select -switch /Applications/Xcode.app/Contents/Developer 这个。
B. 执行$ sudo gem install -n /usr/local/bin cocoapods
C. Git clone https://git.coding.net/CocoaPods/Specs.git ~/.cocoapods/repos/master

然后就等待吧,下载完就ok了.

2. 离线安装RVM方式



// 离线包
curl -sSL https://github.com/rvm/rvm/tarball/stable -o rvm-stable.tar.gz
// 创建文件夹
mkdir rvm && cd rvm
// 解包
tar --strip-components=1 -xzf ../rvm-stable.tar.gz
// 安装
./install --auto-dotfiles
// 加载
source ~/.rvm/scripts/rvm
// if --path was specified when instaling rvm, use the specified path rather than '~/.rvm'

// 查询 ruby的版本
rvm list known


在查询 ruby的版本时可能会出现下面的错误:A RVM version () is installed yet 1.25.14 (master) is loaded.Please do one of the following:* 'rvm reload'* open a new shell* 'echo rvm_auto_reload_flag=1 >> ~/.rvmrc' # for auto reload with msg.* 'echo rvm_auto_reload_flag=2 >> ~/.rvmrc' # for silent auto reload.




解决办法: sudo rm -rf /users/your_username/.rvmThen close and reopen the terminal.

然后重新打开终端即可.



3. [!] CDN: trunk URL couldn't be downloaded:


CocoaPods 1.8 版本之后的一些说明!



我的解决方法



// 在podfile 文件中添加 (选一个就行)
source 'https://github.com/CocoaPods/Specs.git'

source 'https://cdn.cocoapods.org/'

.End

链接:https://www.jianshu.com/p/d80b06f6e4e7
收起阅读 »

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 组件的使用方式到这里就完全介绍完毕,那本文到这里就结束了,谢谢观看,明天见~

收起阅读 »

我在几期薅羊毛活动中学到了什么~

前言 为什么突然想写一篇总结了呢,其实也是被虐的。今年 3 月份初期,我们商城接了一个 XX 银行的一分购活动(说白点就是薅羊毛),那时候是活动第一期,未曾想到活动入口开放时,流量能直接将 cpu 冲至 100%,导致服务短暂的 502 了。。期间采取了紧急方...
继续阅读 »

前言


为什么突然想写一篇总结了呢,其实也是被虐的。今年 3 月份初期,我们商城接了一个 XX 银行的一分购活动(说白点就是薅羊毛),那时候是活动第一期,未曾想到活动入口开放时,流量能直接将 cpu 冲至 100%,导致服务短暂的 502 了。。期间采取了紧急方案到活动结束,但未曾想到还有活动二期,以及上周刚上线的活动三期。想着最近这段时间也做了一些事情,还有遇到的一些坑点,趁此机会,就不偷懒记录一下吧。


活动一期到三期具体做了些什么


技术背景&瓶颈


项目是基于 Vue+SSR 架构的,且没有做缓存处理,没做缓存的主要原因第一个是原本应用 tps 比较低,改造动力不强,并且页面渲染结果中包含了用户数据以及服务端时间,没法在不经过改造的情况下直接上缓存。所以当一期活动大流量冲击时,高并发情况下很容易将 cpu 打至 100%。


一期在未知情况下,服务直接扛不住了,当时为了活动能正常进行,首要方案就是先加机器扛住部分压力,紧接着就是加缓存,目前有两种缓存方案,缓存页面或缓存组件,但由于我们的需要缓存的商品详情页组件涉及到动态信息,可维护性太差,心智成本高,最终选择了前者。我们整理了一下商详页有关动态变化的信息数据(与时间/用户相关等类型的数据),在活动期间,紧急屏蔽了部分不影响功能的动态内容,然后页面上 CDN。


活动结束后,我们做了下复盘,要像应用要能保障大流量情况下稳定运行,性能优化处理是避免不了的了。为此我们做了以下四大方案:




  1. 对数据做动静分离: 我们可以将数据分类成动静两类,静态数据即是一段时间内,不随时间/用户改变,动态数据则是相反的,经常变动,与时间有关,有用户相关等类型的数据都可以归类为动态数据。原页面无法上缓存的最大阻碍就是,就是在 node 渲染模板时,会默认获取用户数据,或是在 asyncData 中调用用户相关的接口;此外,还会设置服务端时间等动态数据。所以思路就是将静态数据放在 node 获取,将动态数据放到客户端(浏览器读取 asyncData、mounted 等浏览器生命周期里)获取保证服务端的清洁。




  2. 页面接入 CDN: 经过动静态分离的改造后,已经可以将其路径加入 cdn。但需要注意路径上 query 等参数是否会影响渲染,如果影响,需要把逻辑搬到客户端,同时需要注意一下过期时间(如 10 分钟)是否会对业务产生影响




  3. 应用缓存: 如果在比较糟糕的情况下,cdn 失效了导致回源率上升,应用本身还是需要做好准备。这就要根据项目需要去选择内存缓存/redis 缓存。




  4. 自动降级: 在极端的情况下,前面的缓存都没挡住流量,就需要终极方案:降级渲染。所谓降级渲染,就是不进入路由,直接将空模板返回,完全交给浏览器去做渲染。这样做的最大好处就是完全避免了 node 压力,将一个 ssr 应用变成了静态应用。缺点也是显而易见的,用户看到的是空模板,需要等待初始化。那如何自动降级呢,可以通过定时器时时检测 cpu、负载等压力,来确定当前机器的负载,从而决定是否降级;也可以通过 url 的 query 上是否携带特定标识,显式地决定是否降级。




对项目方案做了以上性能优化接下来就是压测,也算是顺利上线了。🤪


二期活动没过多久又来了,不过如我们预期,项目很稳定地扛住了压力,期间也增加了流量接口,并加友好提示等优化。但其中一个痛点时需要针对几个特殊商品去做个文案处理,这几个文案非接口返回,也是临时性的一些醒目提示,没必要放在详情页接口中返回。由于时间也很紧急,我们不确定后面还有没有这种特定的文案需求(和具体的页面以及特定的区域关联),决定还是暂时写一些 low code:针对特定的活动商品 id,临时添加文案,活动下线之后,把文案去除。这样做的风险性是有的,毕竟代码是临时性的,需要上下线,并且有时间延迟。但好在活动结束时是周末,最后一天流量访问并不大,给了相对应的引导文案以及售后处理,评估下来影响不大,尚可接受。


以下图片商品详情页和商品购买页需要加的特定文案:


WechatIMG61.png


WechatIMG62.png


薅羊毛活动是真香现场吗~~6 月底产品就和我打了个招呼,说 XX 活动又要有三期了,但整体方案依旧和二期一样不变。我内心:还来???(小声说句打工人太苦了),由于最终时间没定下来,也有了二期的教训之后,和后端同学也一起商量了一下,把活动商品往配置化方向考虑,放在我们配置后台中文案模块且是可行的。针对商品详情页,考虑到不破坏动静分离,先确定下配置化接口返回的数据是静态的,可以放在服务端获取。以下具体三期做的事情:



  1. 将参与活动商品的文案做成配置化,从配置接口获取,去除 low code

  2. 整理大流量活动页(例如商详页)的接口,放在客户端的接口需要做限流,接口达到一定的 tps 后,返回 429 状态,前端要做容错处理,页面功能正常访问,屏蔽限流接口错误。

  3. 针对购买限流接口,需要给 busy 提示(活动太火爆了,请稍后再试)


// 统一在 getResponseErrorInterceptor 处针对 429 状态做处理
export const getResponseErrorInterceptor = ({ errorCallback }) => (error) => {
if (!isClient) {
...
} else {
// 429 Code 服务报错需要支持不弹出错误提示
if (+error.response.status === 429) {
// 针对限流接口,且需要 busy 提示时增加 needBusyMsg 属性
errorCallback(error.config.needBusyMsg ? '活动太火爆了,请稍后再试' : null);
} else {
...
);
}
}

return throwError(error);
};

结束了上周一周忙碌的压测和测试,三期终于上线了。👏👏👏👏


想了解更多 Vue SSR 性能优化方案可以移步到这里: Vue SSR 性能优化实践


实际过程中遇到的一些 Coding Question



  1. 本地项目(vue-ssr 架构)里,一个动态接口放在服务端获取时,有一段代码很 easy,一个是否是会员的标识去开通会员按钮的显隐,代码如下(代码有简化):


<redirect
v-if="!isVip"
:link="link"
type="h5"
>
开通会员<ui-icon name="arrow-right" />
</redirect>

本地中虽然运行正常,但是会有如下警告:
vue.esm.js:6428 Mismatching childNodes vs. VNodes: NodeList(2) [div, a.DetailVip-right.Redirect] (2) [VNode, VNode]


[Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render.


但更新到测试环境中,页面会失效,点击失效等。会报有如下错误:
Failed to execute 'appendChild' on 'Node': This node type does not support this method


分析


Vue SSR 指南在客户端激活里刚好说到了一些需要注意的坑,使用「SSR + 客户端混合」时,需要了解的一件事是,浏览器可能会更改的一些特殊的 HTML 结构。例如 table 中漏写<tbody>,像以下几种情况也会导致导致服务端返回的静态 HTML 和浏览器端渲染的内容不一致:



  1. 无效的HTML(例如:<p><p>Text</p></p>

  2. 服务器与客户端的不同状态

  3. 例如日期、时间戳和随机化等不确定变量的影响

  4. 第三方脚本影响到了组件的渲染

  5. 需要身份验证相关时


当然确定原因之后对症下药,总结有几种办法可以解决此问题:



  1. 检查相关代码,确保 HTML 有效

  2. 最简单粗暴的一个方法就是:用v-show去代替v-if,要知道构建时生成的HTML是无状态的,应用程序中与身份验证相关的所有部分应该只在客户端呈现,具体可以 diff 下获取数据以及在服务器/客户端呈现的内容,解决服务器和客户端之间的状态不一致

  3. 面对第三方脚本这类的,可以通过将组件包装在标签中来避免在服务器端渲染组件

  4. .....(欢迎补充)


针对此类问题,还可以看看这篇文章:blog.lichter.io/posts/vue-h…


2: 同样的 h5 页面,在浏览器中打开配置生效,而在公众号&小程序中打开却失效了?


三期的时候,我们把活动商品 id 和对应文案做成了配置化处理。
配置方式如下:


WechatIMG64.png
获取商品配置内容经过 JSON.stringify()之后,毋庸置疑会得到如下字符串:


455164527672033280|龙支付 立减 10 元|满 40 立减 10(仅限 XX 卡)#\\n623841656577658880|龙支付 立减 10 元(仅限 XX 卡)|满 40 立减 10(仅限 XX 卡)#\\n350947143063699456|龙支付测试 立减 10 元(仅限 XX 卡)|满 40 立减 10 测试(仅限 XX 卡)#

在详情页获取所有的商品 id 列表信息,我们用的#做区分,写了一个简单的正则如下:


activityItems() {
return this.getFieldValue('activity_item')?.split('#\\n');
},

但在公众号里面打开我们的 h5 链接,会将#自动转义成\,内容会变成:


455164527672033280|龙支付 立减 10 元(仅限 XX 卡)|满 40 立减 10(仅限 XX 卡)\\\n623841656577658880|龙支付 立减 10 元(仅限 XX 卡)|满 40 立减 10(仅限 XX 卡)\\\n350947143063699456|龙支付测试 立减 10 元(仅限 XX 卡)|满 40 立减 10 测试(仅限 XX 卡)\

啊,这,,,不是吧??(发现时内心几乎是崩溃的)😩 解决方式立马把#字符换成不被转移的字符;


另外在小程序中打开失效是因为延用二期的方案,当时做了限制判断,只需要在主站和主 app 中打开有效,小程序设有自己单独的 appid,三期活动有多方入口,把该限制放开即可。


总结


薅羊毛参与了三期,也是积累了一些经验,踩了一些坑吧,想着太久没写了该记录一下了,先总结到这里,还有忘记的再补充~



链接:https://juejin.cn/post/6989618653998088228

收起阅读 »

webpack5 和 webpack4 的区别有哪些 ?

1、Tree Shaking 作用: 如果我们的项目中引入了 lodash 包,但是我只有了其中的一个方法。其他没有用到的方法是不是冗余的?此时 tree-shaking 就可以把没有用的那些东西剔除掉,来减少最终的bundle体积。 usedExports...
继续阅读 »

1、Tree Shaking


作用: 如果我们的项目中引入了 lodash 包,但是我只有了其中的一个方法。其他没有用到的方法是不是冗余的?此时 tree-shaking 就可以把没有用的那些东西剔除掉,来减少最终的bundle体积。



usedExports : true, 标记没有用的叶子




minimize: true, 摇掉那些没有用的叶子



  // webpack.config.js中
module.exports = {
optimization: {
usedExports: true, //只导出被使用的模块
minimize : true // 启动压缩
}
}

由于 tree shaking 只支持 esmodule ,如果你打包出来的是 commonjs,此时 tree-shaking 就失效了。不过当前大家都用的是 vue,react 等框架,他们都是用 babel-loader 编译,以下配置就能够保证他一定是 esmodule


image.png


webpack5的 mode=“production” 自动开启 tree-shaking。


2、压缩代码


1.webpack4


webpack4 上需要下载安装 terser-webpack-plugin 插件,并且需要以下配置:



const TerserPlugin = require('terser-webpack-plugin')

module.exports = {
// ...other config
optimization: {
minimize: !isDev,
minimizer: [
new TerserPlugin({
extractComments: false,
terserOptions: {
compress: {
pure_funcs: ['console.log']
}
}
}) ]
}

2.webpack5

内部本身就自带 js 压缩功能,他内置了 terser-webpack-plugin 插件,我们不用再下载安装。而且在 mode=“production” 的时候会自动开启 js 压缩功能。



如果你要在开发环境使用,就用下面:



  // webpack.config.js中
module.exports = {
optimization: {
usedExports: true, //只导出被使用的模块
minimize : true // 启动压缩
}
}

3.js 压缩失效问题

当你下载 optimize-css-assets-webpack-plugin ,执行 css 压缩以后,你会发现 webpack5 默认的 js 压缩功能失效了。先说 optimize-css-assets-webpack-plugin 的配置:



npm install optimize-css-assets-webpack-plugin -D



module.exports = { 
optimization: {
minimizer: [
new OptimizeCssAssetsPlugin()
]
},
}


此时的压缩插件 optimize-css-assets-webpack-plugin 可以配置到 plugins 里面去,也可以如图配置到到 optimization 里面。区别如下:



配置到 plugins 中,那么这个插件在任何情况下都会工作。 而配置在 optimization 表示只有 minimize 为 true 的时候才能工作。



当安装 optimize-css-assets-webpack-plugin 以后你去打包会发现原来可以压缩的 js 文件,现在不能压缩了。原因是你指定的压缩器是



optimize-css-assets-webpack-plugin 导致默认的 terser-webpack-plugin 就会失效。解决办法如下:



npm install terser-webpack-plugin -D



 optimization: {
minimizer: [
new TerserPlugin({
extractComments: false,
terserOptions: {
compress: { pure_funcs: ['console.log'] },
},
}),
new OptimiazeCssAssetPlugin(),
]
}

即便在 webpack5 中,你也要像 webpack4 中一样使用 js 压缩。


4.注意事项

在webpack5里面使用 optimize-css-assets-webpack-plugin 又是会报错,因为官方已经打算要废除了,请使用替换方案:



npm i css-assets-webpack-plugin -D



3、合并模块



普通打包只是将一个模块最终放到一个单独的立即执行函数中,如果你有很多模块,那么就有很多立即执行函数。concatenateModules 可以要所有的模块都合并到一个函数里面去。



optimization.concatenateModules = true


配置如下:


module.exports = {
optimization: {
usedExports: true,
concatenateModules: true,
minimize: true
}
}

此时配合 tree-shaking 你会发现打包的体积会减小很多。


4、副作用 sideEffects



webpack4 新增了一个 sideEffects 的功能,容许我们通过配置来标识我们的代码是否有副作用。




这个特性只有在开发 npm 包的时候用到



副作用的解释: 在utils文件夹下面有index.js文件,用于系统导出utils里面其他文件,作用就是写的少, 不管 utils 里面有多少方法,我都只需要引入 utils 即可。


// utils/index.js
export * from './getXXX.js';
export * from './getAAA.js';
export * from './getBBB.js';
export * from './getCCC.js';

 // 在其他文件使用 getXXX 引入
import {getXX} from '../utils'

此时,如果文件 getBBB 在外界没有用到,而 tree-shaking 又不能把它摇掉咋办?这个 getBBB 就是副作用。你或许要问 tree-shaking 为什么不能奈何他?原因就是:他在 utils/index.js 里面使用了。只能开启副作用特性。如下:


// package.json中
{
name:“项目名称”,
....
sideEffects: false
}

// webpack.config.js

module.exports = {
mode: 'none',
....
optimization: {
sideEffects: true
}
}

副作用开启:



(1)optimization.sideEffects = true 开启副作用功能




(2)package.json 中设置 sideEffects : false 标记所有模块无副作用



说明: webpack 打包前都会检查项目所属的 package.json 文件中的 sideEffects 标识,如果没有副作用,那些没有用到的模块就不需要打包,反之亦然。此时,在webpack.config.js 里面开启 sideEffects。


5、webpack 缓存


1.webpack4 缓存配置

支持缓存在内存中



npm install hard-source-webpack-plugin -D



const HardSourceWebpackPlugin = require('hard-source-webpack-plugin') 

module.exports = {
plugins: [
// 其它 plugin...
new HardSourceWebpackPlugin(),
] }

2. webpack5 缓存配置

webpack5 内部内置了 cache 缓存机制。直接配置即可。



cache 会在开发模式下被设置成 type: memory 而且会在生产模式把cache 给禁用掉。



// webpack.config.js
module.exports= {
// 使用持久化缓存
cache: {
type: 'filesystem',
cacheDirectory: path.join(__dirname, 'node_modules/.cac/webpack')
}
}


type 的可选值为: memory 使用内容缓存,filesystem 使用文件缓存。




当 type=filesystem的时候设置cacheDirectory才生效。用于设置你需要的东西缓存放在哪里?



6、对loader的优化



webpack 4 加载资源需要用不同的 loader




  • raw-loader 将文件导入为字符串

  • url-loader 将文件作为 data url 内联到 bundle文件中

  • file-loader 将文件发送到输出目录中


image.png



webpack5 的资源模块类型替换 loader




  • asset/resource 替换 file-loader(发送单独文件)

  • asset/inline 替换 url-loader (导出 url)

  • asset/source 替换 raw-loader(导出源代码)

  • asset


image.png


webpack5


7、启动服务的差别


1.webpack4 启动服务

通过 webpack-dev-server 启动服务


2.webpack5 启动服务

内置使用 webpack serve 启动,但是他的日志不是很好,所以一般都加都喜欢用 webpack-dev-server 优化。


8. 模块联邦(微前端)



webpack 可以实现 应用程序和应用程序之间的引用。



9.devtool的差别


sourceMap需要在 webpack.config.js里面直接配置 devtool 就可以实现了。而 devtool有很多个选项值,不同的选项值,不同的选项产生的 .map 文件不同,打包速度不同。


一般情况下,我们一般在开发环境配置用“cheap-eval-module-source-map”,在生产环境用‘none’。


devtool在webpack4和webpack5上也是有区别的



v4: devtool: 'cheap-eval-module-source-map'




v5: devtool: 'eval-cheap-module-source-map'



10.热更新差别



webpack4设置



image.png



webpack5 设置



如果你使用的是bable6,按照上述设置,你会发现热更新无效,需要添加配置:


  module.hot.accept('需要热启动的文件',(source)=>{
//自定义热启动
})

当前最新版的babel里面的 babel-loader已经帮我们处理的热更新失效的问题。所以不必担心,直接使用即可。


如果你引入 mini-css-extract-plugin 以后你会发现 样式的热更新也会失效。


只能在开发环境使用style-loader,而在生产环境用MinicssExtractPlugin.loader。 如下:


image.png


11、使用 webpack-merge 的差别



webpack4 导入



const merge = require('webpack-merge);



webpack 5 导入



const {merge} = require('webpack-merge');


12、 使用 copy-webpack-plugin 的差别


//webpack.config.js
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
plugins: [
// webpack 4
new CopyWebpackPlugin(['public']),

// webpack 5
new CopyWebpackPlugin({
patterns: [{
from: './public',
to: './dist/public'
}]
})
]
}

webpack5 支持的新版本里面需要配置的更加清楚。


链接:https://juejin.cn/post/6990869970385109005

收起阅读 »

与大厂面试官的高端博弈、顶级拉扯

前言 最近是跳槽季,发现有小伙伴在一些非技术的软性问题上答的不是很好。 众所周知,程序员情商偏低,而这些软性问题,恰恰都具有一定欺骗性和吹牛皮成分在里边,对于演技不好的直男癌,简直就是天生克星。 其实不用太担心,软性问题往往就那几个,稍加训练和准备,你就可以成...
继续阅读 »

前言


最近是跳槽季,发现有小伙伴在一些非技术的软性问题上答的不是很好。


众所周知,程序员情商偏低,而这些软性问题,恰恰都具有一定欺骗性和吹牛皮成分在里边,对于演技不好的直男癌,简直就是天生克星。


其实不用太担心,软性问题往往就那几个,稍加训练和准备,你就可以成为一位高端名猿。


题目


第 1 题:说一下你自己的缺点


演技考验:4星


这题处处暗藏杀鸡,很多小伙伴会推心置腹,诚实的将自己所有缺点说出来,比如:“我脾气不好”、“我学习东西慢”、“我贪财好.色”。


这种缺点,人人都有,但大可不必说出来。


很多小伙伴说,我就要做real man,你爱喜欢不喜欢。外面的那些人都是虚伪装哔的妖艳剑货,我就要做真实的自己。


其实,这根本不是真实的自己,这是情商低。不懂得逢场作戏,不懂得在特定的场合说正确的话。


所以,如何说一个缺点,但又不完全是缺点的缺点,还能让人觉得这是优点的缺点


你应该这么演:


唉声叹气,微笑、头部倾斜45度看向地面,说:我偶尔会因为专研技术问题,而搞到深夜,把自己弄得很累。今后我会多注意,把控好技术学习和工作状态的平衡。


这里几个细节注意以下



  • “头部倾斜45度看向地面”从行为学上让人感觉你没有架子,谦虚好相处。

  • 回答中多次用了“搞”、“弄”、“累”等动词,让人觉得你不做作,接地气。

  • 我说我的缺点是因为太爱学习,就好像是说,我的缺点是因为我太有钱,这根本不是缺点,而是优点,只是换了一种说法,直接上天日龙


这就是传说中的反套路学,程序猿和面试官之间的高端博弈,顶级拉扯


如果你有更好的答案或想法,欢迎在题目对应的github下留言:第一名的小蝌蚪


第 2 题:你对加班什么看法?


演技考验:3星


这题真的是炒鸡容易被问到。首先,没人有会喜欢加班,但是,但凡出现“敏捷开发”、“谈加班看法”的,大概率都是经常加班的公司。


我觉得只要不是业界那几个著名黑工厂,正常的加班,都是可以接受的


在大厂工作了8年,总结了一下导致我加班的几个原因:



  • 不爱思考

  • 能力不行

  • 贪玩好.色


  • 第五点不能说,懂的都懂


这8年里,我对加班的心态,从早期的抵触,到中期的忍耐,到现在的主动,我发现,你越不想加班,加班就越来找你,还不如主动进攻,主动学习技术,主动思考工作中优化点。


将重复性工作,通过开发一些工程化、自动化工具去代替,慢慢的你会发现,工作会事半功倍,不再那么被动,真的比被按住头逼你加班要好受很多。


撸迅曾说过:“工作就像强J,与其反抗,不如闭上眼睛好好享受”,很悲哀,但也是中年屌丝生存之道


所以,这道题有一个很不错的答案:
如果是工作需要我会义不容辞加班。但同时,我也会思考工作中的优化点,将重复性工作,通过开发一些工程化、自动化工具去代替,提高工作效率,减少不必要的加班


如果你有更好的答案或想法,欢迎在这题目对应的github下留言:第一名的小蝌蚪


第 3 题:你为什么从上家离职


演技考验:2星


有几个注意点,在说离职原因的时候,有几个大忌:



  • 不能说你跟上家leader闹掰

  • 不能说自己是被开除的

  • 不能说上家公司黄了,所以我跳槽了


反正千万记住,不要说任何上家公司和leader的坏话。


正确的表演应该是,不管上家对你怎么样,都要一副感恩戴德的态度去展示给面试官。是因为某种不可抗拒因素所以才导致你离职


有一个常规回答:上家公司平台趋于稳定,想到一家更大的平台去开阔视野,更好的展现自己的实力,让自己创造更大的价值


见过几个比较搞笑的回答:“我老婆要生了,想去一家能准点下班的公司”、“我失恋了,想去一个地方重新开始”,嘻嘻,但老铁还是别这么回答就好


如果你有更好的答案或想法,欢迎在这题目对应的github下留言:第一名的小蝌蚪


第 4 题:面对hr和面试官刁难,如何应对


演技考验:5星


这个情况是我真实遇到的,我职业生涯第二家公司(某知名大厂),就被当时的hr刁难了


她当时很高傲,还很藐视我,说过几句话让我印象深刻



  • 你背景很一般啊,上家公司是个小公司,我们一般只要有大厂经历的候选人

  • 我们都招985的研究生,你的综合素质没有我想象的好

  • 你简历里面写的那几个项目经历,都太一般了,没有任何知名度,你凭什么觉得我们会要你呢?


当时面完以后,我其实很生气,觉得那个hr人品有问题,但从头到尾都忍住了。


最后诡异的是。。。我还拿到了offer,拿到了期望的薪资和职级。。。。。


很久以后问过她为什么要这么面试候选人


她的回答,大意是:“hr一般这么刁难你,都是想压你的气势,让你受挫,让你觉得配不上我们,增加你接收offer的成功率,还能增加将来谈判薪资的主动权。”


这绝对是一种对人性心理的操控了。。。。


第二点,尤为重要“测试你的情商,那些随便刺激一下,就暴怒、就疯狂反驳、情绪失控的候选人,是绝对不能要的。能全程忍下来,且不卑不亢,保持自信的人,证明了你的抗压能力,确保你今后在工作中能处理好各种工作关系和极端情况”


她的回答令我醍醐灌顶,可能是代码敲多了,从来没有想过,原来人性也可以和代码一样,被度量,被证明


从那以后,对大厂的hr专业度,还是挺认可的


当然,这种刁难也可能是pua的早期萌芽,细思极恐。。。


所以针对候选人在面试过程中被刁难情况,应该如何表现和回答呢?


大家可以先自行思考一下


我把答案放在了github中,稍后公布,如果你有更好的想法,也可以给我留言:第一名的小蝌蚪


总结


以上4题,揭露了些面试中的套路和反套路,展示了逢场作戏和演技的技巧,很多人可能会觉得这样很虚伪


这里又要引用撸迅的一句名言:“当混浊变成一种常态,清白也会是一种罪行”


虚伪的人,有时候也是一种自我保护。


也许这并不虚伪,而是一种生存法则,因为不这么做,就会被这么做的人弄si。


希望大家针对上面提出的问题,和对应的答案,触发一些思考,总结自身,完善自己。不仅仅是面试,在工作也是要这样。


由于篇幅限制,下期会公布另外几道软性问题的答案:



  • 1.职场上,你的技术方案和同事不合,如何处理?

  • 2.如果你的方案和领导不合,如何处理?

  • 3.你未来五年的规划是什么

  • 4.你如何看待ppt文化

  • 5.你的入职,能给我们带来什么价值



链接:https://juejin.cn/post/6990533805786431524

收起阅读 »

vuepress的使用

快速上手 前提条件 VuePress 需要 Node.js (opens new window)>= 8.6 1.安装vuepress yarn add -D vuepress # npm install -D vuepress 2.创建你的第一篇文...
继续阅读 »

快速上手



前提条件


VuePress 需要 Node.js (opens new window)>= 8.6



1.安装vuepress


yarn add -D vuepress # npm install -D vuepress

2.创建你的第一篇文档


mkdir docs && echo '# Hello VuePress' > docs/README.md

3.在 package.json 中添加 scripts


{
"scripts": {
  "docs:dev": "vuepress dev docs",
  "docs:build": "vuepress build docs"
}
}

4.在本地启动服务器


yarn docs:dev # npm run docs:dev

VuePress 会在 http://localhost:8080启动一个热重载的开发服务器。


目录结构



  • docs/.vuepress: 用于存放全局的配置、组件、静态资源等。

  • docs/.vuepress/components: 该目录中的 Vue 组件将会被自动注册为全局组件。

  • docs/.vuepress/theme: 用于存放本地主题。

  • docs/.vuepress/styles: 用于存放样式相关的文件。

  • docs/.vuepress/styles/index.styl: 将会被自动应用的全局样式文件,会生成在最终的 CSS 文件结尾,具有比默认样式更高的优先级。

  • docs/.vuepress/styles/palette.styl: 用于重写默认颜色常量,或者设置新的 stylus 颜色常量。

  • docs/.vuepress/public: 静态资源目录。

  • docs/.vuepress/templates: 存储 HTML 模板文件。

  • docs/.vuepress/templates/dev.html: 用于开发环境的 HTML 模板文件。

  • docs/.vuepress/templates/ssr.html: 构建时基于 Vue SSR 的 HTML 模板文件。

  • docs/.vuepress/config.js: 配置文件的入口文件,也可以是 YMLtoml

  • docs/.vuepress/enhanceApp.js: 客户端应用的增强。


默认的页面路由























文件的相对路径页面路由地址
/README.md/
/guide/README.md/guide/
/guide/config.md/guide/config.html

主题配置


首页


默认的主题提供了一个首页(Homepage)的布局 (用于 这个网站的主页)。想要使用它,需要在你的根级 README.md 中指定 home: true。以下是一个如何使用的例子:


---
home: true
heroImage: /hero.png
heroText: Hero 标题
tagline: Hero 副标题
actionText: 快速上手 →
actionLink: /zh/guide/
features:
- title: 简洁至上
details: 以 Markdown 为中心的项目结构,以最少的配置帮助你专注于写作。
- title: Vue驱动
details: 享受 Vue + webpack 的开发体验,在 Markdown 中使用 Vue 组件,同时可以使用 Vue 来开发自定义主题。
- title: 高性能
details: VuePress 为每个页面预渲染生成静态的 HTML,同时在页面被加载的时候,将作为 SPA 运行。
footer: MIT Licensed | Copyright © 2018-present Evan You
---

更多配置项


导航栏


导航栏可能包含你的页面标题、搜索框导航栏链接多语言切换仓库链接,它们均取决于你的配置。


导航栏 Logo


// .vuepress/config.js
module.exports = {
themeConfig: {
logo: '/assets/img/logo.png',
}
}

导航栏链接


// .vuepress/config.js
module.exports = {
themeConfig: {
nav: [
{ text: 'Home', link: '/' },
{ text: 'Guide', link: '/guide/' },
{ text: 'External', link: 'https://google.com' },
{ text: 'test', link: 'test', target:'_self', rel:'' }
]
}
}

设置分组


// .vuepress/config.js
module.exports = {
themeConfig: {
nav: [
{
text: 'Languages',
items: [
{ text: 'Chinese', link: '/language/chinese/' },
{ text: 'Japanese', link: '/language/japanese/' }
]
}
]
}
}

侧边栏


想要使 侧边栏(Sidebar)生效,需要配置 themeConfig.sidebar,基本的配置,需要一个包含了多个链接的数组:


// .vuepress/config.js
module.exports = {
themeConfig: {
sidebar: [
'/',
'/page-a',
['/page-b', 'Explicit link text']
]
}
}

省略 .md 拓展名,同时以 / 结尾的路径将会被视为 */README.md,这个链接的文字将会被自动获取到


嵌套的标题链接


默认情况下,侧边栏会自动地显示由当前页面的标题(headers)组成的链接,并按照页面本身的结构进行嵌套,你可以通过 themeConfig.sidebarDepth 来修改它的行为。默认的深度是 1,它将提取到 h2 的标题,设置成 0 将会禁用标题(headers)链接,同时,最大的深度为 2,它将同时提取 h2h3 标题。


显示所有页面的标题链接


// .vuepress/config.js
module.exports = {
themeConfig: {
displayAllHeaders: true // 默认值:false
}
}

活动的标题链接


默认情况下,当用户通过滚动查看页面的不同部分时,嵌套的标题链接和 URL 中的 Hash 值会实时更新,这个行为可以通过以下的配置来禁用:


// .vuepress/config.js
module.exports = {
themeConfig: {
activeHeaderLinks: false, // 默认值:true
}
}

侧边栏分组


// .vuepress/config.js
module.exports = {
themeConfig: {
sidebar: [
{
title: 'Group 1', // 必要的
path: '/foo/', // 可选的, 标题的跳转链接,应为绝对路径且必须存在
collapsable: false, // 可选的, 默认值是 true,
sidebarDepth: 1, // 可选的, 默认值是 1
children: [
'/'
]
},
{
title: 'Group 2',
children: [ /* ... */ ],
initialOpenGroupIndex: -1 // 可选的, 默认值是 0
}
]
}
}

侧边栏的每个子组默认是可折叠的,你可以设置 collapsable: false 来让一个组永远都是展开状态。


多个侧边栏


.
├─ README.md
├─ contact.md
├─ about.md
├─ foo/
│ ├─ README.md
│ ├─ one.md
│ └─ two.md
└─ bar/
├─ README.md
├─ three.md
└─ four.md

注意


确保 fallback 侧边栏被最后定义。VuePress 会按顺序遍历侧边栏配置来寻找匹配的配置


搜索框


内置搜索


你可以通过设置 themeConfig.search: false 来禁用默认的搜索框,或是通过 themeConfig.searchMaxSuggestions 来调整默认搜索框显示的搜索结果数量


// .vuepress/config.js
module.exports = {
themeConfig: {
search: false,
searchMaxSuggestions: 10
}
}

最后更新时间


你可以通过 themeConfig.lastUpdated 选项来获取每个文件最后一次 git 提交的 UNIX 时间戳(ms),同时它将以合适的日期格式显示在每一页的底部:


// .vuepress/config.js
module.exports = {
themeConfig: {
lastUpdated: 'Last Updated', // string | boolean
}
}

上 / 下一篇链接


上一篇和下一篇文章的链接将会自动地根据当前页面的侧边栏的顺序来获取。


你可以通过 themeConfig.nextLinksthemeConfig.prevLinks 来全局禁用它们:


// .vuepress/config.js
module.exports = {
themeConfig: {
// 默认值是 true 。设置为 false 来禁用所有页面的 下一篇 链接
nextLinks: false,
// 默认值是 true 。设置为 false 来禁用所有页面的 上一篇 链接
prevLinks: false
}
}

Git 仓库和编辑链接


当你提供了 themeConfig.repo 选项,将会自动在每个页面的导航栏生成生成一个 GitHub 链接,以及在页面的底部生成一个 "Edit this page" 链接。


// .vuepress/config.js
module.exports = {
themeConfig: {
// 假定是 GitHub. 同时也可以是一个完整的 GitLab URL
repo: 'vuejs/vuepress',
// 自定义仓库链接文字。默认从 `themeConfig.repo` 中自动推断为
// "GitHub"/"GitLab"/"Bitbucket" 其中之一,或是 "Source"。
repoLabel: '查看源码',
// 以下为可选的编辑链接选项
// 假如你的文档仓库和项目本身不在一个仓库:
docsRepo: 'vuejs/vuepress',
// 假如文档不是放在仓库的根目录下:
docsDir: 'docs',
// 假如文档放在一个特定的分支下:
docsBranch: 'master',
// 默认是 false, 设置为 true 来启用
editLinks: true,
// 默认为 "Edit this page"
editLinkText: '帮助我们改善此页面!'
}
}

静态资源


静态资源都存放在public文件下


图片的使用


<img :src="$withBase('/frontend/prototype-chains.jpg')" alt="prototype-chains">


logo


// .vuepress/config.js
module.exports = {
themeConfig: {
logo: '/logo.png',
}
}

首页logo


// README.md
---
heroImage: /app.png
---

Markdown扩展


Emoji


你可以在这个列表 找到所有可用的 Emoji。


自定义容器




::: warning
这是一个警告
:::
::: danger
这是一个危险警告
:::
::: details
这是一个详情块,在 IE / Edge 中不生效
:::

你也可以自定义块中的标题:


::: danger STOP
危险区域,禁止通行
:::
::: details 点击查看代码
这是代码
:::

代码块中的语法高亮


VuePress 使用了 Prism 来为 markdown 中的代码块实现语法高亮。Prism 支持大量的编程语言,你需要做的只是在代码块的开始倒勾中附加一个有效的语言别名:


    ```
export default {
name: 'MyComponent',
// ...
}
```

在 Prism 的网站上查看 合法的语言列表


代码块中的行高亮


    ``` js {4}
export default {
data () {
return {
msg: 'Highlighted!'
}
}
}
```

除了单行以外,你也可指定多行,行数区间,或是两者都指定。



  • 行数区间: 例如 {5-8}, {3-10}, {10-17}

  • 多个单行: 例如 {4,7,9}

  • 行数区间与多个单行: 例如 {4,7-13,16,23-27,40}


行号


// config.js
module.exports = {
markdown: {
lineNumbers: true
}
}

导入代码段


<<< @/docs/.vuepress/code/test.js

使用vue组件


所有在 .vuepress/components 中找到的 *.vue 文件将会自动地被注册为全局的异步组件,如:


.
└─ .vuepress
└─ components
├─ demo-1.vue
├─ OtherComponent.vue
└─ Foo
└─ Bar.vue

你可以直接使用这些组件在任意的 Markdown 文件中(组件名是通过文件名取到的):


<demo-1/>
<OtherComponent/>
<Foo-Bar/>

插件


@vuepress/plugin-back-to-top


安装


yarn add -D @vuepress/plugin-back-to-top
# OR npm install -D @vuepress/plugin-back-to-top

使用


module.exports = {
plugins: ['@vuepress/back-to-top']
}

构建与部署


Github Pages 是面向用户、组织和项目开放的公共静态页面搭建托管服务,站点可以被免费托管在 Github 上,你可以选择使用 Github Pages 默 认提供的域名 github.io 或者自定义域名来发布站点。不仅免除了租服务器的麻烦,而且部署起来非常轻松。简而言之,在GitHub Pages上发布博客是非常好的选择。


创建两个仓库


1、amjanney.github.io,站点仓库,用来存放打包后的文件。


2、docs,用来放vuepress写的文档。


github.io会默认读取根目录下的index.html作为首页。所以我们要做的就是把打包后的vuepress文档上传到创建的名为<username>.github.io的仓库下。


image.png


仓库地址


github.com/amjanney/am…


github.com/amjanney/do…


发布


在docs下面有个deploy.sh文件,代码如下:


#!/usr/bin/env sh

# 确保脚本抛出遇到的错误
set -e

# 生成静态文件
npm run docs:build

# 进入生成的文件夹
cd docs/.vuepress/dist

# 如果是发布到自定义域名
# echo 'www.example.com' > CNAME

git init
git add -A
git commit -m 'deploy'

# 如果发布到 https://amjanney.github.io
git push -f https://github.com/amjanney/amjanney.github.io.git master

在package.json文件中配置命令


"deploy": "bash deploy.sh"

运行


npm run deploy

会执行deploy.sh中的命令,先打包vuepress文件,在docs/.vuepress/dist下面打包后的文件,cd到这个文件下面,通过git在上传到amjanney.github.io.git仓库,这时候访问amjanney.github.io/,文档就已经生效了。


每次更改了文件,就需要执行npm run deploy命令,更新文档。


当然这个过程可以集成一下,每次push代码到master的时候,自动出发npm run deploy过程。


更多部署方式移步链接


链接:https://juejin.cn/post/6990718623484477447

收起阅读 »

如何做前端单元测试

单元测试 什么是单元测试 单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证 需要访问数据库的测试不是单元测试 需要访问网络的测试不是单元测试 需要访问文件系统的测试不是单元测试 --- 修改代码的艺术 为什么要做单元测...
继续阅读 »

单元测试


什么是单元测试



单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证



需要访问数据库的测试不是单元测试

需要访问网络的测试不是单元测试

需要访问文件系统的测试不是单元测试

--- 修改代码的艺术

为什么要做单元测试



  1. 执行单元测试,就是为了证明这段代码的行为和我们期望的一致

  2. 进行充分的单元测试,是提高软件质量,降低开发成本的必由之路

  3. 在开发人员做出修改后进行可重复的单元测试可以避免产生那些令人不快的负作用


怎么去设计单元测试



  1. 理解这个单元原本要做什么(倒推出一个概要的规格说明(阅读那些程序代码和注释))

  2. 画出流程图

  3. 组织对这个概要规格说明的走读(Review),以确保对这个单元的说明没有基本的错误

  4. 设计单元测试

    在实践工作中,进行了完整计划的单元测试和编写实际的代码所花费的精力大致上是相同的





两个常用的单元测试方法论



  • TDD(Test-driven development):测试驱动开发

  • BDD(Behavior-driven development):行为驱动开发


前端与单元测试


如何对前端代码做单元测试


通常是针对函数、模块、对象进行测试


至少需要三类工具来进行单元测试:



  • *测试管理工具

  • *测试框架:就是运行测试的工具。通过它,可以为 JavaScript 应用添加测试,从而保证代码的质量

  • *断言库

  • 测试浏览器

  • 测试覆盖率统计工具


测试框架选择


Jasmine:Behavior-Drive development(BDD)风格的测试框架,在业内较为流行,功能很全面,自带 asssert、mock 功能


Qunit:该框架诞生之初是为了 jquery 的单元测试,后来独立出来不再依赖于 jquery 本身,但是其身上还是脱离不开 jquery 的影子


Mocha:node 社区大神 tj 的作品,可以在 node 和 browser 端使用,具有很强的灵活性,可以选择自己喜欢的断言库,选择测试结果的 report


Jest:来自于 facebook 出品的通用测试框架,Jest 是一个令人愉快的 JavaScript 测试框架,专注于简洁明快。他适用但不局限于使用以下技术的项目:Babel, TypeScript, Node, React, Angular, Vue


如何编写测试用例(Jest + Enzyme)


通常测试文件名与要测试的文件名相同,后缀为.test.js,所有测试文件默认放在__test__文件夹中


describe块之中,提供测试用例的四个函数:before()、after()、beforeEach()和 afterEach()。它们会在指定时间执行(如果不需要可以不写)


测试文件中应包括一个或多个describe, 每个 describe 中可以有一个或多个it,每个describe中可以有一个或多个expect.



describe 称为"测试套件"(test suite),it 块称为"测试用例"(test case)。



expect就是判断源码的实际执行结果与预期结果是否一致,如果不一致就抛出一个错误.


所有的测试都应该是确定的。 任何时候测试未改变的组件都应该产生相同的结果。 你需要确保你的快照测试与平台和其他不相干数据无关。


基础模板


describe('加法函数测试', () => {
before(() => {
// 在本区块的所有测试用例之前执行
});
after(() => {
// 在本区块的所有测试用例之后执行
});
beforeEach(() => {
// 在本区块的每个测试用例之前执行
});
afterEach(() => {
// 在本区块的每个测试用例之后执行
});

it('1加1应该等于2', () => {
expect(add(1, 1)).toBe(2);
});
it('2加2应该等于4', () => {
expect(add(2, 2)).toBe(42);
});
});

常用的测试


组件中的方法测试


it('changeCardType', () => {
let component = shallow(<Card />);
expect(component.instance().cardType).toBe('initCard');
component.instance().changeCardType('testCard');
expect(component.instance().cardType).toBe('testCard');
});

模拟事件测试


通过 Enzyme 可以在这个返回的 dom 对象上调用类似 jquery 的 api 进行一些查找操作,还可以调用 setProps 和 setState 来设置 props 和 state,也可以用 simulate 来模拟事件,触发事件后,去判断 props 上特定函数是否被调用,传参是否正确;组件状态是否发生预料之中的修改;某个 dom 节点是否存在是否符合期望


it('can save value and cancel', () => {
const value = 'edit';
const { wrapper, props } = setup({
editable: true,
});
wrapper.find('input').simulate('change', { target: { value } });
wrapper.setProps({ status: 'save' });
expect(props.onChange).toBeCalledWith(value);
});

使用 snapshot 进行 UI 测试


it('App -- snapshot', () => {
const renderedValue = renderer.create(<App />).toJSON();
expect(renderedValue).toMatchSnapshot();
});

真实用例分析(组件)


写一个单元测试你需要这样做



  1. 看代码,熟悉待测试模块的功能和作用

  2. 设计测试用例必须覆盖到组件的各种情况

  3. 对错误情况的测试


通常测试文件名与要测试的文件名相同,后缀为.test.js,所有测试文件默认放在test文件夹中,一般测试文件包含下列内容:



  • 全局设置:一些前置配置,mock 的全局或第三方方法、进行一些重复的组件初始化工作,,当多个测试用例有相同的初始化组件行为时,可以在这里进行挂载和销毁

  • UI 测试:为组件打快照,第一次运行测试命令会在目录下生成一个组件的 DOM 节点快照,在之后的测试命令中会与快照文件进行 diff 对照,避免在后面对组件进行了非期望的 UI 更改

  • 关键行为:验证组件的基本行为(如:Checkbox 组件的勾选行为)

  • 事件:测试各种事件的触发

  • 属性:测试传入不同属性值是否得到与期望一致的结果


accordion 组件


// accordion.test.tsx
import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals';
import Enzyme, { mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import toJSON from 'enzyme-to-json';
import JestMock from 'jest-mock';
import React from 'react';
import { Accordion } from '..';

Enzyme.configure({ adapter: new Adapter() }); // 需要根据项目的react版本来配置适配

describe('Accordion', () => {
// 测试套件,通过 describe 块来将测试分组
let onChange: JestMock.Mock<any, any>; // Jest 提供的mock 函数,擦除函数的实际实现、捕获对函数的调用
let wrapper: Enzyme.ReactWrapper;

beforeEach(() => {
// 在运行测试前做的一些准备工作
onChange = jest.fn();
wrapper = mount(
<Accordion onChange={onChange}>
<Accordion.Item name='one' header='one'>
two
</Accordion.Item>
<Accordion.Item name='two' header='two' disabled={true}>
two
</Accordion.Item>
<Accordion.Item name='three' header='three' showIcon={false}>
three
</Accordion.Item>
<Accordion.Item name='four' header='four' active={true} icons={['custom']}>
four
</Accordion.Item>
</Accordion>
);
});

afterEach(() => {
// 在运行测试后进行的一些整理工作
wrapper.unmount();
});

// UI快照测试,确保你的UI不会因意外改变
test('Test snapshot', () => {
// 测试用例,需要提供详细的测试用例描述
expect(toJSON(wrapper)).toMatchSnapshot();
});

// 事件测试
test('should trigger onChange', () => {
wrapper.find('.qtc-accordion-item-header').first().simulate('click');

expect(onChange.mock.calls.length).toBe(1);
expect(onChange.mock.calls[0][0]).toBe('one');
});

// 关键逻辑测试
//点击头部触发展开收起
test('should expand and collapse', () => {
wrapper.find('.qtc-accordion-item-header').at(2).simulate('click');

expect(wrapper.find('.qtc-accordion-item').at(2).hasClass('active')).toBeTruthy();
});
// 配置disabled时不可展开
test('should not trigger onChange when disabled', () => {
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

expect(onChange.mock.calls.length).toBe(0);
});

// 对所有的属性配置进行测试
// 是否展示头部左侧图标
test('hide icon', () => {
expect(wrapper.find('.qtc-accordion-item-header').at(2).children().length).toBe(2);
});
// 自定义图标
test('custom icon', () => {
const customIcon = wrapper.find('.qtc-accordion-item-header').at(3).children().first();

expect(customIcon.getDOMNode().innerHTML).toBe('custom');
});
// 是否可展开多项
test('single expand', () => {
onChange = jest.fn();
wrapper = mount(
<Accordion multiple={false} onChange={onChange}>
<Accordion.Item name='1'>1</Accordion.Item>
<Accordion.Item name='2'>2</Accordion.Item>
</Accordion>
);

wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['2']));
});
test('mutiple expand', () => {
onChange = jest.fn();
wrapper = mount(
<Accordion multiple={true} onChange={onChange}>
<Accordion.Item name='1'>1</Accordion.Item>
<Accordion.Item name='2'>2</Accordion.Item>
</Accordion>
);

wrapper.find('.qtc-accordion-item-header').at(0).simulate('click');
wrapper.find('.qtc-accordion-item-header').at(1).simulate('click');

expect(wrapper.find(Accordion).state().activeNames).toEqual(new Set(['1', '2']));
});
});

难点记录


对一些异步和延时的处理


使用单个参数调用 done,而不是将测试放在一个空参数的函数,Jest 会等 done 回调函数执行结束后,结束测试


test('the data is peanut butter', done => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}

fetchData(callback);
});

模拟 setTimeout


// 提取utils方法,封装一个sleep
export const sleep = async (timeout = 0) => {
await act(async () => {
await new Promise((resolve) => globalTimeout(resolve, timeout));
});
};

// 测试用例中调用
it('测试用例', async () => {
doSomething();
await sleep(1000);
doSomething();
})

mock 组件内系统函数的返回结果


对于组件内调用了 document 上的方法,可以通过 mock 指定方法的返回值,来保证一致性


const getBoundingClientRectMock = jest.spyOn(
HTMLHeadingElement.prototype,
'getBoundingClientRect',
);

beforeAll(() => {
getBoundingClientRectMock.mockReturnValue({
width: 100,
height: 100,
top: 1000,
} as DOMRect);
});

afterAll(() => {
getBoundingClientRectMock.mockRestore();
});

直接调用组件方法


通过 wrapper.instance()获取组件实例,再调用组件内方法,如:wrapper.instance().handleScroll()
测试系统方法的调用


const scrollToSpy = jest.spyOn(window, 'scrollTo');

const calls = scrollToSpy.mock.calls.length;

expect(scrollToSpy.mock.calls.length).toBeGreaterThan(calls);

使用属性匹配器代替时间


当快照有时间时,通过属性匹配器可以在快照写入或者测试前只检查这些匹配器是否通过,而不是具体的值


it('will check the matchers and pass', () => {
const user = {
createdAt: new Date(),
id: Math.floor(Math.random() * 20),
name: 'LeBron James',
};

expect(user).toMatchSnapshot({
createdAt: expect.any(Date),
id: expect.any(Number),
});
});

附录


JEST 语法


匹配器


expect:返回一个'期望‘的对象


toBe:使用 object.is 去判断相等


toEqual:递归检测对象或数组的每个字段


not:测试相反的匹配


真值


toBeNull:只匹配 null


toBeUndefined:只匹配 undefined


toBeDefined:与 toBeUndefined 相反


toBeTruthy:匹配任何 if 语句为真


toBeFalsy:匹配任务 if 语句为假


数字


toBeGreaterThan:大于


toBeGreaterThanOrEqual:大于等于


toBeLessThan:小于


toBeLessThanOrEqual:小于等于


toBeCloseTo:比较浮点数相等


字符串


toMatch:匹配字符串


Array


toContain:检测一个数组或可迭代对象是否包含某个特定项


例外


toThrow:测试某函数在调用时是否抛出了错误


自定义匹配器


// The mock function was called at least once
expect(mockFunc).toHaveBeenCalled();

// The mock function was called at least once with the specified args
expect(mockFunc).toHaveBeenCalledWith(arg1, arg2);

// The last call to the mock function was called with the specified args
expect(mockFunc).toHaveBeenLastCalledWith(arg1, arg2);

// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();

测试异步代码


回调


默认情况下,一旦到达运行上下文底部 Jest 测试立即结束,使用单个参数调用 done,而不是将测试放在一个空参数的函数,Jest 会等 done 回调函数执行结束后,结束测试。


test('the data is peanut butter', (done) => {
function callback(data) {
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}

fetchData(callback);
});

Promises


为你的测试返回一个 Promise,Jest 会等待 Promise 的 resove 状态,如果 Promist 被拒绝,则测试将自动失败


test('the data is peanut butter', () => {
return fetchData().then((data) => {
expect(data).toBe('peanut butter');
});
});

如果期望 Promise 被 Reject,则需要使用 .catch 方法。 请确保添加 expect.assertions 来验证一定数量的断言被调用。 否则,一个 fulfilled 状态的 Promise 不会让测试用例失败


test('the fetch fails with an error', () => {
expect.assertions(1);
return fetchData().catch(e => expect(e).toMatch('error'));
});

.resolves/.rejects


test('the data is peanut butter', () => {
return expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', () => {
return expect(fetchData()).rejects.toMatch('error');
});

Async/Await


写异步测试用例时,可以再传递给 test 的函数前面加上 async。


安装和移除


为多次测试重复设置:beforeEach、afterEach 来为多次测试重复设置的工作


一次性设置:beforeAll、afterAll 在文件的开头做一次设置


作用域:可以通过 describe 块将测试分组,before 和 after 的块在 describe 块内部时,则只适用于该 describe 块内的测试


模拟函数


Mock 函数允许你测试代码之间的连接——实现方式包括:擦除函数的实际实现、捕获对函数的调用(以及在这些调用中传递的参数)、在使用 new 实例化时捕获构造函数的实例、允许测试时配置返回值。


两种方法可以模拟函数:1.在测试代码中创建一个 mock 函数,2.编写一个手动 mock 来覆盖模块依赖


mock 函数


const mockCallback = jest.fn((x) => 42 + x);
forEach([0, 1], mockCallback);

// 此 mock 函数被调用了两次
expect(mockCallback.mock.calls.length).toBe(2);

// 第一次调用函数时的第一个参数是 0
expect(mockCallback.mock.calls[0][0]).toBe(0);

// 第二次调用函数时的第一个参数是 1
expect(mockCallback.mock.calls[1][0]).toBe(1);

// 第一次函数调用的返回值是 42
expect(mockCallback.mock.results[0].value).toBe(42);

.mock 属性


所有的 mokc 函数都有这个特殊的.mock 属性,它保存了关于此函数如何被调用、调用时的返回值的信息。.mock 属性还追踪每次调用时的 this 的值,所以我们同样可以检查 this


// 这个函数被实例化两次
expect(someMockFunction.mock.instances.length).toBe(2);

// 这个函数被第一次实例化返回的对象中,有一个 name 属性,且被设置为了 'test’
expect(someMockFunction.mock.instances[0].name).toEqual('test');

Mock 的返回值


const myMock = jest.fn();
console.log(myMock());
// > undefined

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true

模拟模块


可以 用 jest.mock(...)函数自动模拟 axios 模块,一旦模拟模块,我们可为.get 提供一个 mockResolveValue,它会返回假数据用于测试


// users.test.js
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
const users = [{ name: 'Bob' }];
const resp = { data: users };
axios.get.mockResolvedValue(resp);

// or you could use the following depending on your use case:
// axios.get.mockImplementation(() => Promise.resolve(resp))

return Users.all().then((data) => expect(data).toEqual(users));
});

Mock 实现


用 mock 函数替换指定返回值:jest.fn(cb => cb(null, true))


用 mockImplementation 根据别的模块定义默认的 mock 函数实现:jest.mock('../foo'); const foo = require('../foo');foo.mockImplementation(() => 42);


当你需要模拟某个函数调用返回不同结果时,请使用 mockImplementationOnce 方法


.mockReturnThis()函数来支持链式调用


Mock 名称


可以为你的 Mock 函数命名,该名字会替代 jest.fn() 在单元测试的错误输出中出现。 用这个方法你就可以在单元测试输出日志中快速找到你定义的 Mock 函数


const myMockFn = jest
.fn()
.mockReturnValue('default')
.mockImplementation((scalar) => 42 + scalar)
.mockName('add42');

快照测试


当要确保你的 UI 不会又意外的改变时,快照测试是非常有用的工具;典型的做法是在渲染了 UI 组件之后,保存一个快照文件, 检测他是否与保存在单元测试旁的快照文件相匹配。 若两个快照不匹配,测试将失败:有可能做了意外的更改,或者 UI 组件已经更新到了新版本。


快照文件应该和项目代码一起提交并做代码评审


更新快照


jest --updateSnapshot/jest -u,这将为所有失败的快照测试重新生成快照文件。 如果我们无意间产生了 Bug 导致快照测试失败,应该先修复这些 Bug,再生成快照文件;只重新生成一部分的快照文件,你可以使用--testNamePattern 来正则匹配想要生成的快照名字


属性匹配器


项目中常常会有不定值字段生成(例如 IDs 和 Dates),针对这些情况,Jest 允许为任何属性提供匹配器(非对称匹配器)。 在快照写入或者测试前只检查这些匹配器是否通过,而不是具体的值


it('will check the matchers and pass', () => {
const user = {
createdAt: new Date(),
id: Math.floor(Math.random() * 20),
name: 'LeBron James',
};

expect(user).toMatchSnapshot({
createdAt: expect.any(Date),
id: expect.any(Number),
});
});

覆盖率


Jest 还提供了生成测试覆盖率报告的命令,只需要添加上 --coverage 这个参数即可生成,再加上--colors 可根据覆盖率生成不同颜色的报告(<50%红色,50%~80%黄色, ≥80%绿色)



  • % Stmts 是语句覆盖率(statement coverage):是否每个语句都执行了

  • % Branch 分支覆盖率(branch coverage):是否每个分支代码块都执行了(if, ||, ? : )

  • % Funcs 函数覆盖率(function coverage):是否每个函数都调用了

  • % Lines 行覆盖率(line coverage):是否每一行都执行了


Enzyme


nzyme 来自 airbnb 公司,是一个用于 React 的 JavaScript 测试工具,方便你判断、操纵和历遍 React Components 输出。Enzyme 的 API 通过模仿 jQuery 的 API ,使得 DOM 操作和历遍很灵活、直观。Enzyme 兼容所有的主要测试运行器和判断库。


安装与配置



  • npm install --save-dev enzyme

  • 安装 Enzyme Adapter 来对应 React 的版本 npm install --save-dev enzyme-adapter-react-16


渲染方式


shallow 浅渲染


返回组件的浅渲染,对官方 shallow rendering 进行封装。浅渲染 作用就是:它仅仅会渲染至虚拟 dom,不会返回真实的 dom 节点,这个对测试性能有极大的提升。shallow 只渲染当前组件,只能能对当前组件做断言


render 静态渲染


将 React 组件渲染成静态的 HTML 字符串,然后使用 Cheerio 这个库解析这段字符串,并返回一个 Cheerio 的实例对象,可以用来分析组件的 html 结构,对于 snapshot 使用 render 比较合适


mount 完全渲染


将组件渲染加载成一个真实的 DOM 节点,用来测试 DOM API 的交互和组件的生命周期,用到了 jsdom 来模拟浏览器环境


常用 API


.simulate(event, mock):用来模拟事件触发,event 为事件名称,mock 为一个 event object


.instance():返回测试组件的实例


.find(selector):根据选择器查找节点,selector 可以是 CSS 中的选择器,也可以是组件的构造函数,以及组件的 display name 等


.get(index):返回指定位置的子组件的 DOM 节点


.at(index):返回指定位置的子组件


.first():返回第一个子组件


.last():返回最后一个子组件


.type():返回当前组件的类型


.contains(nodeOrNodes):当前对象是否包含参数重点 node,参数类型为 react 对象或对象数组


.text():返回当前组件的文本内容


.html():返回当前组件的 HTML 代码形式


.props():返回根组件的所有属性


.prop(key):返回根组件的指定属性


.state([key]):返回根组件的状态


.setState(nextState):设置根组件的状态


.setProps(nextProps):设置根组件的属性



链接:https://juejin.cn/post/6990655486659919902

收起阅读 »

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 - UIApplication

一、UIApplication1.简单介绍(1)UIApplication对象是应用程序的象征,一个UIApplication对象就代表一个应用程序。(2)每一个应用都有自己的UIApplication对象,而且是单例的,如果试图在程序中新建一个UIAppli...
继续阅读 »

一、UIApplication

1.简单介绍

(1)UIApplication对象是应用程序的象征,一个UIApplication对象就代表一个应用程序。
(2)每一个应用都有自己的UIApplication对象,而且是单例的,如果试图在程序中新建一个UIApplication对象,那么将报错提示。
(3)通过[UIApplication sharedApplication]可以获得这个单例对象
(4) 一个iOS程序启动后创建的第一个对象就是UIApplication对象,且只有一个(通过代码获取两个UIApplication对象,打印地址可以看出地址是相同的)。
(5)利用UIApplication对象,能进行一些应用级别的操作

2.应用级别的操作示例:

(1)设置应用程序图标右上角的红色提醒数字(如QQ,微博等消息的时候,图标上面会显示1,2,3条新信息等。)


@property(nonatomic) NSInteger applicationIconBadgeNumber;
代码实现和效果:
- (void)viewDidLoad{
[super viewDidLoad];
//创建并添加一个按钮
UIButton *btn=[[UIButton alloc]initWithFrame:CGRectMake(100, 100, 60, 30)];
[btn setTitle:@"按钮" forState:UIControlStateNormal];
[btn setBackgroundColor:[UIColor brownColor]];
[btn addTarget:self action:@selector(onClick) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:btn];
}
-(void)onClick{
NSLog(@"按钮点击事件");
//错误,只能有一个唯一的UIApplication对象,不能再进行创建
// UIApplication *app=[[UIApplication alloc]init];
//通过sharedApplication获取该程序的UIApplication对象
UIApplication *app=[UIApplication sharedApplication];
app.applicationIconBadgeNumber=123;
}


(2)设置联网指示器的可见性

@property(nonatomic,getter=isNetworkActivityIndicatorVisible) BOOL networkActivityIndicatorVisible;
代码和效果:
 //设置指示器的联网动画
app.networkActivityIndicatorVisible=YES;



(3)管理状态栏

  • 从iOS7开始,系统提供了2种管理状态栏的方式
    通过UIViewController管理(每一个UIViewController都可以拥有自己不同的状态栏).
    在iOS7中,默认情况下,状态栏都是由UIViewController管理的,UIViewController实现下列方法就可以轻松管理状态栏的可见性和样式

状态栏的样式    
- (UIStatusBarStyle)preferredStatusBarStyle;
状态栏的可见性 
-(BOOL)prefersStatusBarHidden;
#pragma mark-设置状态栏的样式
-(UIStatusBarStyle)preferredStatusBarStyle{
//设置为白色
//return UIStatusBarStyleLightContent;
//默认为黑色
return UIStatusBarStyleDefault;
}
#pragma mark-设置状态栏是否隐藏(否)
-(BOOL)prefersStatusBarHidden{
return NO;
}

通过UIApplication管理(一个应用程序的状态栏都由它统一管理)
如果想利用UIApplication来管理状态栏,首先得修改Info.plist的设置




//通过sharedApplication获取该程序的UIApplication对象
UIApplication *app=[UIApplication sharedApplication];
app.applicationIconBadgeNumber=123;

//设置指示器的联网动画
app.networkActivityIndicatorVisible=YES;
//设置状态栏的样式
//app.statusBarStyle=UIStatusBarStyleDefault;//默认(黑色)
//设置为白色+动画效果
[app setStatusBarStyle:UIStatusBarStyleLightContent animated:YES];
//设置状态栏是否隐藏
app.statusBarHidden=YES;
//设置状态栏是否隐藏+动画效果
[app setStatusBarHidden:YES withAnimation:UIStatusBarAnimationFade];

  • 补充
    既然两种都可以对状态栏进行管理,那么什么时候该用什么呢?
    如果状态栏的样式只设置一次,那就用UIApplication来进行管理;
    如果状态栏是否隐藏,样式不一样那就用控制器进行管理。
    UIApplication来进行管理有额外的好处,可以提供动画

(4)openURL:方法
UIApplication有个功能十分强大的openURL:方法

-(BOOL)openURL:(NSURL*)url;

openURL:方法的部分功能有
打电话


UIApplication *app = [UIApplicationsharedApplication]; [app openURL:[NSURLURLWithString:@"tel://10086"]];
发短信 [app openURL:[NSURLURLWithString:@"sms://10086"]];
发邮件 [app openURL:[NSURLURLWithString:@"mailto://12345@qq.com"]];
打开一个网页资源 [app openURL:[NSURLURLWithString:@"http://ios.itcast.cn"]];
打开其他app程序 openURL方法,可以打开其他APP。

URL补充:
URL:统一资源定位符,用来唯一的表示一个资源。
URL格式:协议头://主机地址/资源路径
网络资源:http/ ftp等 表示百度上一张图片的地址  http://www.baidu.com/images/20140603/abc.png
本地资源:file:///users/apple/desktop/abc.png(主机地址省略)

二、UIApplication Delegate

1.简单说明
所有的移动操作系统都有个致命的缺点:app很容易受到打扰。比如一个来电或者锁屏会导致app进入后台甚至被终止。
还有很多其它类似的情况会导致app受到干扰,在app受到干扰时,会产生一些系统事件,这时UIApplication会通知它的delegate对象,让delegate代理来处理这些系统事件。
作用:当被打断的时候,通知代理进入到后台。



每次新建完项目,都有个带有“AppDelegate”字眼的类,它就是UIApplication的代理,NJAppDelegate默认已经遵守了UIApplicationDelegate协议,已经是UIApplication的代理。




2.代理方法

#import "AppDelegate.h"

@interface AppDelegate ()

@end

@implementation AppDelegate

// 当应用程序启动完毕的时候就会调用(系统自动调用)
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

return YES;
}
//当应用程序程序失去焦点的时候调用(系统自动调用)
- (void)applicationWillResignActive:(UIApplication *)application {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}

//当程序进入后台的时候调用
//一般在这里保存应用程序的数据和状态
- (void)applicationDidEnterBackground:(UIApplication *)application {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}

//将要进入前台的是时候调用
//一般在该方法中恢复应用程序的数据,以及状态
- (void)applicationWillEnterForeground:(UIApplication *)application {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}

//应用程序获得焦点
- (void)applicationDidBecomeActive:(UIApplication *)application {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}

// 应用程序即将被销毁的时候会调用该方法
// 注意:如果应用程序处于挂起状态的时候无法调用该方法
- (void)applicationWillTerminate:(UIApplication *)application {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}


@end

三、程序启动原理

UIApplicationMain
main函数中执行了一个UIApplicationMain这个函数

intUIApplicationMain(int argc, char *argv[], NSString *principalClassName, NSString *delegateClassName);
*argc、argv:直接传递给UIApplicationMain进行相关处理即可
*principalClassName:指定应用程序类名(app的象征),该类必须是UIApplication(或子类)。如果为nil,则用UIApplication类作为默认值

1、delegateClassName:指定应用程序的代理类,该类必须遵守UIApplicationDelegate协议
2、UIApplicationMain函数会根据principalClassName创建UIApplication对象,根据delegateClassName创建一个delegate对象,并将该delegate对象赋值给UIApplication对象中的delegate属性

接着会建立应用程序的Main Runloop(事件循环),进行事件的处理(首先会在程序完毕后调用delegate对象的application:didFinishLaunchingWithOptions:方法)
程序正常退出时UIApplicationMain函数才返回

#import <UIKit/UIKit.h>
#import "AppDelegate.h"

int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
/*
argc: 系统或者用户传入的参数个数
argv: 系统或者用户传入的实际参数
1.根据传入的第三个参数创建UIApplication对象
2.根据传入的第四个产生创建UIApplication对象的代理
3.设置刚刚创建出来的代理对象为UIApplication的代理
4.开启一个事件循环
*/

}
}

系统入口的代码和参数说明:
argc:系统或者用户传入的参数
argv:系统或用户传入的实际参数
1.根据传入的第三个参数,创建UIApplication对象
2.根据传入的第四个产生创建UIApplication对象的代理
3.设置刚刚创建出来的代理对象为UIApplication的代理
4.开启一个事件循环(可以理解为里面是一个死循环)这个时间循环是一个队列(先进先出)先添加进去的先处理
ios程序启动原理



四、程序启动的完整过程

1.main函数
2.UIApplicationMain

  • 创建UIApplication对象
  • 创建UIApplication的delegate对象
    3.delegate对象开始处理(监听)系统事件(没有storyboard)
  • 程序启动完毕的时候, 就会调用代理的application:didFinishLaunchingWithOptions:方法
  • 在application:didFinishLaunchingWithOptions:中创建UIWindow
  • 创建和设置UIWindow的rootViewController
  • 显示窗口

3.根据Info.plist获得最主要storyboard的文件名,加载最主要的storyboard(有storyboard)

  • 创建UIWindow
  • 创建和设置UIWindow的rootViewController
  • 显示窗口


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

收起阅读 »

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大小优化



项目实战


收起阅读 »

老掉牙之前端组件化

组件化已经无处不在。可能每个人一张嘴都是组件化模块化。 这个时候我们能否认真回想一下,自己的组件,真的是组件化了吗? 怎样的组件化才算比较好的组件化? 根据客观事实(主要是主观臆想),浅谈一下前端的组件化。 1、组件化的使用背景 业务的迭代和堆积 1、单个文件...
继续阅读 »

组件化已经无处不在。可能每个人一张嘴都是组件化模块化。

这个时候我们能否认真回想一下,自己的组件,真的是组件化了吗?

怎样的组件化才算比较好的组件化?

根据客观事实(主要是主观臆想),浅谈一下前端的组件化。


1、组件化的使用背景


业务的迭代和堆积


1、单个文件有成千上万行代码,可读性非常差,维护也不方便

2、有大量重复的代码,相同或者类似的功能实现了很多遍

3、新功能的开发成本巨大

4、不敢重构,牵一发而动全身


场景的多样化


1、不同的项目,类似的场景

2、相同的项目,越来越多的场景


背景和场景都有了。

如何判断你的代码质量如何?

一个比较直观(其实也是我的主观)的判断就是:2年后我是否还能轻易维护或者复用你的代码。

如果举步维艰,那我们应该好好想想,什么才是组件化?


2、组件化的定义和特性


(蹩脚的)定义


组件化 就是将UI、样式以及其实现的比较完整的功能作为独立的整体,

无关业务,

无论将这个整体放在哪里去使用,

它都具有一样的功能和UI,

从而达到复用的效果,

这种体系化的思想就是组件化。


特性——高内聚,低耦合


一个组件中包含了完整视图结构,样式表以及交互逻辑。

它是封闭的结构,对外提供属性的输入用来满足用户的多样化需求,

对内自己管理内部状态来满足自己的交互需求,

一言蔽之就是:高内聚,低耦合。


组件化的目的


减少重复造轮子(虽然造轮子是避免不了的事)、反复修轮胎(疲于奔命迭代维护组件)的频率,

增加代码复用性和灵活性,提高系统设计,从而提高开发效率。

说完组件化的基本定义和特性,接下来就说说组件化的分类吧。


3、组件的分类


分类的形式可能有多种和多角度,我这里按自己的日常(react技术栈)使用分一下。


函数组件和类组件


1、函数组件的写法要比类组件简洁

2、类组件比函数组件功能更加强大

类组件可以维护自身的状态变量,还有不同的生命周期方法,

可以让开发者能够在组件的不同阶段(挂载、更新、卸载),

对组件做更多的控制。

ps:自从hooks出来以后, 函数组件也能实现生命周期等更多骚操作了。


自己的使用原则:

如果功能相对简单简洁,就是用函数组件;

功能丰富多样,相对复杂就使用类组件。

界限不是特别清晰,但是大的基本原则是这个,仅供参考。


展示型组件和容器型组件


1、展示型组件像个父亲(父爱如山,一动不动,他真的不动!)

不用理会数据是怎么来到哪里去,

它只管兵来将挡,水来土掩,

你给我什么数据,就就拿它来渲染成相应的UI即可。


2、容器型组件则像个老师。

他需要知道如何获取学生(子组件)所需数据,

以及这些数据的处理逻辑,

并把数据和使用方法(处理逻辑)通过props提供给学生(子组件)使用。

ps:容器型组件一般是有状态组件,因为它们需要管理页面所需数据。


无状态组件和有状态组件


1、无状态组件内部不维护自身的state(因为它根本就没使用),

只根据外部组件传入的props返回待渲染的元素(传说中的饭来张口,衣来伸手?)。


2、有状态组件维护自身状态的变化,

并且根据外部组件传入的props和自身的state,

共同决定最终渲染的元素(自给自足,别人也来者不拒)


高阶组件


任性,就是不说,自己百度谷歌一下...

说完分类,说说使用了组件化以后有啥好处。


4、组件化的价值


业务价值


1、组件与具体场景或业务解耦,提升开发效率与降低风险,促进业务安全、快速迭代

2、提高了组件的复用和可移植,减少开发人力

3、方便测试模拟接口数据

4、便于堆积木般快速组合不同的场景和业务


技术价值


1、组件与框架解耦,去中心化的开发,这背后其实是一种前端微服务化的思想;

2、页面资源可以动态按需加载,提升性能;

3、组件可持续,可自由组合,提升开发效率;


O了,说得组件化这么好,那我们设计组件前,应该思考什么问题呢?


5、开发组件前的灵魂拷问?


组件应该如何划分,划分的粒度标准是什么?


组件划分的依据通常是业务逻辑和功能,

一段相对完整且完备的功能逻辑就是划分的一个界限。
当然,你还要考虑各组件之间的关系是否明确以及组件的可复用度等等。


这个组件还能再减吗,它还能减少不必要的代码和依赖吗?


越简单的组件,往往具备越容易复用的特性。
你看各大知名UI库组件库,
他们设计出来的轮子是不是几乎都差不多?
都是按钮,弹窗,提示框等等?
而那些看起来功能超级丰富的组件,往往使用的场景反而很少?


此组件是不是渣男?是不是到处去破坏别的组件,入侵其他组件却挥一挥衣袖,留下了一堆云彩?


如果一个组件的封装性不够好,或者实现自身的越界操作,

就可能对自身之外造成了侵入,这种情况应该尽量避免。

确保组件的生命周期能够对其影响进行有效的管理(如destroy后不留痕迹)。

举个栗子: 如果你的组件触发了鼠标滚轮事件,一滚轮就打断点,或者不断的加数据到内存中。

直到组件被销毁了,还没有去掉这个事件的处理,那就可能导致内存泄漏等等情况了,渣男...


是否便于拔插,就是来去自如的意思,复用方便,删除随便?


1、组件设计需要考虑需要适用的不同场景,

在组件设计时进行必要的兼容。

2、各组件之前以组合的关系互相配合,

也是对功能需求的模块化抽象,

当需求变化时可以将实现以模块粒度进行调整。

3、设计组件时要想想如何快速接入,快速删除,但是又不影响别的组件和业务。


6、组件化的设计原则和标准


不说了,打字打累了,直接上图 (有图有真相)


PS:这个标准和原则来源于网络(图是我的图),我看到的时候表示默默认同,记下来了,再截图出来。如果侵权,请告诉我删除。


组件化,你今天想好了吗?


链接:https://juejin.cn/post/6926726194054365192

收起阅读 »

防抖和节流知多少

防抖 在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新再等n秒在执行回调。 例子 //模拟一段ajax请求 function ajax(content) { console.log('ajax request ' + content) } l...
继续阅读 »

防抖


在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新再等n秒在执行回调。


例子


//模拟一段ajax请求
function ajax(content) {
console.log('ajax request ' + content)
}

let inputa = document.getElementById('unDebounce')

inputa.addEventListener('keyup', function (e) {
ajax(e.target.value)
})

看一下运行结果:
1111.gif


可以看到,我们只要按下键盘,就会触发这次ajax请求。不仅从资源上来说是很浪费的行为,而且实际应用中,用户也是输出完整的字符后,才会请求。下面我们优化一下:


//模拟一段ajax请求

function ajax(content) {
console.log('ajax request' + content)
}
function debounce(fn, delay) {
let timer;
return function () {
let context = this;
const args = [...arguments];
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn.apply(context, args);
}, delay);
};
}
let dedebounceajax = debounce(ajax,1000);
var inputs = document.getElementById('inputs')
inputs.addEventListener("keyup",(e)=>{
dedebounceajax(e.target.value)
})

看一下运行结果:


2222.gif


可以看到,我们加入了防抖以后,当你在频繁的输入时,并不会发送请求,只有当你在指定间隔内没有输入时,才会执行函数。如果停止输入但是在指定间隔内又输入,会重新触发计时。


节流


规定在一个单位时间内,只能触发一次函数。
假设你点击一个按钮规定了5秒生效,不管你在5秒内点击了按钮多少次,5秒只会生效一次。


例子


// 时间戳箭头函数版本 节流函数

function throttle(func, wait) {
let timer = 0;
return (...rest) => {
let now = Date.now();
let that = this;
if (now > timer + delay) {
fn.apply(that, rest);
timer = now;
}
};
}

// 定时器版本 节流函数

function throttle(func, wait) {
let timeout;
return function() {
let context = this;
let args = arguments;
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
func.apply(context, args)
}, wait)
}

}
}

let throttleAjax = throttle(ajax, 1000)
let inputc = document.getElementById('throttle')
inputc.addEventListener('keyup', function(e) {
throttleAjax(e.target.value)
})

复制代码

看一下运行结果:


3333.gif


可以看到,我们在不断输入时,ajax会按照我们设定的时间,每1s执行一次。


总结



  • 函数防抖和函数节流都是防止某一时间频繁触发,但是这两兄弟之间的原理却不一样。

  • 函数防抖是某一段时间内只执行一次,而函数节流是间隔时间执行。


应用场景


防抖应用场景



  • input框搜索,用户在不断输入值时,用防抖来节约请求资源。

  • window触发resize的时候,不断的调整浏览器窗口大小会不断的触发这个事件,用防抖来让其只触发一次。


节流应用场景



  • 鼠标不断点击触发,mousedown(单位时间内只触发一次)

  • 监听滚动事件,比如是否滑到底部自动加载更多,用throttle来判断


链接:https://juejin.cn/post/6989861117187063822

收起阅读 »

每个前端都需要知道这些面向未来的CSS技术

写在前面 前端技术日新月异,我们需要不断学习来更新自己的前端知识并运用到自己的项目中。这次笔者整理一些未来普及或者现在同学们可能已经用到的CSS特性,包括SVG图标、滚动特性、CSS自定义属性、CSS现代伪类 、JS in CSS、Web Layout、混合模...
继续阅读 »

写在前面


前端技术日新月异,我们需要不断学习来更新自己的前端知识并运用到自己的项目中。这次笔者整理一些未来普及或者现在同学们可能已经用到的CSS特性,包括SVG图标、滚动特性、CSS自定义属性、CSS现代伪类 、JS in CSS、Web Layout、混合模式和滤镜、CSS计数器等等。


滚动特性


能用CSS实现的就不用麻烦JavaScript文章提及到滚动捕捉的特性,更多有关于容器滚动方面的CSS新特性其实还有有很多个,比如:



  • 自定义滚动条的外观

  • scroll-behavior指容容器滚动行为,让滚动效果更丝滑

  • overscroll-behavior优化滚动边界,特别是可以帮助我们滚动的穿透


自定义滚动条的外观


默认的window外观和mac外观


windows
image.png
mac


image.png


在CSS中,我们可以使用-webkit-scrollbar来自定义滚动条的外观。该属性提供了七个伪元素:



  • ::-webkit-scrollbar:整个滚动条

  • ::-webkit-scrollbar-button:滚动条上的按钮(下下箭头)

  • ::-webkit-scrollbar-thumb:滚动条上的滚动滑块

  • ::-webkit-scrollbar-track:滚动条轨道

  • ::-webkit-scrollbar-track-piece:滚动条没有滑块的轨道部分

  • ::-webkit-scrollbar-corner:当同时有垂直和水平滚动条时交汇的部分

  • ::-webkit-resizer:某些元素的交汇部分的部分样式(类似textarea的可拖动按钮)


html {
--maxWidth:1284px;
scrollbar-color: linear-gradient(to bottom,#ff8a00,#da1b60);
scrollbar-width: 30px;
background: #100e17;
color: #fff;
overflow-x: hidden
}

html::-webkit-scrollbar {
width: 30px;
height: 30px
}

html::-webkit-scrollbar-thumb {
background: -webkit-gradient(linear,left top,left bottom,from(#ff8a00),to(#da1b60));
background: linear-gradient(to bottom,#ff8a00,#da1b60);
border-radius: 30px;
-webkit-box-shadow: inset 2px 2px 2px rgba(255,255,255,.25),inset -2px -2px 2px rgba(0,0,0,.25);
box-shadow: inset 2px 2px 2px rgba(255,255,255,.25),inset -2px -2px 2px rgba(0,0,0,.25)
}

html::-webkit-scrollbar-track {
background: linear-gradient(to right,#201c29,#201c29 1px,#100e17 1px,#100e17)
}

通过这几个伪元素,可以实现你自己喜欢的滚动条外观效果,比如下面这个示例:


image.png


完整演示


css自定义属性


你大概已经听说过CSS自定义属性,也被称为 CSS 变量,估计熟悉SCSS、LESS就会很快上手,概念大同小异,都是让我们的CSS变得可维护,目前Edge最新版都已经支持这个特性了,这说明现在 CSS 自定义属性已经能用在实际项目中了,相信不久以后开发者们将大大依赖这个特性。但还请在使用之前请先检查一下本文附录中 Postcss 对于 CSS 自定义属性的支持情况,以便做好兼容。


什么是自定义属性呢?简单来说就是一种开发者可以自主命名和使用的 CSS 属性。浏览器在处理像 color 、position 这样的属性时,需要接收特定的属性值,而自定义属性,在开发者赋予它属性值之前,它是没有意义的。所以要怎么给 CSS 自定义属性赋值呢?这倒和习惯无异


.foo {
color: red;
--theme-color: gray;
}

自定义元素的定义由 -- 开头,这样浏览器能够区分自定义属性和原生属性,从而将它俩分开处理。假如只是定义了一个自定义元素和它的属性值,浏览器是不会做出反应的。如上面的代码, .foo 的字体颜色由 color 决定,但 --theme-color.foo 没有作用。


你可以用 CSS 自定义元素存储任意有效的 CSS 属性值


.foo {
--theme-color: blue;
--spacer-width: 8px;
--favorite-number: 3;
--greeting: "Hey, what's up?";
--reusable-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.85);
}

使用


假如自定义属性只能用于设值,那也太没用了点。至少,浏览器得能获取到它们的属性值。


使用 var() 方法就能实现:


.button {
background-color: var(--theme-color);
}

下面这段代码中,我们将 .buttonbackground-color 属性值赋值为 --theme-color 的值。这例子看起来自定义属性也没什么了不起的嘛,但这是一个硬编码的情况。你有没有意识到,--theme-color 的属性值是可以用在任意选择器和属性上的呢?这可就厉害了。


.button {
background-color: var(--theme-color);
}

.title {
color: var(--theme-color);
}

.image-grid > .image {
border-color: var(--theme-color);
}

缺省值


如果开发者并没有定义过 --theme-color 这个变量呢?var() 可以接收第二个参数作为缺省值:


.button {
background-color: var(--theme-color, gray);
}


注意:如果你想把另一个自定义属性作为缺省值,语法应该是 background-color: var(--theme-color, var(--fallback-color))



传参数时总是传入一个缺省值是一个好习惯,特别是在构建 web components 的时候。为了让你的页面在不支持自定义属性的浏览器上正常显示,别忘了加上兼容代码:


.button {
background-color: gray;
background-color: var(--theme-color, gray);
}

CSS现代伪类


这些最新的伪类特性,我们也需要知道。
image.png


使用 :is() 减少重复


你可以使用 :is() 伪类来删除选择器列表中的重复项。


/* BEFORE */
.embed .save-button:hover,
.attachment .save-button:hover {
opacity: 1;
}

/* AFTER */
:is(.embed, .attachment) .save-button:hover {
opacity: 1;
}

此功能主要在未处理的标准CSS代码中有用。如果使用Sass或类似的CSS预处理程序,则可能更喜欢嵌套。


注意:浏览器还支持非标准的 :-webkit-any() 和 :-moz-any() 伪类,它们与 :is() 相似,但限制更多。WebKit在2015年弃用了 :-webkit-any() ,Mozilla已将Firefox的用户代理样式表更新为使用 :is() 而不是 :-moz-any()


使用 :where() 来保持低特殊性


:where() 伪类与 :is() 具有相同的语法和功能。它们之间的唯一区别是 :where() 不会增加整体选择器的特殊性(即某条CSS规则特殊性越高,它的样式越优先被采用)。



:where() 伪类及其任何参数都不对选择器的特殊性有所帮助,它的特殊性始终为零



此功能对于应易于覆盖的样式很有用。例如,基本样式表 sanitize.css 包含以下样式规则,如果缺少 <svg fill> 属性,该规则将设置默认的填充颜色:


svg:not([fill]) {
fill: currentColor;
}

由于其较高的特殊性(B = 1,C = 1),网站无法使用单个类选择器(B = 1)覆盖此声明,并且被迫添加 !important 或人为地提高选择器的特殊性(例如 .share- icon.share-icon)。


.share-icon {
fill: blue; /* 由于特殊性较低,因此不适用 */
}

CSS库和基础样式表可以通过用 :where() 包装它们的属性选择器来避免这个问题,以保持整个选择器的低特殊性(C=1)。


/* sanitize.css */
svg:where(:not([fill])) {
fill: currentColor;
}

/* author stylesheet */
.share-icon {
fill: blue; /* 由于特殊性较高,适用 */
}

其它新伪类特性有情趣同学可以按照导图查阅一下相关文档资料。


完整演示


JS in CSS


前面提到过,使用CSS自定义属性的时候,可以通过JavaScript来操作自定义属性的值。其实还可以更强大一点,如果你对CSS Houdini熟悉的话,可以借助其特性,直接在CSS的代码中来操作CSS自定义属性


:root {
--property: document.write('hello world!');
}

window.onload = () => {
const doc = window.getComputedStyle(document.documentElement);
const cssProp = doc.getPropertyValue('--property');
new Function((cssProp))();
}

完整演示


Web layout


对于Web布局而言,前端就一直在探讨这方面的最优方式。早期的table布局,接着的floatposition相关的布局,多列布局,Flexbox布局和Grid布局等。Flexbox和Grid的出现,Web布局的灵活性越来越高。


如图不依赖媒体查询实现自动计算


屏幕录制2021-07-27 下午3.17.46.gif


CSS Grid中提供了很多强大的特性,比如:



  • fr单位,可以很好的帮助我们来计算容器可用空间

  • repeat()函数,允许我们给网格多个列指定相同的值。它也接受两个值:重复的次娄和重复的值

  • minmax()函数,能够让我们用最简单的CSS控制网格轨道的大小,其包括一个最小值和一个最大值

  • auto-fillauto-fit,配合repeat()函数使用,可以用来替代重复次数,可以根据每列的宽度灵活的改变网格的列数

  • max-contentmin-content,可以根据单元格的内容来确定列的宽度

  • grid-suto-flow,可以更好的让CSS Grid布局时能自动排列


结合这些功能点,布局会变得更轻松。比如我们要实现一个响应式的布局,很多时候都会依赖于媒体查询(@media)来处理,事实上,有了CSS Grid Layout之后,这一切变得更为简单,不需要依赖任何媒体查询就可以很好的实现响应式的布局。特别是当今这个时代,要面对的终端设备只会增加不会减少,那么希望布局更容易的适配这些终端的布局,那么CSS Grid Layout将会起到很大的作用。


完整示例


Grid和flex都是面向未来的最佳布局方案。我们不应该探讨谁优谁劣,而是应该取长补短结合使用。


混合模式和滤镜


能用CSS实现的就不用麻烦JavaScript — Part2一文提到混合模式。CSS混合模式和滤镜主要是用来处理图片的。熟悉PS之类软件的同学很容易理解里面的属性。


屏幕录制2021-07-19 上午11.12.39.gif


完整代码演示


CSS计数器


CSS计数器其实涉及到三个属性:counter-incrementcounter-resetcounter()。一般情况都是配合CSS的伪元素::before::aftercontent一起使用。可以用来计数


屏幕录制2021-07-27 下午3.15.06.gif


完整演示


SVG图标


对于SVG而言,它是一套独立而又成熟的体系,也有自己的相关规范(Scalable Vecgtor Graphics 2),即 SVG2。虽然该规范已经存在很久了,但很多有关于SVG相关的特性在不同的浏览器中得到的支持度也是有所不一致的。特别是SVG中的渐变和滤镜相关的特性。不过,随着技术的革新,在Web的应用当中SVG的使用越来越多,特别是SVG 图标相关的方面的运用。




  • 最早通过<img>标签来引用图标(每个图标一个文件)




  • 为了节省请求,提出了Sprites的概念,即将多个图标合并在一起,使用一个图片文件,借助background相关的属性来实现图标




  • 图片毕竟是位图,面对多种设备终端,或者说更易于控制图标颜色和大小,开始在使用Icon Font来制作Web图标




  • 当然,字体图标是解决了不少问题,但每次针对不同的图标的使用,需要自定义字体,也要加载相应的字体文件,相应的也带了一定的问题,比如说跨域问题,字体加载问题




  • 随着SVG的支持力度越来越强,大家开始在思考SVG,使用SVG来制作图标。该技术能解决我们前面碰到的大部分问题,特别是在而对众多终端设备的时候,它的优势越发明显




  • SVG和img有点类似,我们也可以借助<symbol>标签和<use>标签,将所有的SVG图标拼接在一起,有点类似于Sprites的技术,只不过在此称为SVG Sprites




<!-- HTML -->
<svg width="0" height="0" display="none" xmlns="http://www.w3.org/2000/svg">
<symbol id="half-circle" viewBox="0 0 106 57">...</symbol>
<!-- .... -->
<symbol id="icon-burger" viewBox="0 0 24 24">...</symbol>
</svg>

SVG Sprites和img Sprites有所不同,SVG Sprites就是一些代码(类似于HTML一样),估计没有接触过的同学会问,SVG Sprites对应的代码怎么来获取呢?其实很简单,可以借助一些设计软件来完成,比如Sketch。当然也可以使用一些构建工具,比如说svg-sprite。有了这个之后,在该使用的地方,使用<use>标签,指定<symbol>中相应的id值即可,比如:


<svg class="icon-nav-articles" width="26px" height="26px">
<use xlink:href="#icon-nav-articles"></use>
</svg>

使用SVG的图标还有一优势,我们可以在CSS中直接通过代码来控制图标的颜色:


.site-header .main-nav .main-sections>li>a>svg {
// ...
fill: none;
stroke-width: 2;
stroke: #c2c2c2;
}
.site-header .main-nav:hover>ul>li:nth-child(1) svg {
stroke: #ff8a00;
}

image.png


完整演示


写在最后


以上列举都是CSS一些优秀的特性。还有很多,有时间再收集更多分享给大家。这些新特性在不同的浏览器中差异性是有所不同的。但这并不是阻碍我们去学习和探索的原因所在。我们应该及时去了解并运用到,才可以做到对项目精益求精。


链接:https://juejin.cn/post/6989513390636924936

收起阅读 »

React 中的一些 Router 必备知识点

前言 每次开发新页面的时候,都免不了要去设计一个新的 URL,也就是我们的路由。其实路由在设计的时候不仅仅是一个由几个简单词汇和斜杠分隔符组成的链接,偶尔也可以去考虑有没有更“优雅”的设计方式和技巧。而在这背后,路由和组件之间的协作关系是怎样的呢?于是我以 R...
继续阅读 »

前言


每次开发新页面的时候,都免不了要去设计一个新的 URL,也就是我们的路由。其实路由在设计的时候不仅仅是一个由几个简单词汇和斜杠分隔符组成的链接,偶尔也可以去考虑有没有更“优雅”的设计方式和技巧。而在这背后,路由和组件之间的协作关系是怎样的呢?于是我以 React 中的 Router 使用方法为例,整理了一些知识点小记和大家分享~


React-Router


基本用法


通常我们使用 React-Router 来实现 React 单页应用的路由控制,它通过管理 URL,实现组件的切换,进而呈现页面的切换效果。


其最基本用法如下:


import { Router, Route } from 'react-router';
render((
<Router>
<Route path="/" component={App}/>
</Router>
), document.getElementById('app'));

亦或是嵌套路由:


在 React-Router V4 版本之前可以直接嵌套,方法如下:


<Router>
<Route path="/" render={() => <div>外层</div>}>
<Route path="/in" render={() => <div>内层</div>} />
</Route>
</Router>

上面代码中,理论上,用户访问 /in 时,会先加载 <div>外层</div>,然后在它的内部再加载 <div>内层</div>


然而实际运行上述代码却发现它只渲染出了根目录中的内容。后续对比 React-Router 版本发现,是因为在 V4 版本中变更了其渲染逻辑,原因据说是为了践行 React 的组件化理念,不能让 Route 标签看起来只是一个标签(奇怪的知识又增加了)。


现在较新的版本中,可以使用 Render 方法实现嵌套。


<Route
path="/"
render={() => (
<div>
<Route
path="/"
render={() => <div>外层</div>}
/>
<Route
path="/in"
render={() => <div>内层</div>}
/>
<Route
path="/others"
render={() => <div>其他</div>}
/>
</div>
)}
/>

此时访问 /in 时,会将“外层”和“内层”一起展示出来,类似地,访问 /others 时,会将“外层”和“其他”一起展示出来。


图片


路由传参小 Tips


在实际开发中,往往在页面切换时需要传递一些参数,有些参数适合放在 Redux 中作为全局数据,或者通过上下文传递,比如业务的一些共享数据,但有些参数则适合放在 URL 中传递,比如页面类型或详情页中单据的唯一标识 id。在处理 URL 时,除了问号带参数的方式,React-Router 能帮我们做什么呢?在这其中,Route 组件的 path 属性便可用于指定路由的匹配规则。


场景 1



描述:就想让普普通通的 URL 带个平平无奇的参数



那么,接下来我们可以这样干:


Case A:路由参数


path="/book/:id"

我们可以用冒号 + 参数名字的方式,将想要传递的参数添加到 URL 上,此时,当参数名字(本 Case 中是 id)对应的值改变时,将被认为是不同 URL。


Case B:查询参数


path="/book"

如果想要在页面跳转的时候问号带参数,那么 path 可以直接设计成既定的样子,参数由跳转方拼接。
在跳转时,有两种形式带上参数。其一是在 Link 组件的 to 参数中通过配置字符串并用问号带参数,其二是 to 参数可以接受一个对象,其中可以在 search 字段中配置想要传递的参数。


<Link to="/book?id=111" />
// 或者
<Link to={{
pathname: '/book',
search: '?id=111',
}}/>

此时,假设当前页面 URL中的 id 由111 修改为 222 时,该路由对应的组件(在上述例子中就是 React-Route 配置时 path="/book" 对应的页面/组件 )会更新,即执行 componentDidUpdate 方法,但不会被卸载,也就是说,不会执行 componentDidMount 方法。


Case C:查询参数隐身式带法


path="/book"

path 依旧设计成既定的样子,而在跳转时,可以通过 Link 中的 state 将参数传递给对应路由的页面。


<Link to={{
pathname: '/book',
state: { id: 111 }
}}/>

但一定要注意的是,尽管这种方式下查询参数不会明文传递了,但此时页面刷新会导致参数丢失(存储在 state 中的通病),So,灰常不推荐~~(其实不想明文可以进行加密处理,但一般情况下敏感信息是不建议放在 URL 中传递的~)


场景 2



描述:编辑/详情页,想要共用一个页面,URL 由不同的参数区分,此时我们希望,参数必须为 edit、detail、add 中的 1 个,不然需要跳转到 404 Not Found 页面。



path='/book/:pageType(edit|detail|add)'

如果不加括号中的内容 (edit|detail|add),当传入错误的参数(比如用户误操作、随便拼接 URL 的情况),则页面不会被 404 拦截,而是继续走下去开始渲染页面或调用接口,但此时很有可能导致接口传参错误或页面出错。


场景 3



描述:新增页和编辑页辣么像,我的新增页也想和编辑/详情共用一个页面。但是新增页不需要 id,编辑/详情页需要 id,使用同一个页面怎么办?



path='/book/:pageType(edit|detail|add)/:id?'

别急,可以用 ? 来解决,它意味着 id 不是一个必要参数,可传可不传。


场景 4



描述:我的 id 只能是数字,不想要字符串怎么办?



path='/book/:id(\\\d+)'

此时 id 不是数字时,会跳转 404,被认为 URL 对应的页面找不到啦。


底层依赖


有了这么多场景,那 Router 是怎样实现的呢?其实它底层是依赖了 path-to-regexp 方法。


var pathToRegexp = require('path-to-regexp')
// pathToRegexp(path, keys, options)
// 示例
var keys = []
var re = pathToRegexp('/foo/:bar', keys)
// re = /^\/foo\/([^\/]+?)\/?$/i
// keys = [{ name: 'bar', prefix: '/', delimiter: '/', optional: false, repeat: false, pattern: '[^\\/]+?' }]


delimiter:重复参数的定界符,默认是 '/',可配置



一些其他常用的路由正则通配符:




  • ? 可选参数




  • * 匹配 0 次或多次




  • + 匹配 1 次或多次




如果忘记写参数名字,而只写了路由规则,比如下述代码中 /:foo 后面的参数:


var re = pathToRegexp('/:foo/(.*)', keys)
// 匹配除“\n”之外的任何字符
// keys = [{ name: 'foo', ... }, { name: 0, ...}]
re.exec('/test/route')
//=> ['/test/route', 'test', 'route']

它也会被正确解析,只不过在方法处理的内部,未命名的参数名会被替换成数组下标。


取路由参数


path 带的参数,可以通过 this.props.match 获取


例如:


// url 为 /book/:pageType(edit|detail|add)
const { match } = this.props;
const { pageType } = match.params;

由于有 #,# 之后的所有内容都会被认为是 hash 的一部分,window.location.search 是取不到问号带的参数的。


比如:aaa.bbb.com/book-center…


那么在 React-Router 中,问号带的参数,可以通过 this.props.location (官方墙推 👍)获取。个人理解是因为 React-Router 帮我们做了处理,通过路由和 hash 值(window.location.hash)做了解析的封装。


例如:


// url 为 /book?pageType=edit
const { location } = this.props;
const searchParams = location.search; // ?pageType=edit

实际打印 props 参数发现,this.props.history.location 也可以取到问号参数,但不建议使用,因为 React 的生命周期(componentWillReceiveProps、componentDidUpdate)可能使它变得不可靠。(原因可参考:blog.csdn.net/zrq1210/art…


在早期的 React-Router 2.0 版本是可以用 location.query.pageType 来获取参数的,但是 V4.0 去掉了(有人认为查询参数不是 URL 的一部分,有人认为现在有很多第三方库,交给开发者自己去解析会更好,有个对此讨论的 Issue,有兴趣的可以自行获取 😊 github.com/ReactTraini…


针对上一节中场景 1 的 Case C,查询参数隐身式带法时(从 state 里带过去的),在 this.props.location.state 里可以取到(不推荐不推荐不推荐,刷新会没~)


Switch


<div>
<Route
path="/router/:type"
render={() => <div>影像</div>}
/>
<Route
path="/router/book"
render={() => <div>图书</div>}
/>
</div>

如果 <Route /> 是平铺的(用 div 包裹是因为 Router 下只能有一个元素),输入 /router/book 则影像和图书都会被渲染出来,如果想要只精确渲染其中一个,则需要 Switch


<Switch>
  <Route
    path="/router/:type"
    render={() => <div>影像</div>}
  />
  <Route
    path="/router/book"
    render={() => <div>图书</div>}
  />
</Switch>

Switch 的意思便是精准的根据不同的 path 渲染不同 Route 下的组件。
但是,加了 Switch 之后路由匹配规则是从上到下执行,一旦发现匹配,就不再匹配其余的规则了。因此在使用的时候一定要“百般小心”。


上面代码中,用户访问 /router/book 时,不会触发第二个路由规则(不会 展示“图书”),因为它会匹配 /router/:type 这个规则。因此,带参数的路径一般要写在路由规则的底部。


路由的基本原理


路由做的事情:管控 URL 变化,改变浏览器中的地址。


Router 做的事情:URL 改变时,触发渲染,渲染对应的组件。


URL 有两种,一种不带 #,一种带 #,分别对应 Browse 模式和 Hash 模式。


一般单页应用中,改变 URL,但是不重新加载页面的方式有两类:


Case 1(会触发路由监听事件):点击 前进、后退,或者调用的 history.back( )、history.forward( )


Case 2(不会触发路由监听事件):组件中调用 history.push( ) 和 history.replace( )


于是参考 「源码解析 」这一次彻底弄懂 React-Router 路由原理 一文,针对上述两种 Case,以及这两种 Case 分别对应的两种模式,作出如下总结。


图片



图片来源:「源码解析 」这一次彻底弄懂 React-Router 路由原理



Browser 模式


Case 1:


URL 改变,触发路由的监听事件 popstate,then,监听事件的回调函数 handlePopState 在回调中触发 history 的 setState 方法,产生新的 location 对象。state 改变,通知 Router 组件更新 location 并通过 context 上下文传递,匹配出符合的 Route 组件,最后由 <Route /> 组件取出对应内容,传递给渲染页面,渲染更新。


/* 简化版的 handlePopState (监听事件的回调) */
const handlePopState = (event)=>{
     /* 获取当前location对象 */
    const location = getDOMLocation(event.state)
    const action = 'POP'
     /* transitionManager 处理路由转换 */
    transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
        if (ok) {
          setState({ action, location })
        } else {
          revertPop(location)
        }
    })
}

Case 2:
以 history.push 为例,首先依据你要跳转的 path 创建一个新的 location 对象,然后通过 window.history.pushState (H5 提供的 API )方法改变浏览器当前路由(即当前的 url),最后通过 setState 方法通知 Router,触发组件更新。


const push = (path, state) => {
  const action = 'PUSH'
   /* 创建location对象 */
  const location = createLocation(path, state, createKey(), history.location)
   /* 确定是否能进行路由转换 */
   transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
... // 此处省略部分代码
   const href = createHref(location)
   const { key, state } = location
   if (canUseHistory) {
     /* 改变 url */
     globalHistory.pushState({ key, state }, null, href)
     if (forceRefresh) {
       window.location.href = href
     } else {
      /* 改变 react-router location对象, 创建更新环境 */
       setState({ action, location })
     }
   } else {
     window.location.href = href
   }
 })
}

Hash 模式


Case 1:


增加监听,当 URL 的 Hash 发生变化时,触发 hashChange 注册的回调,回调中去进行相类似的操作,进而展示不同的内容。


window.addEventListener('hashchange',function(e){
/* 监听改变 */
})

Case 2:
history.push 底层调用 window.location.hash 来改变路由。history.replace 底层是调用 window.location.replace 改变路由。然后 setState 通知改变。


从一些参考资料中显示,出于兼容性的考虑(H5 的方法 IE10 以下不兼容),路由系统内部将 Hash 模式作为创建 History 对象的默认方法。(此处若有疑议,欢迎指正~)


Dva/Router


在实际项目中发现,Link,Route 都是从 dva/router 中引进来的,那么,Dva 在这之中做了什么呢?


答案:貌似没有做特殊处理,Dva 在 React-Router 上做了上层封装,会默认输出 React-Router 接口。


我们对 Router 做过的一些处理


Case 1:


项目代码的 src 目录下,不管有多少文件夹,路由一般会放在同一个 router.js 文件中维护,但这样会导致页面太多时,文件内容会越来越长,不便于查找和修改。


因此我们可以做一些小改造,在 src 下的每个文件夹中,创建自己的路由配置文件,以便管理各自的路由。但这种情况下 React-Router 是不能识别的,于是我们写了一个 Plugin 放在 Webpack 中,目的是将各个文件夹下的路由汇总,并生成 router-config.js 文件。之后,将该文件中的内容解析成组件需要的相关内容。插件实现方式可了解本团队另一篇文章: 手把手带你入门 Webpack Plugin


Case 2:


路由的 Hash 模式虽然兼容性好,但是也存在一些问题:



  1. 对于 SEO、前端埋点不太友好,不容易区分路径

  2. 原有页面有锚点时,使用 Hash 模式会出现冲突


因此公司内部做了一次 Hash 路由转 Browser 路由的改造。


如原有链接为:aaa.bbb.com/book-center…


改造方案为:


通过新增以下配置代码去掉 #


import createHistory from 'history/createBrowserHistroy';
const app = dva({
history: createHistory({
basename: '/book-center',
}),
onError,
});

同时,为了避免用户访问旧页面出现 404 的情况,前端需要在 Redirect 中配置重定向以及在 Nginx 中配置旧的 Hash 页面转发。


Case 3:


在实际项目中,其实我们也会去考虑用户未授权时路由跳转、页面 404 时路由跳转等不同情况,以下 Case 和代码仅供读者参考~


<Switch>
{
getRoutes(match.path, routerData).map(item =>
(
// 用户未授权处理,AuthorizedRoute 为项目中自己实现的处理组件
<AuthorizedRoute
{...item}
redirectPath="/exception/403"
/>
)
)
}
// 默认跳转页面
<Redirect from="/" exact to="/list" />
// 页面 404 处理
<Route render={props => <NotFound {...props} />} />
</Switch>

参考链接


「源码解析 」这一次彻底弄懂react-router路由原理


react-router v4 路由规则解析


二级动态路由的解决方案


链接:https://juejin.cn/post/6989764387275800607

收起阅读 »

老生常谈的JavaScript闭包

老生常谈的闭包 很多观点参考于《你不知道的JavaScript》、《JavaScript忍者秘籍》,私信我,可发电子书呀。进入正文: 也许你并不知道闭包是什么,但是你的代码中到处都有闭包的影子!也许你觉得闭包平时用不到,但是每次面试你都得去准备这个方面内容!也...
继续阅读 »

老生常谈的闭包


很多观点参考于《你不知道的JavaScript》、《JavaScript忍者秘籍》,私信我,可发电子书呀。进入正文:


也许你并不知道闭包是什么,但是你的代码中到处都有闭包的影子!也许你觉得闭包平时用不到,但是每次面试你都得去准备这个方面内容!也许你不觉得这个功能有什么用,但是很多框架的功能都是基于闭包去实现的!


下面我们将目光聚焦到以下几个问题,来理解一下闭包:



  • 词法作用域

  • 闭包的形成

  • 闭包的概念

  • 闭包的常见形式

  • 闭包的作用


闭包与词法作用域


在《你不知道的JavaScript》书有一句原话:闭包是基于词法作用域书写代码所产生的自然结果。所以在知道闭包之前,得先理解什么是词法作用域。以前的文章有过介绍: 理解JavaScript的词法作用域(可以稍微的翻看一下)。如果不想看,也没关系。 接下来我们分析一段代码,去理解什么是词法作用域,以及闭包的形成。


var a = 100

function foo() {
var a = 10
function test() {
var b = 9
console.log(a + b)
}
return test
}

var func = foo()
func()

作用域分析


image


上图我们清晰的反应了作用域嵌套。



  • 其中全局变量func就是test函数的引用。

  • test定义虽然定义在foo包裹的作用域内,但运行在全局作用域内。

  • test里面执行a + b的时候,a变量的值等于10而不是等于100。说明变量a的查找跟test在哪里执行没有关系。

  • 这就是词法作用域,在书写阶段作用域嵌套就已经确定好了,跟函数在哪里运行没有关系。


闭包的形成


对上诉代码进行作用域分析之后我们不难得出一个结论:test函数不管在哪里执行,他永远都属于foo作用域下得一个标识符,所以test永远对foo作用域持有访问的权限


正常情况下,foo函数执行完毕后,js的垃圾回收机制就会对foo函数作用域进行销毁。但是由于test函数对foo的作用域持有引用,所以只要程序还在运行中,你就永远不会知道test会在哪里被调用。 每当test要执行的时候,都会去访问foo作用域下的a变量。所以垃圾回收机制在foo执行完毕之后,不会对foo作用域进行销毁。这就形成了闭包


闭包的常见形式


以上我们分析了,闭包是怎么形成的。也分析了一段典型的闭包代码。前言中我们有说过一句话也许你并不知道闭包是什么,但是你的代码中到处都有闭包的影子。接下来我们分析一下,闭包的常见形式。


以下代码就算你不了解闭包,你也写过。类似的:


computed: {
add() {
return function(num) {
return num + 1
}
}
},


vue中可接受参数的计算属性



function init() {
$(.name).click(function handleClickBtn() {
alert('click btn')
})
}


初始化函数中使用jq绑定事件



$.ajax({url:"/api/getName",success:function(result){
console.log(result)
}});


ajax请求数据



window.addEventListener('click', function() {

})


原生注册事件



可以发现当把函数当做值传递的时候,就会形成闭包。《你不知道的JavaScript》给出了总结: 如果将函数(访问它们各自的词法作用域)当作第一
级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、
Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使
用了回调函数,实际上就是在使用闭包!


闭包的作用


闭包的一大用处是回调函数。还有一个作用是封装私变量。在《JavaScript忍者秘籍》有专门章节对其进行详细讲解。 下面我们看看闭包如何封装私有变量


私有变量封装


场景:有一个函数foo, 统计其被调用的次数


var num = 0

function foo() {
// 次数加一
num = num + 1
return num
}

foo()
foo()
foo()

console.log(num)


全局变量num来统计foo调用次数,最大的坏处在于,你不知道程序运行到什么时候,num的值被篡改。如果能够将num变量私有化,外部不能随意更改就好了。



function baz() {
var num = 0

return function() {
num++
return num
}
}

var foo = baz()
foo()
foo()
let fooNum = foo()

console.log(fooNum)


通过闭包,num被私有化在baz作用域下,程序运行过程中,不能随意更改baz下的num值。



小结



  • 闭包是基于词法作用域书写代码产生的自然结果

  • 闭包经常出现在我们的代码里面,常见的是回调函数

  • 闭包作用很多,回调函数,私有化变量等等


作者:limbo
链接:https://juejin.cn/post/6989148728649072653

收起阅读 »

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




收起阅读 »