注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

iOS RXSwift 4.6

iOS
Observable & Observer 既是可监听序列也是观察者在我们所遇到的事物中,有一部分非常特别。它们既是可监听序列也是观察者。例如:textField的当前文本。它可以看成是由用户输入,而产生的一个文本序列。也可以是由外部文本序列,来控制当...
继续阅读 »

Observable & Observer 既是可监听序列也是观察者

在我们所遇到的事物中,有一部分非常特别。它们既是可监听序列也是观察者

例如:textField的当前文本。它可以看成是由用户输入,而产生的一个文本序列。也可以是由外部文本序列,来控制当前显示内容的观察者

// 作为可监听序列
let observable = textField.rx.text
observable.subscribe(onNext: { text in show(text: text) })
// 作为观察者
let observer = textField.rx.text
let text: Observable<String?> = ...
text.bind(to: observer)

有许多 UI 控件都存在这种特性,例如:switch的开关状态,segmentedControl的选中索引号,datePicker的选中日期等等。

参考

另外,框架里面定义了一些辅助类型,它们既是可监听序列也是观察者。如果你能合适的应用这些辅助类型,它们就可以帮助你更准确的描述事物的特征:


AsyncSubject

AsyncSubject 将在源 Observable 产生完成事件后,发出最后一个元素(仅仅只有最后一个元素),如果源 Observable 没有发出任何元素,只有一个完成事件。那 AsyncSubject 也只有一个完成事件。

它会对随后的观察者发出最终元素。如果源 Observable 因为产生了一个 error 事件而中止, AsyncSubject 就不会发出任何元素,而是将这个 error 事件发送出来。


演示

let disposeBag = DisposeBag()
let subject = AsyncSubject<String>()

subject
.subscribe { print("Subscription: 1 Event:", $0) }
.disposed(by: disposeBag)

subject.onNext("🐶")
subject.onNext("🐱")
subject.onNext("🐹")
subject.onCompleted()

输出结果:

Subscription: 1 Event: next(🐹)
Subscription: 1 Event: completed

PublishSubject

PublishSubject 将对观察者发送订阅后产生的元素,而在订阅前发出的元素将不会发送给观察者。如果你希望观察者接收到所有的元素,你可以通过使用 Observable 的 create 方法来创建 Observable,或者使用 ReplaySubject

如果源 Observable 因为产生了一个 error 事件而中止, PublishSubject 就不会发出任何元素,而是将这个 error 事件发送出来。


演示

let disposeBag = DisposeBag()
let subject = PublishSubject<String>()

subject
.subscribe { print("Subscription: 1 Event:", $0) }
.disposed(by: disposeBag)

subject.onNext("🐶")
subject.onNext("🐱")

subject
.subscribe { print("Subscription: 2 Event:", $0) }
.disposed(by: disposeBag)

subject.onNext("🅰️")
subject.onNext("🅱️")

输出结果:

Subscription: 1 Event: next(🐶)
Subscription: 1 Event: next(🐱)
Subscription: 1 Event: next(🅰️)
Subscription: 2 Event: next(🅰️)
Subscription: 1 Event: next(🅱️)
Subscription: 2 Event: next(🅱️)
收起阅读 »

Flutter跨进程混合栈渲染的实践——子进程WebView

前言 首先祝大家中秋节快乐,而明天又要上班啦~ 哈哈哈。不过,立此之处,国庆可期矣~ 好了,书归正传,在此我想分享一下关于我在Flutter 安卓端的跨进程渲染所做的一些实践。 起因 随着项目不断的迭代,功能日益复杂,内存占用也与日俱增。在压测过程中,app的...
继续阅读 »

前言


首先祝大家中秋节快乐,而明天又要上班啦~ 哈哈哈。不过,立此之处,国庆可期矣~


好了,书归正传,在此我想分享一下关于我在Flutter 安卓端跨进程渲染所做的一些实践。


起因


随着项目不断的迭代,功能日益复杂,内存占用也与日俱增。在压测过程中,app的崩溃也多是因为各种原因的内存泄漏异常抖动并最终引发OOM而被系统杀死。按技术栈划分主要集中以下两端:




  1. 原生端本身的代码质量(不当设计、图片加载、对象未释放等)所造成,这点通过回溯及找到组内对应同学修复便可快速解决。




  2. 前端的代码质量(亦如上)所引起,这点则需要找到前端组的同学进行修复,但是跨组/部门的无力感我想大家或多或少都会有一些。




不管原因几何,结果都是App崩了,我们一方面找到负责的同学抓紧修复外,另一方面也在思考如何从原生解决(至少隔绝)H5导致的App崩溃问题。


分析


有一定原生开发经验的我们,便想到了子进程。而通过子进程去分担主进程的内存压力,在各大厂也均有应用,可证明它是一个比较成熟的方案,而就单进程Web-View来说,市面上也有不少成功的Android框架及技术方案的分享。


纯原生(Android)应用来讲,因为栈的统一,接入一个子进程web-view,还是比较方便的,大致开启一个子进程,然后startActivity即可,无需关心栈的管理。但是Flutter应用则分为两种栈:


1 Android栈 (管理activity)

2 Flutter栈 (管理flutter的route)

在实际应用中,H5与原生均有复杂的交互,这里不仅体现在功能上的,还包括UI上的。就算不考虑跳转动画的问题,Flutter栈内的叠加(Flutter和H5)就需要一个单独的栈管理器来处理(如Flutter Boost)。


在考虑到投入产出成本以及问题的本质并非栈管理器可以解决的情况下(如 Flutter页面部分是H5等情况),我决定用Flutter自带的Texture Widget进行H5的显示,这样统一了栈的管理,同时Texture Widget可以自由调整大小,做到任意Flutter页面的(部分)嵌入。Texture Widget需要一个Surface,而Surface又具有天然的跨进程属性这无疑大大方便了开发。


实践及结果


经过一段时间的研究和设计,最终有了一个Alpha版的框架,在此我对架构做一下简单的介绍:


flutter_remote_view_framework.png


按进程划分


主要分为两部分:


1. 主进程包含Flutter及相应的平台部分,承担surface的创建、展示、交互等的发起方。

2. 子进程主要包含zygote activity , webview 等。

进程之间通过Binder进行通信。


按流程划分


主要分为三部分:


1. Flutter侧,主要发起创建指令并最终消费子进程的渲染数据。

2. 平台侧,主要承担Flutter与子进程的web-view的通信转发功能,同时承担surface的创建功能,
也是真正与子进程通信的模块。

3. 子进程,主要负责webview的创建,并使用主进程所提供的surface进行H5内容的输出。

所遇到的一些难点


系统弹窗的权限问题


在子进程中使用web-view,并渲染在指定surface 上需要借助virtual displaypresentation,但是如果presentation的创建不是基于activity context,那么则需要一个系统权限才可以正常工作,这对于我们的需求来说,是不可接受的。


为此便创建了一个Zygote activity,它工作于后台,主要责任就是提供一个context和部分presentation的创建工作。同时借助内存泄漏以尽可能长的保留它的存活时间。


交互


由于系统事件(如 触摸)是分发到当前(前台)activity stack的栈顶activity,那么当Zygote activity工作于后台的时候,我们的触摸事件是分发到了Main activity,h5则无法响应任何交互。因此我们需要在主进程做事件的分拣并通过binder转发到子进程,以此来让H5消费到属于它的事件。


触摸事件的分发及错位问题


上面的问题细分后,可以明确我们需要解决Flutter端的H5页面在非栈顶的情况下不能消费事件,因为Flutter所接受的事件由Main Activity提供,所以事件的分发也在此处处理,为此我增加了一个栈协调器(相对于栈管理要简单一些),以获取当前Flutter端的栈情况,并做出正确的分发。


经过实际实践,效果还是不错的,但也发现一个问题:点击坐标错位。经过研究发现,这主要是Flutter端布局和web view端布局不一致导致的,换言之需要计算在Flutter点击时的position相对于那个Texture widget内的相对位置,并做转换再进行分发。


通信


客观的说,这里并没有什么难点,但比较,因为操作涉及到UI,所以不仅要考虑到进程间的通信、线程切换还有各进程的主、子线程的切换。并且按领域进行划分话,又分为共有和私有通信,为此增加了communicate hub以区分各领域的通信。


结果


在一些主要问题解决后,得到了最终的效果图(debug mode):


small.gif


这个Demo并不满足生产,但是验证了它的可行性,而就真正的上线来说,还是有一部分工作要做的,如坐标转换器优化(下一个版本要做的)、协调器、垃圾回收、兜底策略等等。


到此我的分享就结束了,希望对大家有所帮助,同时也殷切希望有大佬能指出设计的不足,谢谢大家的阅读。


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

iOS RXSwift 4.5

iOS
Observer - 观察者观察者 是用来监听事件,然后它需要这个事件做出响应。例如:弹出提示框就是观察者,它对点击按钮这个事件做出响应。响应事件的都是观察者在 Observable 章节,我们举了个几个例子来介绍什么是可监听序列...
继续阅读 »

Observer - 观察者

观察者 是用来监听事件,然后它需要这个事件做出响应。例如:弹出提示框就是观察者,它对点击按钮这个事件做出响应。

响应事件的都是观察者

在 Observable 章节,我们举了个几个例子来介绍什么是可监听序列。那么我们还是用这几个例子来解释一下什么是观察者

  • 当室温高于 33 度时,打开空调降温

    1

    打开空调降温就是观察者 Observer<Double>

  • 当《海贼王》更新一集时,我们就立即观看这一集

    1

    观看这一集就是观察者 Observer<OnePieceEpisode>

  • 当取到 JSON 时,将它打印出来

    1

    将它打印出来就是观察者 Observer<JSON>

  • 当任务结束后,提示用户任务已完成

    1

    提示用户任务已完成就是观察者 Observer<Void>

如何创建观察者

现在我们已经知道观察者主要是做什么的了。那么我们要怎么创建它们呢?

和 Observable 一样,框架已经帮我们创建好了许多常用的观察者。例如:view 是否隐藏,button 是否可点击, label 的当前文本,imageView 的当前图片等等。

另外,有一些自定义的观察者是需要我们自己创建的。这里介绍一下创建观察者最基本的方法,例如,我们创建一个弹出提示框的的观察者

tap.subscribe(onNext: { [weak self] in
self?.showAlert()
}, onError: { error in
print("发生错误: \(error.localizedDescription)")
}, onCompleted: {
print("任务完成")
})

创建观察者最直接的方法就是在 Observable 的 subscribe 方法后面描述,事件发生时,需要如何做出响应。而观察者就是由后面的 onNextonErroronCompleted的这些闭包构建出来的。

以上是创建观察者最常见的方法。当然你还可以通过其他的方式来创建观察者,可以参考一下 AnyObserver 和 Binder

特征观察者

和 Observable 一样,观察者也存特征观察者,例如:


AnyObserver

AnyObserver 可以用来描叙任意一种观察者。

例如:


打印网络请求结果:

URLSession.shared.rx.data(request: URLRequest(url: url))
.subscribe(onNext: { data in
print("Data Task Success with count: \(data.count)")
}, onError: { error in
print("Data Task Error: \(error)")
})
.disposed(by: disposeBag)

可以看作是:

let observer: AnyObserver<Data> = AnyObserver { (event) in
switch event {
case .next(let data):
print("Data Task Success with count: \(data.count)")
case .error(let error):
print("Data Task Error: \(error)")
default:
break
}
}

URLSession.shared.rx.data(request: URLRequest(url: url))
.subscribe(observer)
.disposed(by: disposeBag)

用户名提示语是否隐藏:

usernameValid
.bind(to: usernameValidOutlet.rx.isHidden)
.disposed(by: disposeBag)

可以看作是:

let observer: AnyObserver<Bool> = AnyObserver { [weak self] (event) in
switch event {
case .next(let isHidden):
self?.usernameValidOutlet.isHidden = isHidden
default:
break
}
}

usernameValid
.bind(to: observer)
.disposed(by: disposeBag)

下一节将介绍 Binder 以及 usernameValidOutlet.rx.isHidden 的由来。


Binder

Binder 主要有以下两个特征:

  • 不会处理错误事件
  • 确保绑定都是在给定 Scheduler 上执行(默认 MainScheduler

一旦产生错误事件,在调试环境下将执行 fatalError,在发布环境下将打印错误信息。


示例

在介绍 AnyObserver 时,我们举了这样一个例子:

let observer: AnyObserver<Bool> = AnyObserver { [weak self] (event) in
switch event {
case .next(let isHidden):
self?.usernameValidOutlet.isHidden = isHidden
default:
break
}
}

usernameValid
.bind(to: observer)
.disposed(by: disposeBag)

由于这个观察者是一个 UI 观察者,所以它在响应事件时,只会处理 next 事件,并且更新 UI 的操作需要在主线程上执行。

因此一个更好的方案就是使用 Binder

let observer: Binder<Bool> = Binder(usernameValidOutlet) { (view, isHidden) in
view.isHidden = isHidden
}

usernameValid
.bind(to: observer)
.disposed(by: disposeBag)

Binder 可以只处理 next 事件,并且保证响应 next 事件的代码一定会在给定 Scheduler 上执行,这里采用默认的 MainScheduler


复用

由于页面是否隐藏是一个常用的观察者,所以应该让所有的 UIView 都提供这种观察者:

extension Reactive where Base: UIView {
public var isHidden: Binder<Bool> {
return Binder(self.base) { view, hidden in
view.isHidden = hidden
}
}
}
usernameValid
.bind(to: usernameValidOutlet.rx.isHidden)
.disposed(by: disposeBag)

这样你不必为每个 UI 控件单独创建该观察者。这就是 usernameValidOutlet.rx.isHidden 的由来,许多 UI 观察者 都是这样创建的:

  • 按钮是否可点击 button.rx.isEnabled

    extension Reactive where Base: UIControl {
    public var isEnabled: Binder<Bool> {
    return Binder(self.base) { control, value in
    control.isEnabled = value
    }
    }
    }
  • label 的当前文本 label.rx.text

    extension Reactive where Base: UILabel {
    public var text: Binder<String?> {
    return Binder(self.base) { label, text in
    label.text = text
    }
    }
    }

你也可以用这种方式来创建自定义的 UI 观察者

收起阅读 »

iOS RXSwift 4.4

iOS
SignalSignal 和 Driver 相似,唯一的区别是,Driver 会对新观察者回放(重新发送)上一个元素,而 Signal 不会对新观察者回放上一个元素。他有如下特性:不会产生 ...
继续阅读 »

Signal

Signal 和 Driver 相似,唯一的区别是,Driver 对新观察者回放(重新发送)上一个元素,而 Signal 不会对新观察者回放上一个元素。

他有如下特性:

  • 不会产生 error 事件
  • 一定在 MainScheduler 监听(主线程监听)
  • 共享附加作用

现在,我们来看看以下代码是否合理:

let textField: UITextField = ...
let nameLabel: UILabel = ...
let nameSizeLabel: UILabel = ...

let state: Driver<String?> = textField.rx.text.asDriver()

let observer = nameLabel.rx.text
state.drive(observer)

// ... 假设以下代码是在用户输入姓名后运行

let newObserver = nameSizeLabel.rx.text
state.map { $0?.count.description }.drive(newObserver)

这个例子只是将用户输入的姓名绑定到对应的标签上。当用户输入姓名后,我们创建了一个新的观察者,用于订阅姓名的字数。那么问题来了,订阅时,展示字数的标签会立即更新吗?

嗯、、、 因为 Driver 会对新观察者回放上一个元素(当前姓名),所以这里是会更新的。在对他进行订阅时,标签的默认文本会被刷新。这是合理的。

那如果我们用 Driver 来描述点击事件呢,这样合理吗?

let button: UIButton = ...
let showAlert: (String) -> Void = ...

let event: Driver<Void> = button.rx.tap.asDriver()

let observer: () -> Void = { showAlert("弹出提示框1") }
event.drive(onNext: observer)

// ... 假设以下代码是在用户点击 button 后运行

let newObserver: () -> Void = { showAlert("弹出提示框2") }
event.drive(onNext: newObserver)

当用户点击一个按钮后,我们创建一个新的观察者,来响应点击事件。此时会发生什么?Driver 会把上一次的点击事件回放给新观察者。所以,这里的 newObserver 在订阅时,就会接受到上次的点击事件,然后弹出提示框。这似乎不太合理。

因此像这类型的事件序列,用 Driver 建模就不合适。于是我们就引入了 Signal:

...

let event: Signal<Void> = button.rx.tap.asSignal()

let observer: () -> Void = { showAlert("弹出提示框1") }
event.emit(onNext: observer)

// ... 假设以下代码是在用户点击 button 后运行

let newObserver: () -> Void = { showAlert("弹出提示框2") }
event.emit(onNext: newObserver)

在同样的场景中,Signal 不会把上一次的点击事件回放给新观察者,而只会将订阅后产生的点击事件,发布给新观察者。这正是我们所需要的。

结论

一般情况下状态序列我们会选用 Driver 这个类型,事件序列我们会选用 Signal 这个类型。

参考


ControlEvent

ControlEvent 专门用于描述 UI 控件所产生的事件,它具有以下特征:

  • 不会产生 error 事件
  • 一定在 MainScheduler 订阅(主线程订阅)
  • 一定在 MainScheduler 监听(主线程监听)
  • 共享附加作用
收起阅读 »

你知道如何批量创建一批邮箱吗?

1.前期准备 搭建邮件服务器需要一些“基础建设”,包括如下 一台服务器 推荐centos 一个域名 1.1 配置细节 邮件服务器是通过SMTP协议进行通信,为了让服务器能够成功接收邮件,我们需要打开25这个端口,并允许访问25端口。同时如果你需要使用像类似...
继续阅读 »

1.前期准备


搭建邮件服务器需要一些“基础建设”,包括如下



  • 一台服务器 推荐centos

  • 一个域名


1.1 配置细节


邮件服务器是通过SMTP协议进行通信,为了让服务器能够成功接收邮件,我们需要打开25这个端口,并允许访问25端口。同时如果你需要使用像类似foxmail这种客户端接发收邮件,还需要支持POP3协议,需要打开110端口。换句话说为了保证邮件服务的正常使用,需要开启25和110这两个端口



关于 POP3协议(Post Office Protocol 3):协议主要用于支持使用客户端远程管理在服务器上的电子邮件,将电子邮件存储到本地主机



下图是阿里云服务器配置安全策略组的规则,在其中加入一条访问规则


image.png


接下来是域名,需要配置域名解析,配置主机记录


如下图是域名的解析配置,主要包括几个记录数值




  • MX类:增加 MX 记录,类型选择 MX记录,值可以填写主机名,也可以填写你的公网ip地址也可以是mail.example.com。如果配置的是域名,还需要新增一条A类型的记录,主机记录定义为:mail,具体看下图




  • A类:该配置主要用来支持客户端接收邮件(比如:foxmail)分别添加smtp、imap、pop等配置,记录值为 ip




配置完如下图所示,可以在列表中看到配置好的,


image.png


2 服务器安装


2.1 Postfix



关于 postfix:Postfix 是实现 SMTP 协议的软件,也叫做邮件发送服务器,负责对邮件进行转发,具体的转发规则,就需要我们对postfix的配置进行修改



我使用的是阿里云的服务器,首先我们安装邮件服务`postfix'



  • 安装


yum install postfix // 服务器安装 


  • 配置


安装成功之后,修改配置,通过vi /etc/postfix/main.cf 命令行修改以下配置


myhostname =  email.example.com //  设置系统的主机名

mydomain = example.com  // 设置域名(我们将让此处设置将成为E-mail地址“@”后面的部分)

myorigin = $mydomain  // 将发信地址“@”后面的部分设置为域名(非系统主机名)

inet_interfaces = all  // 接受来自所有网络的请求

mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain  // 指定发给本地邮件的域名

home_mailbox = Maildir/  // 指定用户邮箱目录

# 规定邮件最大尺寸为10M
message_size_limit = 10485760
# 规定收件箱最大容量为1G
mailbox_size_limit = 1073741824
# SMTP认证
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
smtpd_sasl_security_options = noanonymous
smtpd_sasl_local_domain = $myhostname
smtpd_recipient_restrictions = permit_mynetworks,permit_auth_destination,permit_sasl_authenticated,reject


下图是postfix中主要的参数
image.png



  • 启动


配置完postfix的,启动服务


postfix check   // 检查配置文件是否正确
systemctl start postfix //开启postfix服务
systemctl enable postfix //设置postfix服务开机启动

完成postfix的配置,接下来我们还需要安装dovecot


2.2 Dovecot



关于 Dovecot:是一款能够为Linux系统提供IMAP和POP3电子邮件服务的开源服务程序,安全性极高,配置简单,执行速度快,而且占用的服务器硬件资源也较少。上文提到POP3/IMAP是从邮件服务器中读取邮件时使用的协议




  • 安装


yum install dovecot // 服务器安装 


  • 配置


安装成功之后,修改配置,通过vi /etc/dovecot/dovecot.conf 命令行修改以下配置


protocols = imap pop3 lmtp listen = *, 

#新添加以下配置 #

!include conf.d/10-auth.conf

ssl = no

disable_plaintext_auth = no

mail_location = maildir:~/Maildir



  • 启动


systemctl start dovecot   //开启dovecot服务
systemctl enable dovecot //置dovecot服务开机启动

完成以上两个服务的配置,你离成功就近一步了!



啊乐同学:postfix与dovecot这两个其实有什么区别?



答:postfix主要做发送邮件使用,而dovecot主要做接收使用,两者结合才能完成一个完整的邮件服务


3 新建用户


搭建完邮件服务器之后,我们需要创建用户来完成 邮件的接收和发送



  • 如何创建用户


useradd tree/ 新增用户
passwd tree // 设置用户密码


啊乐同学:如果这样我创建100个邮箱用户,岂不是很浪费时间?



莫慌,我们写个shell脚本,批量创建就可以解决你这个问题


创建一个文件,createUser.sh 内容如下


/bash
#user.txt 为需要创建的用户的文件passwd.txt为随机生成密码
USER_FILE=user.txt
pass_FILE=passwd.txt
for user in `cat user.txt`
do
id $user &> /dev/null #查看用户是否存在
if [ $? -eq 0 ]
then
echo "The $user already exist"
else
useradd $user #创建用户
if [ $? -eq 0 ]
then
echo "$user create sucessful"
PASSWD=$(echo $RANDOM |md5sum |cut -c 1-8) #随机生成数字
echo $PASSWD |passwd --stdin $user &>/dev/null #修改用户密码
echo -e "$user\'$PASSWD'\'$(date +%Y%m%d)'" >> $pass_FILE #将用户,密码,日期输入到文件中
fi
fi
done

前提需要建立一个user.txt 来维护我们要创建的用户,比如


tree
shujiang

脚本会根据我们列出的用户名去批量生成用户


4.测试邮箱


搭建好服务以及完成用户的创建,接下来就是测试邮件是否正常接收环节了


我使用的是foxmail来做验证


image.png


这个用户名就是我们上一节创建的用户名称,完成创建之后,我们通过发送邮件来测试是否能够成功接收


image.png


还有一种方式就是借助telnet去做测试,这里不做大篇幅介绍。最原始的方式



阿乐同学:如果我每个新建的邮箱用户,我都得去配置一个客户端去接收邮寄,岂不是很费劲,有没有其他方式?



有的,换个角度思考,你可以通过配置邮件转发,将所有邮件接收都转发到某一个用户的邮箱中去,你就可以只在该邮箱查阅邮件(我开始怀疑你的动机,是不是搞什么批量注册!)


具体如下,需要配置下第二节中提到的postfix配置文件,在文件最后添加


virtual_alias_domains = ensbook.com  mail.ensbook.com
virtual_alias_maps = hash:/etc/postfix/virtual

完成配置之后,我查阅网上一些资料,需要配置/etc/postfix/virtual文件,该文件主要用来管理电子邮件转发规则的


于是我尝试修改/etc/postfix/virtual文件,并添加一下信息


image.png


这条规则的含义是:所有邮件发送至 @ensbook.com 转发到 qq邮箱


发现竟然没有生效,最后是创建一个virtual的用户实现转发接收的。如果你看得出问题,记得在评论区告诉我



阿乐同学:我接收不到邮箱,又不知道什么问题,如何排查?



你可以通过tail -n /var/log/maillog查看邮件日志


image.png


最后


通过上文的了解,我们不难看到,一个域名邮件服务器的创建其实很简单,而且技术很老。但是无论老不老,能够解决我们的需求就好。如果你有其他方式实现,欢迎在评论区留言。



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

收起阅读 »

JavaScript深浅拷贝的实现

前置知识 对象类型在赋值的过程中其实是复制了地址,从而会导致改变了一方其他也都被改变的情况 let a = { age: 1 } let b = a a.age = 2 console.log(b.a...
继续阅读 »

前置知识



  • 对象类型在赋值的过程中其实是复制了地址,从而会导致改变了一方其他也都被改变的情况


    let a = {
age: 1
}
let b = a
a.age = 2
console.log(b.age) // 2

浅拷贝



  • Object.assign : 拷贝所有的属性值到新的对象中,如果属性值是对象的话,拷贝的是地址,所以并不是深拷贝


    let a = {
age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age) // 1


  • 通过展开运算符 ... 来实现浅拷贝


    let a = {
age: 1
}
let b = {...a}
a.age = 2
console.log(b.age) // 1

深拷贝



  • JSON.parse(JSON.stringify(object))

    • 会忽略 undefined

    • 会忽略 symbol

    • 不能序列化函数

    • 不能解决循环引用的对象,会报错抛出异常




    let a = {
age: 1,
jobs: {
first: 'FE'
}
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = 'native'
console.log(b.jobs.first) // FE

let a = {
age: undefined,
sex: Symbol('male'),
jobs: function() {},
name: 'yck'
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // {name: "yck"}


  • 递归


    function isObject(obj) {
//Object.prototype.toString.call(obj) === '[object Object]'要保留数组形式,用在这里并不合适
return typeof obj === 'object' && obj != null
}

function cloneDeep1(obj){
if(!isObject(obj)) return obj
var newObj = Array.isArray(obj)? [] : {}
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
newObj[key] = isObject(obj[key])? cloneDeep1(obj[key]) : obj[key]
}
}
return newObj
}


  • 问题:递归方法最大的问题在于爆栈,当数据的层次很深是就会栈溢出,例如循环引用



var a = {
name: "muyiy",
a1: undefined,
a2: null,
a3: 123,
book: {title: "You Don't Know JS", price: "45"}
}
a.circleRef = a

// TypeError: Converting circular structure to JSON
JSON.parse(JSON.stringify(a))

//Uncaught RangeError: Maximum call stack size exceeded at Object.hasOwnProperty (<anonymous>)
cloneDeep1(a)



  • 解决方法:循环检测(设置一个数组或者哈希表存储已拷贝过的对象,当检测到当前对象已存在于哈希表中时,取出该值并返回即可)


//哈希表
function cloneDeep3(source, hash = new WeakMap()) {

if (!isObject(source)) return source;
if (hash.has(source)) return hash.get(source); // 新增代码,查哈希表

var target = Array.isArray(source) ? [] : {};
hash.set(source, target); // 新增代码,哈希表设值

for(var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
if (isObject(source[key])) {
target[key] = cloneDeep3(source[key], hash); // 新增代码,传入哈希表
} else {
target[key] = source[key];
}
}
}
return target;
}

//数组
function cloneDeep3(source, uniqueList) {

if (!isObject(source)) return source;
if (!uniqueList) uniqueList = []; // 新增代码,初始化数组

var target = Array.isArray(source) ? [] : {};

// 数据已经存在,返回保存的数据
var uniqueData = find(uniqueList, source);
if (uniqueData) {
return uniqueData.target;
};

// 数据不存在,保存源数据,以及对应的引用
uniqueList.push({
source: source,
target: target
});

for(var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
if (isObject(source[key])) {
target[key] = cloneDeep3(source[key], uniqueList); // 新增代码,传入数组
} else {
target[key] = source[key];
}
}
}
return target;
}

// 新增方法,用于查找
function find(arr, item) {
for(var i = 0; i < arr.length; i++) {
if (arr[i].source === item) {
return arr[i];
}
}
return null;
}

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

收起阅读 »

为什么 Vue2 this 能够直接获取到 data 和 methods ? 源码揭秘!

1. 前言 写相对很难的源码,耗费了自己的时间和精力,也没收获多少阅读点赞,其实是一件挺受打击的事情。从阅读量和读者受益方面来看,不能促进作者持续输出文章。 所以转变思路,写一些相对通俗易懂的文章。其实源码也不是想象的那么难,至少有很多看得懂。歌德曾说:读一...
继续阅读 »

1. 前言



写相对很难的源码,耗费了自己的时间和精力,也没收获多少阅读点赞,其实是一件挺受打击的事情。从阅读量和读者受益方面来看,不能促进作者持续输出文章。
所以转变思路,写一些相对通俗易懂的文章。其实源码也不是想象的那么难,至少有很多看得懂。歌德曾说:读一本好书,就是在和高尚的人谈话。
同理可得:读源码,也算是和作者的一种学习交流的方式。



本文源于一次源码共读群里群友的提问,请问,“为什么 data 中的数据可以用 this 直接获取到啊”,当时我翻阅源码做出了解答。想着如果下次有人再次问到,我还需要回答一次。当时打算有空写篇文章告诉读者自己探究原理,于是就有了这篇文章。


阅读本文,你将学到:


1. 如何学习调试 vue2 源码
2. data 中的数据为什么可以用 this 直接获取到
3. methods 中的方法为什么可以用 this 直接获取到
4. 学习源码中优秀代码和思想,投入到自己的项目中

本文不难,用过 Vue 的都看得懂,希望大家动手调试和学会看源码。


看源码可以大胆猜测,最后小心求证。


2. 示例:this 能够直接获取到 data 和 methods


众所周知,这样是可以输出我是若川的。好奇的人就会思考为啥 this 就能直接访问到呢。


const vm = new Vue({
data: {
name: '我是若川',
},
methods: {
sayName(){
console.log(this.name);
}
},
});
console.log(vm.name); // 我是若川
console.log(vm.sayName()); // 我是若川

那么为什么 this.xxx 能获取到data里的数据,能获取到 methods 方法。


我们自己构造写的函数,如何做到类似Vue的效果呢。


function Person(options){

}

const p = new Person({
data: {
name: '若川'
},
methods: {
sayName(){
console.log(this.name);
}
}
});

console.log(p.name);
// undefined
console.log(p.sayName());
// Uncaught TypeError: p.sayName is not a function

如果是你,你会怎么去实现呢。带着问题,我们来调试 Vue2源码学习。


3. 准备环境调试源码一探究竟


可以在本地新建一个文件夹examples,新建文件index.html文件。
<body></body>中加上如下js


<script src="https://unpkg.com/vue@2.6.14/dist/vue.js"></script>
<script>
const vm = new Vue({
data: {
name: '我是若川',
},
methods: {
sayName(){
console.log(this.name);
}
},
});
console.log(vm.name);
console.log(vm.sayName());
</script>

再全局安装npm i -g http-server启动服务。


npm i -g http-server
cd examples
http-server .
// 如果碰到端口被占用,也可以指定端口
http-server -p 8081 .

这样就能在http://localhost:8080/打开刚写的index.html页面了。


对于调试还不是很熟悉的读者,可以看这篇文章《前端容易忽略的 debugger 调试技巧》,截图标注的很详细。



调试:在 F12 打开调试,source 面板,在例子中const vm = new Vue({打上断点。



如下图所示


刷新页面后按F11进入函数,这时断点就走进了 Vue 构造函数。


3.1 Vue 构造函数


function Vue (options) {
if (!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword');
}
this._init(options);
}
// 初始化
initMixin(Vue);
stateMixin(Vue);
eventsMixin(Vue);
lifecycleMixin(Vue);
renderMixin(Vue);

值得一提的是:if (!(this instanceof Vue)){} 判断是不是用了 new 关键词调用构造函数。
一般而言,我们平时应该不会考虑写这个。


当然看源码库也可以自己函数内部调用 new 。但 vue 一般一个项目只需要 new Vue() 一次,所以没必要。


jQuery 源码的就是内部 new ,对于使用者来说就是无new构造。


jQuery = function( selector, context ) {
// 返回new之后的对象
return new jQuery.fn.init( selector, context );
};

因为使用 jQuery 经常要调用。
其实 jQuery 也是可以 new 的。和不用 new 是一个效果。


如果不明白 new 操作符的用处,可以看我之前的文章。面试官问:能否模拟实现JS的new操作符



调试:继续在this._init(options);处打上断点,按F11进入函数。



3.2 _init 初始化函数


进入 _init 函数后,这个函数比较长,做了挺多事情,我们猜测跟datamethods相关的实现在initState(vm)函数里。


// 代码有删减
function initMixin (Vue) {
Vue.prototype._init = function (options) {
var vm = this;
// a uid
vm._uid = uid$3++;

// a flag to avoid this being observed
vm._isVue = true;
// merge options
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options);
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
);
}

// expose real self
vm._self = vm;
initLifecycle(vm);
initEvents(vm);
initRender(vm);
callHook(vm, 'beforeCreate');
initInjections(vm); // resolve injections before data/props
// 初始化状态
initState(vm);
initProvide(vm); // resolve provide after data/props
callHook(vm, 'created');
};
}


调试:接着我们在initState(vm)函数这里打算断点,按F8可以直接跳转到这个断点,然后按F11接着进入initState函数。



3.3 initState 初始化状态


从函数名来看,这个函数主要实现功能是:


初始化 props
初始化 methods
监测数据
初始化 computed
初始化 watch

function initState (vm) {
vm._watchers = [];
var opts = vm.$options;
if (opts.props) { initProps(vm, opts.props); }
// 有传入 methods,初始化方法
if (opts.methods) { initMethods(vm, opts.methods); }
// 有传入 data,初始化 data
if (opts.data) {
initData(vm);
} else {
observe(vm._data = {}, true /* asRootData */);
}
if (opts.computed) { initComputed(vm, opts.computed); }
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch);
}
}


我们重点来看初始化 methods,之后再看初始化 data




调试:在 initMethods 这句打上断点,同时在initData(vm)处打上断点,看完initMethods函数后,可以直接按F8回到initData(vm)函数。
继续按F11,先进入initMethods函数。



3.4 initMethods 初始化方法


function initMethods (vm, methods) {
var props = vm.$options.props;
for (var key in methods) {
{
if (typeof methods[key] !== 'function') {
warn(
"Method \"" + key + "\" has type \"" + (typeof methods[key]) + "\" in the component definition. " +
"Did you reference the function correctly?",
vm
);
}
if (props && hasOwn(props, key)) {
warn(
("Method \"" + key + "\" has already been defined as a prop."),
vm
);
}
if ((key in vm) && isReserved(key)) {
warn(
"Method \"" + key + "\" conflicts with an existing Vue instance method. " +
"Avoid defining component methods that start with _ or $."
);
}
}
vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
}
}

initMethods函数,主要有一些判断。


判断 methods 中的每一项是不是函数,如果不是警告。
判断 methods 中的每一项是不是和 props 冲突了,如果是,警告。
判断 methods 中的每一项是不是已经在 new Vue实例 vm 上存在,而且是方法名是保留的 _ $ (在JS中一般指内部变量标识)开头,如果是警告。

除去这些判断,我们可以看出initMethods函数其实就是遍历传入的methods对象,并且使用bind绑定函数的this指向为vm,也就是new Vue的实例对象。


这就是为什么我们可以通过this直接访问到methods里面的函数的原因


我们可以把鼠标移上 bind 变量,按alt键,可以看到函数定义的地方,这里是218行,点击跳转到这里看 bind 的实现。


3.4.1 bind 返回一个函数,修改 this 指向


function polyfillBind (fn, ctx) {
function boundFn (a) {
var l = arguments.length;
return l
? l > 1
? fn.apply(ctx, arguments)
: fn.call(ctx, a)
: fn.call(ctx)
}

boundFn._length = fn.length;
return boundFn
}

function nativeBind (fn, ctx) {
return fn.bind(ctx)
}

var bind = Function.prototype.bind
? nativeBind
: polyfillBind;

简单来说就是兼容了老版本不支持 原生的bind函数。同时兼容写法,对参数多少做出了判断,使用callapply实现,据说是因为性能问题。


如果对于call、apply、bind的用法和实现不熟悉,可以查看我在面试官问系列中写的面试官问:能否模拟实现JS的call和apply方法
面试官问:能否模拟实现JS的bind方法



调试:看完了initMethods函数,按F8回到上文提到的initData(vm)函数断点处。



3.5 initData 初始化 data


initData 函数也是一些判断。主要做了如下事情:


先给 _data 赋值,以备后用。
最终获取到的 data 不是对象给出警告。
遍历 data ,其中每一项:
如果和 methods 冲突了,报警告。
如果和 props 冲突了,报警告。
不是内部私有的保留属性,做一层代理,代理到 _data 上。
最后监测 data,使之成为响应式的数据。

function initData (vm) {
var data = vm.$options.data;
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {};
if (!isPlainObject(data)) {
data = {};
warn(
'data functions should return an object:\n' +
'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
vm
);
}
// proxy data on instance
var keys = Object.keys(data);
var props = vm.$options.props;
var methods = vm.$options.methods;
var i = keys.length;
while (i--) {
var key = keys[i];
{
if (methods && hasOwn(methods, key)) {
warn(
("Method \"" + key + "\" has already been defined as a data property."),
vm
);
}
}
if (props && hasOwn(props, key)) {
warn(
"The data property \"" + key + "\" is already declared as a prop. " +
"Use prop default value instead.",
vm
);
} else if (!isReserved(key)) {
proxy(vm, "_data", key);
}
}
// observe data
observe(data, true /* asRootData */);
}

3.5.1 getData 获取数据


是函数时调用函数,执行获取到对象。


function getData (data, vm) {
// #7573 disable dep collection when invoking data getters
pushTarget();
try {
return data.call(vm, vm)
} catch (e) {
handleError(e, vm, "data()");
return {}
} finally {
popTarget();
}
}

3.5.2 proxy 代理


其实就是用 Object.defineProperty 定义对象


这里用处是:this.xxx 则是访问的 this._data.xxx


/**
* Perform no operation.
* Stubbing args to make Flow happy without leaving useless transpiled code
* with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/).
*/
function noop (a, b, c) {}
var sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
};

function proxy (target, sourceKey, key) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
};
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val;
};
Object.defineProperty(target, key, sharedPropertyDefinition);
}

3.5.3 Object.defineProperty 定义对象属性


Object.defineProperty 算是一个非常重要的API。还有一个定义多个属性的API:Object.defineProperties(obj, props) (ES5)


Object.defineProperty 涉及到比较重要的知识点,面试也常考。


value——当试图获取属性时所返回的值。
writable——该属性是否可写。
enumerable——该属性在for in循环中是否会被枚举。
configurable——该属性是否可被删除。
set()——该属性的更新操作所调用的函数。
get()——获取属性值时所调用的函数。

详细举例见此链接


3.6 文中出现的一些函数,最后统一解释下


3.6.1 hasOwn 是否是对象本身拥有的属性


调试模式下,按alt键,把鼠标移到方法名上,可以看到函数定义的地方。点击可以跳转。


/**
* Check whether an object has the property.
*/
var hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn (obj, key) {
return hasOwnProperty.call(obj, key)
}

hasOwn({ a: undefined }, 'a') // true
hasOwn({}, 'a') // false
hasOwn({}, 'hasOwnProperty') // false
hasOwn({}, 'toString') // false
// 是自己的本身拥有的属性,不是通过原型链向上查找的。

3.6.2 isReserved 是否是内部私有保留的字符串$ 和 _ 开头


/**
* Check if a string starts with $ or _
*/
function isReserved (str) {
var c = (str + '').charCodeAt(0);
return c === 0x24 || c === 0x5F
}
isReserved('_data'); // true
isReserved('$options'); // true
isReserved('data'); // false
isReserved('options'); // false

4. 最后用60余行代码实现简化版


function noop (a, b, c) {}
var sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop
};
function proxy (target, sourceKey, key) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
};
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val;
};
Object.defineProperty(target, key, sharedPropertyDefinition);
}
function initData(vm){
const data = vm._data = vm.$options.data;
const keys = Object.keys(data);
var i = keys.length;
while (i--) {
var key = keys[i];
proxy(vm, '_data', key);
}
}
function initMethods(vm, methods){
for (var key in methods) {
vm[key] = typeof methods[key] !== 'function' ? noop : methods[key].bind(vm);
}
}

function Person(options){
let vm = this;
vm.$options = options;
var opts = vm.$options;
if(opts.data){
initData(vm);
}
if(opts.methods){
initMethods(vm, opts.methods)
}
}

const p = new Person({
data: {
name: '若川'
},
methods: {
sayName(){
console.log(this.name);
}
}
});

console.log(p.name);
// 未实现前: undefined
// '若川'
console.log(p.sayName());
// 未实现前:Uncaught TypeError: p.sayName is not a function
// '若川'

5. 总结


本文涉及到的基础知识主要有如下:


构造函数
this 指向
call、bind、apply
Object.defineProperty
等等基础知识。

本文源于解答源码共读群友的疑惑,通过详细的描述了如何调试 Vue 源码,来探寻答案。


解答文章开头提问:


通过this直接访问到methods里面的函数的原因是:因为methods里的方法通过 bind 指定了this为 new Vue的实例(vm)。


通过 this 直接访问到 data 里面的数据的原因是:data里的属性最终会存储到new Vue的实例(vm)上的 _data对象中,访问 this.xxx,是访问Object.defineProperty代理后的 this._data.xxx


Vue的这种设计,好处在于便于获取。也有不方便的地方,就是propsmethodsdata三者容易产生冲突。


文章整体难度不大,但非常建议读者朋友们自己动手调试下。调试后,你可能会发现:原来 Vue 源码,也没有想象中的那么难,也能看懂一部分。


启发:我们工作使用常用的技术和框架或库时,保持好奇心,多思考内部原理。能够做到知其然,知其所以然。就能远超很多人。


你可能会思考,为什么模板语法中,可以省略this关键词写法呢,内部模板编译时其实是用了with。有余力的读者可以探究这一原理。


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

收起阅读 »

webpack-dev-server 从入门到实战

古有云:“工欲善其事,必先利其器”。作为一个前端开发,搭建一个便捷的开发环境,将会为我们的开发工作带来极大的效率提升。而Webpack作为如今前端工程打包必不可少的工具,很多人却不知道从Webpack 4开始提供的DevServer功能。 让我们一起来学习下吧...
继续阅读 »

古有云:“工欲善其事,必先利其器”。作为一个前端开发,搭建一个便捷的开发环境,将会为我们的开发工作带来极大的效率提升。而Webpack作为如今前端工程打包必不可少的工具,很多人却不知道从Webpack 4开始提供的DevServer功能。


让我们一起来学习下吧!


1 什么是webpack-dev-server


DevServerWebpack 3开放的一个实验功能,使用webpack-dev-middleware中间件,提供热更新的开发服务器,旨在帮助开发者在开发阶段快速进行环境搭建。


最新Webpack 5还支持反向代理、防火墙、Socketgzip压缩等功能。


2 反向代理配置


Nginx类似,webpack-dev-server也是通过url正则匹配的方式进行url代理配置,常用配置参考如下代码:


{
"/rest/": {
"target": "http://127.0.0.1:8080",
"secure": false
}
}

还可以通过用JavaScript定义此配置,把多个条目代理到同一个目标。将代理配置文件设置为proxy.conf.js(代替proxy.conf.json),并指定如下例子中的配置文件。


module.exports = {
    //...
    devServer: {
        proxy: [
            {
                context: ['/auth', '/api'],
                target: 'http://localhost:3000',
            },
        ],
    },
};

2.1 基本配置项介绍



  • proxydevServer代理配置

  • /api: 表示需要代理的请求url

  • target:反向代理的地址

  • pathRewrite:请求地址重写,类似NginxRewite功能


其他写法参考:


"pathRewrite": {
  "^/old/api": "/new/api"
}

 // remove path
pathRewrite: {
'^/remove/api': ''
}

// add base path
pathRewrite: {
'^/': '/basepath/'
}

// custom rewriting
pathRewrite: function (path, req) {
return path.replace('/api', '/base/api');
}

// custom rewriting, returning Promise
pathRewrite: async function (path, req) {
const should_add_something = await httpRequestToDecideSomething(path);
if (should_add_something) path += 'something';
return path;
}

2.2 其他配置参考



  • logLevel:日志打印等级,支持['debug', 'info', 'warn', 'error', 'silent']silent不打印日志

  • logProvider: 自定义日志打印中间件

  • secure:是否关闭https安全认证

  • changeOrigin:修改代理请求host

  • protocolRewrite:协议重写,httphttps请求互转

  • cookieDomainRewrite:修改cookieDomain的值

  • headers:给所有请求添加headers配置

  • proxyTimeout:请求超时时间


2.3 高级代理机制



  • onError:  对请求状态码进行处理


function onError(err, req, res, target) {
    res.writeHead(500, {
        'Content-Type': 'text/plain',
    });
    res.end('Something went wrong. And we are reporting a custom error message.');
}


  • onProxyRes: 对代理接口的Response处理,这里常用来获取cookie、重定向等


function onProxyRes(proxyRes, req, res) {
    proxyRes.headers['x-added'] = 'foobar'; // 添加一个header
    delete proxyRes.headers['x-removed']; // 删除一个header
}


  • onProxyReq:对代理接口request处理,执行在请求前,常用来设置cookieheader等操作


function onProxyReq(proxyReq, req, res) {
    // add custom header to request
    proxyReq.setHeader('x-added', 'foobar');
    // or log the req
}

3 域名白名单配置


配置该配置后,只有匹配的host地址才可以访问该服务,常用于开发阶段模拟网络网络防火墙对访问IP进行限制。当该配置项被配置为all时,会跳过host检查,但不建议这样做,因为有DNS攻击的风险。



  1. webpack配置项配置


module.exports = {
  //...
  devServer: {
    allowedHosts: [
      'host.com',
      'subdomain.host.com',
      'subdomain2.host.com',
      'host2.com',
    ],
  },
};


  1. cli 启动命令配置


npx webpack serve --allowed-hosts .host.com --allowed-hosts host2.com

4 端口配置



  1. webpack配置项配置


module.exports = {
  //...
  devServer: {
    port: 8080,
  },
};


  1. cli 启动命令配置


   npx webpack serve --port 8080

5 Angular 实战 —— 通过webpack devServer代理REST接口到本地服务器


在Angular框架中,由于对webpack进行了封装,proxy配置文件默认使用的是proxy.config.json。(js格式配置文件需要到angular.json配置文件中修改),这里以proxy.config.json为例。



  1. 代理所有以/rest/开头的接口到127.0.0.1:8080,并且将/rest/请求地址转为/


{
  "/rest/": {
    "target": "http://127.0.0.1:8080",
    "secure": false,
    "pathRewrite": {
      "/rest/": "/"
    },
    "changeOrigin": true,
    "logLevel": "debug",
    "proxyTimeout": 3000
  }
}

访问启动地址测试{{ host地址}}/rest/testApi



  1. 给所有的/rest/接口加上cftk的header


这个需要使用js格式的proxy配置文件,修改angular.json中的proxyConfig为 proxy.config.js,在proxy.config.js中添加如下内容:


const PROXY_CONFIG = [
    {
        "target": "http://127.0.0.1:8080",
        "secure": false,
        "pathRewrite": {
            "/rest/": "/"
        },
        "changeOrigin": true,
        "logLevel": "debug",
        "proxyTimeout": 3000,
        "onProxyReq": (request, req, res) => {
            request.setHeader('cftk', 'my cftk');
        }
    },
];
module.exports = PROXY_CONFIG;

6 webpack-dev-server 与 nginx 的对比



作者:DevUI团队
链接:https://juejin.cn/post/7010571347705200671

收起阅读 »

Android性能优化—StrictMode的使用

概述StrictMode是Android开发过程中一个必不可缺的性能检测工具,他能帮助开发检测出一些不合理的代码块。策略分类StrictMode分为线程策略(ThreadPolicy)和虚拟机策略(VmPolicy)线程策略(ThreadPolicy)线程策略...
继续阅读 »

概述

StrictMode是Android开发过程中一个必不可缺的性能检测工具,他能帮助开发检测出一些不合理的代码块。

策略分类

StrictMode分为线程策略(ThreadPolicy)和虚拟机策略(VmPolicy)

线程策略(ThreadPolicy)

线程策略主要包含了以下几个方面

  • detectNetwork:监测主线程使用网络(重要)
  • detectCustomSlowCalls:监测自定义运行缓慢函数
  • penaltyLog:输出日志
  • penaltyDialog:监测情况时弹出对话框
  • detectDiskReads:检测在UI线程读磁盘操作 (重要)
  • detectDiskWrites:检测在UI线程写磁盘操作(重要)
  • detectResourceMismatches:检测发现资源不匹配 (api>22)
  • detectAll:检测所有支持检测等项目(如果太懒,不想一一列出来,可以通过这个方式)
  • permitDiskReads:允许UI线程在磁盘上读操作

虚拟机策略(VmPolicy)

虚拟机策略主要包含了以下几个方面

  • detectActivityLeaks:检测Activity 的内存泄露情况(重要)(api>10)
  • detectCleartextNetwork:检测明文的网络 (api>22)
  • detectFileUriExposure:检测file://或者是content:// (api>17)
  • detectLeakedClosableObjects:检测资源没有正确关闭(重要)(api>10)
  • detectLeakedRegistrationObjects:检测BroadcastReceiver、ServiceConnection是否被释放 (重要)(api>15)
  • detectLeakedSqlLiteObjects:检测数据库资源是否没有正确关闭(重要)(api>8)
  • setClassInstanceLimit:设置某个类的同时处于内存中的实例上限,可以协助检查内存泄露(重要)
  • penaltyLog:输出日志
  • penaltyDeath:一旦检测到应用就会崩溃

代码

    private void enabledStrictMode() {
//开启Thread策略模式
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().detectNetwork()//监测主线程使用网络io
.detectCustomSlowCalls()//监测自定义运行缓慢函数
.detectDiskReads() // 检测在UI线程读磁盘操作
.detectDiskWrites() // 检测在UI线程写磁盘操作
.penaltyLog() //写入日志
.penaltyDialog()//监测到上述状况时弹出对话框
.build());
//开启VM策略模式
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectLeakedSqlLiteObjects()//监测sqlite泄露
.detectLeakedClosableObjects()//监测没有关闭IO对象
.setClassInstanceLimit(MainActivity.class, 1) // 设置某个类的同时处于内存中的实例上限,可以协助检查内存泄露
.detectActivityLeaks()
.penaltyLog()//写入日志
.penaltyDeath()//出现上述情况异常终止
.build());
}

案例1

public class MainActivity extends Activity {

private Handler mHandler = new Handler();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (BuildConfig.DEBUG) {
enabledStrictMode();
}
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
Log.d("MainActivity", "我来了");
}
}, 10 * 1000);
TextView tv = new TextView(this);
tv.setText("不错啊");
}

private void enabledStrictMode() {
//开启Thread策略模式
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().detectNetwork()//监测主线程使用网络io
.detectCustomSlowCalls()//监测自定义运行缓慢函数
.detectDiskReads() // 检测在UI线程读磁盘操作
.detectDiskWrites() // 检测在UI线程写磁盘操作
.penaltyLog() //写入日志
.penaltyDialog()//监测到上述状况时弹出对话框
.build());
//开启VM策略模式
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectLeakedSqlLiteObjects()//监测sqlite泄露
.detectLeakedClosableObjects()//监测没有关闭IO对象
.setClassInstanceLimit(MainActivity.class, 1) // 设置某个类的同时处于内存中的实例上限,可以协助检查内存泄露
.detectActivityLeaks()
.penaltyLog()//写入日志
.penaltyDeath()//出现上述情况异常终止
.build());
}
}

如代码所示,我在MainActivity(启动模式为singleTask且为app的启动Activity)中创建一个Handler(非静态),然后执行一个delay了10s的任务。
现在我不断的启动和退出MainActivity,结果发现如下图所示

可以看出MainActivity创建了多份实例(此图使用了MAT中的OQL,以后的章节会详细的讲解),我们的预期是只能有一个这样的MainActivity实例。将其中某个对象实例引用路径列出来,见下图。

通过上图我们可以发现,是Handler持有了此MainActivity实例,导致这个MainActivity无法被释放。

改造

public class MainActivity extends Activity {

private static class InnerHandler extends Handler {
private final WeakReference<MainActivity> mWeakreference;

InnerHandler(MainActivity activity) {
mWeakreference = new WeakReference<>(activity);
}

@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
final MainActivity activity = mWeakreference.get();
if (activity == null) {
return;
}
Log.d("MainActivity","执行msg");
}
}

private Handler mHandler = new InnerHandler(this);

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (BuildConfig.DEBUG) {
enabledStrictMode();
}
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
Log.d("MainActivity", "我来了");
}
}, 10 * 1000);
TextView tv = new TextView(this);
tv.setText("我来了");
setContentView(tv);
}

@Override
protected void onDestroy() {
mHandler.removeCallbacksAndMessages(null);
super.onDestroy();
}

private void enabledStrictMode() {
//开启Thread策略模式
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().detectNetwork()//监测主线程使用网络io
.detectCustomSlowCalls()//监测自定义运行缓慢函数
.detectDiskReads() // 检测在UI线程读磁盘操作
.detectDiskWrites() // 检测在UI线程写磁盘操作
.penaltyLog() //写入日志
.penaltyDialog()//监测到上述状况时弹出对话框
.build());
//开启VM策略模式
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectLeakedSqlLiteObjects()//监测sqlite泄露
.detectLeakedClosableObjects()//监测没有关闭IO对象
.setClassInstanceLimit(MainActivity.class, 1) // 设置某个类的同时处于内存中的实例上限,可以协助检查内存泄露
.detectActivityLeaks()
.penaltyLog()//写入日志
.build());
}
}

将Handler实现为静态内部类,且通过弱引用的方式将当前Activity持有,在onDestory出调用removeCallbacksAndMessages(null)方法,此处填null,表示将Handler中所有的消息都清空掉。
运行代码后,通过MAT分析见下图

由图可见,当前有且仅有一个MainActivity,达到代码设计预期。

备注

这个案例在我们分析过程中,会爆出android instances=2; limit=1字样的StrictMode信息,原因是由于我们在启动退出MainActivity的过程中,系统正在回收MainActivity的实例(回收是需要时间的),即此对象正在被FinalizerReference引用,而我们正在启动另外一项MainActivity,故报两个实例。

案例2

public class MainActivity extends Activity {

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (BuildConfig.DEBUG) {
enabledStrictMode();
}
TextView tv = new TextView(this);
tv.setText("我来了");
setContentView(tv);
newThread();
takeTime();
}

private void newThread() {
for (int i = 0; i < 50; i++) {
new Thread(new Runnable() {
@Override
public void run() {
takeTime();
}
}).start();
}
}

private void takeTime() {
try {
File file = new File(getCacheDir(), "test");
if (file.exists()) {
file.delete();
}
file.createNewFile();
FileOutputStream fileOutputStream = new FileOutputStream(file);
final String content = "hello 我来了";
StringBuffer buffer = new StringBuffer();
for (int i = 0; i < 100; i++) {
buffer.append(content);
}
fileOutputStream.write(buffer.toString().getBytes());
fileOutputStream.flush();
} catch (Exception e) {
e.printStackTrace();
}
}

@Override
protected void onDestroy() {
super.onDestroy();
}

private void enabledStrictMode() {
//开启Thread策略模式
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().detectNetwork()//监测主线程使用网络io
.detectCustomSlowCalls()//监测自定义运行缓慢函数
.detectDiskReads() // 检测在UI线程读磁盘操作
.detectDiskWrites() // 检测在UI线程写磁盘操作
.penaltyLog() //写入日志
.penaltyDialog()//监测到上述状况时弹出对话框
.build());
//开启VM策略模式
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectLeakedSqlLiteObjects()//监测sqlite泄露
.detectLeakedClosableObjects()//监测没有关闭IO对象
.setClassInstanceLimit(MainActivity.class, 1) // 设置某个类的同时处于内存中的实例上限,可以协助检查内存泄露
.detectActivityLeaks()
.penaltyLog()//写入日志
.build());
}
}

运行以上代码,弹出警告对话框

点击确定后,查看StrictMode日志见附图

从日志信息我们可以得到,程序在createNewFile、openFile、writeFile都花了75ms时间,这对于程序来说是一个较为耗时的操作。接着继续我们的日志

从字面上意思我们知道,是文件流没有关闭,通过日志我们能很快的定位问题点:

      fileOutputStream.write(buffer.toString().getBytes());
fileOutputStream.flush();

文件流flush后,没有执行close方法,这样会导致这个文件资源一直被此对象持有,资源得不到释放,造成内存及资源浪费。

总结

StrictMode除了上面的案例情况,还可以检测对IO、网络、数据库等相关操作,而这些操作恰恰是Android开发过程中影响App性能最常见因素(都比较耗时、CPU占用时间、占用大量内存),所以在开发过程中时刻关注StrictMode变化是一个很好的习惯——一方面可以检测项目组员代码质量,另一方面也可以让自己在Android开发过程中形成一些良好的写代码的思维方式。在StrictMode检测过程中,我们要时刻关注日志的变换(如方法执行时间长短),尤其要对那些红色的日志引起注意,因为这些方法引发的问题是巨大的。

收起阅读 »

写个图片加载框架

假如让你自己写个图片加载框架,你会考虑哪些问题?首先,梳理一下必要的图片加载框架的需求:异步加载:线程池切换线程:Handler,没有争议吧缓存:LruCache、DiskLruCache防止OOM:软引用、LruCache、图片压缩、Bitmap像素存储位置...
继续阅读 »

    假如让你自己写个图片加载框架,你会考虑哪些问题?

    首先,梳理一下必要的图片加载框架的需求:

    • 异步加载:线程池
    • 切换线程:Handler,没有争议吧
    • 缓存:LruCache、DiskLruCache
    • 防止OOM:软引用、LruCache、图片压缩、Bitmap像素存储位置
    • 内存泄露:注意ImageView的正确引用,生命周期管理
    • 列表滑动加载的问题:加载错乱、队满任务过多问题

    当然,还有一些不是必要的需求,例如加载动画等。

    2.1 异步加载:

    线程池,多少个?

    缓存一般有三级,内存缓存、硬盘、网络。

    由于网络会阻塞,所以读内存和硬盘可以放在一个线程池,网络需要另外一个线程池,网络也可以采用Okhttp内置的线程池。

    读硬盘和读网络需要放在不同的线程池中处理,所以用两个线程池比较合适。

    Glide 必然也需要多个线程池,看下源码是不是这样

    public final class GlideBuilder {
    ...
    private GlideExecutor sourceExecutor; //加载源文件的线程池,包括网络加载
    private GlideExecutor diskCacheExecutor; //加载硬盘缓存的线程池
    ...
    private GlideExecutor animationExecutor; //动画线程池

    Glide使用了三个线程池,不考虑动画的话就是两个。

    2.2 切换线程:

    图片异步加载成功,需要在主线程去更新ImageView,

    无论是RxJava、EventBus,还是Glide,只要是想从子线程切换到Android主线程,都离不开Handler。

    看下Glide 相关源码:

        class EngineJob<R> implements DecodeJob.Callback<R>,Poolable {
    private static final EngineResourceFactory DEFAULT_FACTORY = new EngineResourceFactory();
    //创建Handler
    private static final Handler MAIN_THREAD_HANDLER =
    new Handler(Looper.getMainLooper(), new MainThreadCallback());

    问RxJava是完全用Java语言写的,那怎么实现从子线程切换到Android主线程的? 依然有很多3-6年的开发答不上来这个很基础的问题,而且只要是这个问题回答不出来的,接下来有关于原理的问题,基本都答不上来。

    有不少工作了很多年的Android开发不知道鸿洋、郭霖、玉刚说,不知道掘金是个啥玩意,内心估计会想是不是还有叫掘银掘铁的(我不知道有没有)。

    我想表达的是,干这一行,真的是需要有对技术的热情,不断学习,不怕别人比你优秀,就怕比你优秀的人比你还努力,而你却不知道

    2.3 缓存

    我们常说的图片三级缓存:内存缓存、硬盘缓存、网络。

    2.3.1 内存缓存

    一般都是用LruCache

    Glide 默认内存缓存用的也是LruCache,只不过并没有用Android SDK中的LruCache,不过内部同样是基于LinkHashMap,所以原理是一样的。

    // -> GlideBuilder#build
    if (memoryCache == null) {
    memoryCache = new LruResourceCache(memorySizeCalculator.getMemoryCacheSize());
    }

    既然说到LruCache ,必须要了解一下LruCache的特点和源码:

    为什么用LruCache?

    LruCache 采用最近最少使用算法,设定一个缓存大小,当缓存达到这个大小之后,会将最老的数据移除,避免图片占用内存过大导致OOM。

    LruCache 源码分析
        public class LruCache<K, V> {
    // 数据最终存在 LinkedHashMap 中
    private final LinkedHashMap<K, V> map;
    ...
    public LruCache(int maxSize) {
    if (maxSize <= 0) {
    throw new IllegalArgumentException("maxSize <= 0");
    }
    this.maxSize = maxSize;
    // 创建一个LinkedHashMap,accessOrder 传true
    this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
    }
    ...

    LruCache 构造方法里创建一个LinkedHashMap,accessOrder 参数传true,表示按照访问顺序排序,数据存储基于LinkedHashMap。

    先看看LinkedHashMap 的原理吧

    LinkedHashMap 继承 HashMap,在 HashMap 的基础上进行扩展,put 方法并没有重写,说明LinkedHashMap遵循HashMap的数组加链表的结构

    LinkedHashMap重写了 createEntry 方法。

    看下HashMap 的 createEntry 方法

    void createEntry(int hash, K key, V value, int bucketIndex) {
    HashMapEntry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new HashMapEntry<>(hash, key, value, e);
    size++;
    }

    HashMap的数组里面放的是HashMapEntry 对象

    看下LinkedHashMap 的 createEntry方法

    void createEntry(int hash, K key, V value, int bucketIndex) {
    HashMapEntry<K,V> old = table[bucketIndex];
    LinkedHashMapEntry<K,V> e = new LinkedHashMapEntry<>(hash, key, value, old);
    table[bucketIndex] = e; //数组的添加
    e.addBefore(header); //处理链表
    size++;
    }

    LinkedHashMap的数组里面放的是LinkedHashMapEntry对象

    LinkedHashMapEntry

    private static class LinkedHashMapEntry<K,V> extends HashMapEntry<K,V> {
    // These fields comprise the doubly linked list used for iteration.
    LinkedHashMapEntry<K,V> before, after; //双向链表

    private void remove() {
    before.after = after;
    after.before = before;
    }

    private void addBefore(LinkedHashMapEntry<K,V> existingEntry) {
    after = existingEntry;
    before = existingEntry.before;
    before.after = this;
    after.before = this;
    }

    LinkedHashMapEntry继承 HashMapEntry,添加before和after变量,所以是一个双向链表结构,还添加了addBeforeremove 方法,用于新增和删除链表节点。

    LinkedHashMapEntry#addBefore
    将一个数据添加到Header的前面

    private void addBefore(LinkedHashMapEntry<K,V> existingEntry) {
    after = existingEntry;
    before = existingEntry.before;
    before.after = this;
    after.before = this;
    }

    existingEntry 传的都是链表头header,将一个节点添加到header节点前面,只需要移动链表指针即可,添加新数据都是放在链表头header 的before位置,链表头节点header的before是最新访问的数据,header的after则是最旧的数据。

    再看下LinkedHashMapEntry#remov

    在Bitmap构造方法创建了一个 BitmapFinalizer类,重写finalize 方法,在java层Bitmap被回收的时候,BitmapFinalizer 对象也会被回收,finalize 方法肯定会被调用,在里面释放native层Bitmap对象。

    6.0 之后做了一些变化,BitmapFinalizer 没有了,被NativeAllocationRegistry取代。

    例如 8.0 Bitmap构造方法

        Bitmap(long nativeBitmap, int width, int height, int density,
    boolean isMutable, boolean requestPremultiplied,
    byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets) {

    ...
    mNativePtr = nativeBitmap;
    long nativeSize = NATIVE_ALLOCATION_SIZE + getAllocationByteCount();
    // 创建NativeAllocationRegistry这个类,调用registerNativeAllocation 方法
    NativeAllocationRegistry registry = new NativeAllocationRegistry(
    Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), nativeSize);
    registry.registerNativeAllocation(this, nativeBitmap);
    }

    NativeAllocationRegistry 就不分析了, 不管是BitmapFinalizer 还是NativeAllocationRegistry,目的都是在java层Bitmap被回收的时候,将native层Bitmap对象也回收掉。 一般情况下我们无需手动调用recycle方法,由GC去盘它即可。

    上面分析了Bitmap像素存储位置,我们知道,Android 8.0 之后Bitmap像素内存放在native堆,Bitmap导致OOM的问题基本不会在8.0以上设备出现了(没有内存泄漏的情况下),那8.0 以下设备怎么办?赶紧升级或换手机吧~

    我们换手机当然没问题,但是并不是所有人都能跟上Android系统更新的步伐,所以,问题还是要解决~

    Fresco 之所以能跟Glide 正面交锋,必然有其独特之处,文中开头列出 Fresco 的优点是:“在5.0以下(最低2.3)系统,Fresco将图片放到一个特别的内存区域(Ashmem区)” 这个Ashmem区是一块匿名共享内存,Fresco 将Bitmap像素放到共享内存去了,共享内存是属于native堆内存。

    Fresco 关键源码在 PlatformDecoderFactory 这个类

    8.0 先不看了,看一下 4.4 以下是怎么得到Bitmap的,看下GingerbreadPurgeableDecoder这个类有个获取Bitmap的方法

    //GingerbreadPurgeableDecoder
    private Bitmap decodeFileDescriptorAsPurgeable(
    CloseableReference<PooledByteBuffer> bytesRef,
    int inputLength,
    byte[] suffix,
    BitmapFactory.Options options) {
    // MemoryFile :匿名共享内存
    MemoryFile memoryFile = null;
    try {
    //将图片数据拷贝到匿名共享内存
    memoryFile = copyToMemoryFile(bytesRef, inputLength, suffix);
    FileDescriptor fd = getMemoryFileDescriptor(memoryFile);
    if (mWebpBitmapFactory != null) {
    // 创建Bitmap,Fresco自己写了一套创建Bitmap方法
    Bitmap bitmap = mWebpBitmapFactory.decodeFileDescriptor(fd, null, options);
    return Preconditions.checkNotNull(bitmap, "BitmapFactory returned null");
    } else {
    throw new IllegalStateException("WebpBitmapFactory is null");
    }
    }
    }

    捋一捋,4.4以下,Fresco 使用匿名共享内存来保存Bitmap数据,首先将图片数据拷贝到匿名共享内存中,然后使用Fresco自己写的加载Bitmap的方法。

    Fresco对不同Android版本使用不同的方式去加载Bitmap,至于4.4-5.0,5.0-8.0,8.0 以上,对应另外三个解码器,大家可以从PlatformDecoderFactory 这个类入手,自己去分析,思考为什么不同平台要分这么多个解码器,8.0 以下都用匿名共享内存不好吗?期待你在评论区跟大家分享~

    2.5 ImageView 内存泄露

    曾经在Vivo驻场开发,带有头像功能的页面被测出内存泄漏,原因是SDK中有个加载网络头像的方法,持有ImageView引用导致的。

    当然,修改也比较简单粗暴,将ImageView用WeakReference修饰就完事了。

    事实上,这种方式虽然解决了内存泄露问题,但是并不完美,例如在界面退出的时候,我们除了希望ImageView被回收,同时希望加载图片的任务可以取消,队未执行的任务可以移除。

    Glide的做法是监听生命周期回调,看 RequestManager 这个类

    public void onDestroy() {
    targetTracker.onDestroy();
    for (Target<?> target : targetTracker.getAll()) {
    //清理任务
    clear(target);
    }
    targetTracker.clear();
    requestTracker.clearRequests();
    lifecycle.removeListener(this);
    lifecycle.removeListener(connectivityMonitor);
    mainHandler.removeCallbacks(addSelfToLifecycle);
    glide.unregisterRequestManager(this);
    }

    在Activity/fragment 销毁的时候,取消图片加载任务,细节大家可以自己去看源码。

    2.6 列表加载问题

    图片错乱

    由于RecyclerView或者LIstView的复用机制,网络加载图片开始的时候ImageView是第一个item的,加载成功之后ImageView由于复用可能跑到第10个item去了,在第10个item显示第一个item的图片肯定是错的。

    常规的做法是给ImageView设置tag,tag一般是图片地址,更新ImageView之前判断tag是否跟url一致。

    当然,可以在item从列表消失的时候,取消对应的图片加载任务。要考虑放在图片加载框架做还是放在UI做比较合适。

    线程池任务过多

    列表滑动,会有很多图片请求,如果是第一次进入,没有缓存,那么队列会有很多任务在等待。所以在请求网络图片之前,需要判断队列中是否已经存在该任务,存在则不加到队列去。

    总结

    本文通过Glide开题,分析一个图片加载框架必要的需求,以及各个需求涉及到哪些技术和原理。

    • 异步加载:最少两个线程池
    • 切换到主线程:Handler
    • 缓存:LruCache、DiskLruCache,涉及到LinkHashMap原理
    • 防止OOM:软引用、LruCache、图片压缩没展开讲、Bitmap像素存储位置源码分析、Fresco部分源码分析
    • 内存泄露:注意ImageView的正确引用,生命周期管理

收起阅读 »

Android 高级UI 事件传递机制

1.View的事件分发流程dispatchTouchEvent():onTouchListener--->onTouch方法onTouchEventonClickListener--->onClick方法ListenerInfo static...
继续阅读 »

1.View的事件分发

流程
  1. dispatchTouchEvent():
  2. onTouchListener--->onTouch方法
  3. onTouchEvent
  4. onClickListener--->onClick方法

ListenerInfo


    static class ListenerInfo {
/**
* Listener used to dispatch focus change events.
* This field should be made private, so it is hidden from the SDK.
* {@hide}
*/

protected OnFocusChangeListener mOnFocusChangeListener;

/**
* Listeners for layout change events.
*/

private ArrayList<OnLayoutChangeListener> mOnLayoutChangeListeners;

protected OnScrollChangeListener mOnScrollChangeListener;

/**
* Listeners for attach events.
*/

private CopyOnWriteArrayList<OnAttachStateChangeListener> mOnAttachStateChangeListeners;

/**
* Listener used to dispatch click events.
* This field should be made private, so it is hidden from the SDK.
* {@hide}
*/

public OnClickListener mOnClickListener;

/**
* Listener used to dispatch long click events.
* This field should be made private, so it is hidden from the SDK.
* {@hide}
*/

protected OnLongClickListener mOnLongClickListener;

/**
* Listener used to dispatch context click events. This field should be made private, so it
* is hidden from the SDK.
* {@hide}
*/

protected OnContextClickListener mOnContextClickListener;

/**
* Listener used to build the context menu.
* This field should be made private, so it is hidden from the SDK.
* {@hide}
*/

protected OnCreateContextMenuListener mOnCreateContextMenuListener;

private OnKeyListener mOnKeyListener;

private OnTouchListener mOnTouchListener;

private OnHoverListener mOnHoverListener;

private OnGenericMotionListener mOnGenericMotionListener;

private OnDragListener mOnDragListener;

private OnSystemUiVisibilityChangeListener mOnSystemUiVisibilityChangeListener;

OnApplyWindowInsetsListener mOnApplyWindowInsetsListener;

OnCapturedPointerListener mOnCapturedPointerListener;
}

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)) {
result = true;
}

if (!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;
}

onTouchEvent

结论:
  1. 控件的Listener事件触发的顺序是onTouch,再onClick
  2. 控件的onTouch返回true,将会使onClick的事件没有了---阻止了事件的传递。返回false,才会传递onClick事件 。
  3. 如果onTouchListener的onTouch方法返回了true,那么view里面的onTouchEvent就不会被调用了。顺序dispatchTouchEvent-->onTouchListener---return false-->onTouchEvent
  4. 如果view为disenable,则:onTouchListener里面不会执行,但是会执行onTouchEvent(event)方法
  5. onTouchEvent方法中的ACTION_UP分支中触发onclick事件监听
    onTouchListener-->onTouch方法返回true,消耗次事件。down,但是up事件是无法到达onClickListener.
    onTouchListener-->onTouch方法返回false,不会消耗此事件

2.ViewGroup+View的事件分发

ViewGroup继承View

  1. dispatchTouchEvent()
  2. onInterceptTouchEvent() (拦截触摸,ViewGroup独有)
  3. onTouchEvent()
dispatchTouchEvent

  @Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onTouchEvent(ev, 1);
}
if (!handled && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
}
return handled;
}
onInterceptTouchEvent

  public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.isFromSource(InputDevice.SOURCE_MOUSE)
&& ev.getAction() == MotionEvent.ACTION_DOWN
&& ev.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
&& isOnScrollbarThumb(ev.getX(), ev.getY())) {
return true;
}
return false;
}
示例

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.widget.RelativeLayout;

/**
* Created by Xionghu on 2018/6/6.
* Desc:
*/


public class MyRelativeLayout extends RelativeLayout {
public MyRelativeLayout(Context context) {
super(context);
}

public MyRelativeLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
Log.i("kpioneer", "dispatchTouchEvent:action--"+ev.getAction()+"---view:MyRelativeLayout");
return super.dispatchTouchEvent(ev);
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
Log.i("kpioneer", "onInterceptTouchEvent:action--"+ev.getAction()+"---view:MyRelativeLayout");
return super.onInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
Log.i("kpioneer", "onTouchEvent:action--"+event.getAction()+"---view:MyRelativeLayout");
return super.onTouchEvent(event);
}
}

点击Button


06-06 11:05:18.340 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--0---view:MyRelativeLayout
06-06 11:05:18.340 27438-27438/com.haocai.eventdemo I/kpioneer: onInterceptTouchEvent:action--0---view:MyRelativeLayout
06-06 11:05:18.340 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--0
06-06 11:05:18.340 27438-27438/com.haocai.eventdemo I/kpioneer: OnTouchListener:acton--0----view:com.haocai.eventdemo.MyButton{8e32527 VFED..C.. ........ 0,42-264,186 #7f070022 app:id/button1}
06-06 11:05:18.340 27438-27438/com.haocai.eventdemo I/kpioneer: onTouchEvent:action--0
06-06 11:05:18.370 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--2---view:MyRelativeLayout
06-06 11:05:18.370 27438-27438/com.haocai.eventdemo I/kpioneer: onInterceptTouchEvent:action--2---view:MyRelativeLayout
06-06 11:05:18.370 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--2
06-06 11:05:18.370 27438-27438/com.haocai.eventdemo I/kpioneer: OnTouchListener:acton--2----view:com.haocai.eventdemo.MyButton{8e32527 VFED..C.. ...P.... 0,42-264,186 #7f070022 app:id/button1}
06-06 11:05:18.380 27438-27438/com.haocai.eventdemo I/kpioneer: onTouchEvent:action--2
06-06 11:05:18.390 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--2---view:MyRelativeLayout
06-06 11:05:18.390 27438-27438/com.haocai.eventdemo I/kpioneer: onInterceptTouchEvent:action--2---view:MyRelativeLayout
06-06 11:05:18.390 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--2
06-06 11:05:18.390 27438-27438/com.haocai.eventdemo I/kpioneer: OnTouchListener:acton--2----view:com.haocai.eventdemo.MyButton{8e32527 VFED..C.. ...P.... 0,42-264,186 #7f070022 app:id/button1}
06-06 11:05:18.390 27438-27438/com.haocai.eventdemo I/kpioneer: onTouchEvent:action--2
06-06 11:05:18.400 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--2---view:MyRelativeLayout
06-06 11:05:18.400 27438-27438/com.haocai.eventdemo I/kpioneer: onInterceptTouchEvent:action--2---view:MyRelativeLayout
06-06 11:05:18.400 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--2
06-06 11:05:18.400 27438-27438/com.haocai.eventdemo I/kpioneer: OnTouchListener:acton--2----view:com.haocai.eventdemo.MyButton{8e32527 VFED..C.. ...P.... 0,42-264,186 #7f070022 app:id/button1}
06-06 11:05:18.410 27438-27438/com.haocai.eventdemo I/kpioneer: onTouchEvent:action--2
06-06 11:05:18.410 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--1---view:MyRelativeLayout
06-06 11:05:18.410 27438-27438/com.haocai.eventdemo I/kpioneer: onInterceptTouchEvent:action--1---view:MyRelativeLayout
06-06 11:05:18.410 27438-27438/com.haocai.eventdemo I/kpioneer: dispatchTouchEvent:action--1
06-06 11:05:18.410 27438-27438/com.haocai.eventdemo I/kpioneer: OnTouchListener:acton--1----view:com.haocai.eventdemo.MyButton{8e32527 VFED..C.. ...P.... 0,42-264,186 #7f070022 app:id/button1}
06-06 11:05:18.410 27438-27438/com.haocai.eventdemo I/kpioneer: onTouchEvent:action--1
06-06 11:05:18.410 27438-27438/com.haocai.eventdemo I/kpioneer: OnClickListener----view:com.haocai.eventdemo.MyButton{8e32527 VFED..C.. ...P.... 0,42-264,186 #7f070022 app:id/button1}
该例子中Button事件点击:
  1. 先接触到事件的是父容器
  2. ViewGroup顺序:dispatchTouchEvent--->onInterceptTouchevent-->dispatchTouchEvent(Button)-->OnTouchListener(Button) --->return false---> onTouchEvent(Button)(消耗事件) ----- onTouchevent(该示例父布局并没调用)
收起阅读 »

Android View post 方法

解析View.post方法。分析一下这个方法的流程。 说起post方法,我们很容易联想到Handler的post方法,都是接收一个Runnable对象。那么这两个方法有啥不同呢? Handler的post方法 先来简单看一下Handler的post(Runna...
继续阅读 »

解析View.post方法。分析一下这个方法的流程。


说起post方法,我们很容易联想到Handlerpost方法,都是接收一个Runnable对象。那么这两个方法有啥不同呢?


Handler的post方法


先来简单看一下Handlerpost(Runnable)方法。这个方法是将一个Runnable加到消息队列中,并且会在这个handler关联的线程里执行。


下面是关联的部分源码。可以看到传入的Runnable对象,装入Message后,被添加进了queue队列中。


Handler 有关的部分源码


    // android.os Handler 有关的部分源码
public final boolean post(@NonNull Runnable r) {
return sendMessageDelayed(getPostMessage(r), 0);
}

private static Message getPostMessage(Runnable r) {
Message m = Message.obtain();
m.callback = r;
return m;
}

public final boolean sendMessageDelayed(@NonNull Message msg, long delayMillis) {
if (delayMillis < 0) {
delayMillis = 0;
}
return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);
}

public boolean sendMessageAtTime(@NonNull Message msg, long uptimeMillis) {
MessageQueue queue = mQueue;
if (queue == null) {
RuntimeException e = new RuntimeException(
this + " sendMessageAtTime() called with no mQueue");
Log.w("Looper", e.getMessage(), e);
return false;
}
return enqueueMessage(queue, msg, uptimeMillis);
}

private boolean enqueueMessage(@NonNull MessageQueue queue, @NonNull Message msg,
long uptimeMillis) {
msg.target = this;
msg.workSourceUid = ThreadLocalWorkSource.getUid();

if (mAsynchronous) {
msg.setAsynchronous(true);
}
return queue.enqueueMessage(msg, uptimeMillis);
}

具体流程,可以看handler介绍


View的post方法


我们直接跟着post的源码走。


public boolean post(Runnable action) {
final AttachInfo attachInfo = mAttachInfo;
if (attachInfo != null) {
return attachInfo.mHandler.post(action);
}

// Postpone the runnable until we know on which thread it needs to run.
// Assume that the runnable will be successfully placed after attach.
getRunQueue().post(action);
return true;
}

private HandlerActionQueue getRunQueue() {
if (mRunQueue == null) {
mRunQueue = new HandlerActionQueue();
}
return mRunQueue;
}

可以看到一开始就查询是否有attachInfo,如果有,则用attachInfo.mHandler来执行这个任务。


如果没有attachInfo,则添加到View自己的mRunQueue中。确定运行的线程后,再执行任务。


post(Runnable action)的返回boolean值,如果为true,表示任务被添加到消息队列中了。
如果是false,通常表示消息队列关联的looper正在退出。


那么我们需要了解AttachInfoHandlerActionQueue


AttachInfo


AttachInfoView的静态内部类。View关联到父window后,用这个类来存储一些信息。


AttachInfo存储的一部分信息如下:



  • WindowId mWindowId window的标志

  • View mRootView 最顶部的view

  • Handler mHandler 这个handler可以用来处理任务


HandlerActionQueue


View还没有handler的时候,拿HandlerActionQueue来缓存任务。HandlerAction是它的静态内部类,存储Runnable与延时信息。


public class HandlerActionQueue {
private HandlerAction[] mActions;

public void post(Runnable action)
public void executeActions(Handler handler)
// ...

private static class HandlerAction {
final Runnable action;
final long delay;
// ...
}
}

View的mRunQueue


将任务(runnable)排成队。当View关联上窗口并且有handler后,再执行这些任务。


/**
* Queue of pending runnables. Used to postpone calls to post() until this
* view is attached and has a handler.
*/
private HandlerActionQueue mRunQueue;

这个mRunQueue里存储的任务啥时候被执行?我们关注dispatchAttachedToWindow方法。


void dispatchAttachedToWindow(AttachInfo info, int visibility) {
// ...
// Transfer all pending runnables.
if (mRunQueue != null) {
mRunQueue.executeActions(info.mHandler);
mRunQueue = null;
}
// ...
}

这个方法里调用了mRunQueue.executeActions


executeActions(Handler handler)方法实际上是用传入的handler处理队列中的任务。


而这个dispatchAttachedToWindow会被ViewGroup中被调用。


或者是ViewRootImpl中调用


host.dispatchAttachedToWindow(mAttachInfo, 0);

小结


View的post方法,实际上是使用了AttachInfohandler


如果View当前还没有AttachInfo,则把任务添加到了View自己的HandlerActionQueue队列中,然后在dispatchAttachedToWindow中把任务交给传入的AttachInfohandler。也可以这样认为,View.post用的就是handler.post


我们在获取View的宽高时,会利用View的post方法,就是等View真的关联到window再拿宽高信息。


流程图归纳如下


post-flow1.png


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

【开源项目】简单易用的Compose版StateLayout,了解一下~

前言 在页面中常常需要展示网络请求状态,以带来更好的用户体验,具体来说通常有加载中,加载失败,加载为空,加载成功等状态. 在XML中我们通常用一个ViewGroup封装各种状态来实现,那么使用Compose该如何实现这种效果呢? 本文主要介绍Compose如何...
继续阅读 »

前言


在页面中常常需要展示网络请求状态,以带来更好的用户体验,具体来说通常有加载中加载失败加载为空加载成功等状态.

XML中我们通常用一个ViewGroup封装各种状态来实现,那么使用Compose该如何实现这种效果呢?

本文主要介绍Compose如何封装一个简单易用的StateLayout,有兴趣的同学可以点个Star : Compose版StateLayout


效果图


首先看下最终的效果图


特性



  1. 支持配置全局默认布局,如默认加载中,默认成功失败等

  2. 支持自定义默认样式文案,图片等细节

  3. 支持完全自定义样式,如自定义加载中样式

  4. 支持自定义处理点击重试事件

  5. 完全使用数据驱动,使用简单,接入方便


使用


接入


第 1 步:在工程的build.gradle中添加:


allprojects {
repositories {
...
mavenCentral()
}
}

第2步:在应用的build.gradle中添加:


dependencies {
implementation 'io.github.shenzhen2017:compose-statelayout:1.0.0'
}

简单使用


定义全局样式


在框架中没有指定任何默认样式,因此你需要自定义自己的默认加载中,加载失败等页面样式

同时需要自定义传给自定义样式的数据结构类型,方便数据驱动


data class StateData(
val tipTex: String? = null,
val tipImg: Int? = null,
val btnText: String? = null
)

@Composable
fun DefaultStateLayout(
modifier: Modifier = Modifier,
pageStateData: PageStateData,
onRetry: OnRetry = { },
loading: @Composable (StateLayoutData) -> Unit = { DefaultLoadingLayout(it) },
empty: @Composable (StateLayoutData) -> Unit = { DefaultEmptyLayout(it) },
error: @Composable (StateLayoutData) -> Unit = { DefaultErrorLayout(it) },
content: @Composable () -> Unit = { }
) {
ComposeStateLayout(
modifier = modifier,
pageStateData = pageStateData,
onRetry = onRetry,
loading = { loading(it) },
empty = { empty(it) },
error = { error(it) },
content = content
)
}

如上所示,初始化时我们主要需要做以下事



  1. 自定义默认加载中,加载失败,加载为空等样式

  2. 自定义StateData,即传给默认样式的数据结构,比如文案,图片等,这样后续需要修改的时候只需修改StateData即可


直接使用


如果我们直接使用默认样式,直接如下使用即可


@Composable
fun StateDemo() {
var pageStateData by remember {
mutableStateOf(PageState.CONTENT.bindData())
}
DefaultStateLayout(
modifier = Modifier.fillMaxSize(),
pageStateData = pageStateData,
onRetry = {
pageStateData = PageState.LOADING.bindData()
}
) {
//Content
}
}

如上所示,可以直接使用,如果需要修改状态,修改pageStateData即可


自定义文案


如果我们需要自定义文案或者图片等细节,可简单直接修改StateData即可


fun StateDemo() {
var pageStateData by remember {
mutableStateOf(PageState.CONTENT.bindData())
}
//....
pageStateData = PageState.LOADING.bindData(StateData(tipTex = "自定义加载中文案"))
}

自定义布局


有时页面的加载中样式与全局的并不一样,这就需要自定义布局样式了


@Composable
fun StateDemo() {
var pageStateData by remember {
mutableStateOf(PageState.CONTENT.bindData())
}
DefaultStateLayout(
modifier = Modifier.fillMaxSize(),
pageStateData = pageStateData,
loading = { CustomLoadingLayout(it) },
onRetry = {
pageStateData = PageState.LOADING.bindData()
}
) {
//Content
}
}

主要原理


其实Compose要实现不同的状态非常简单,传入不同的数据即可,如下所示:


    Box(modifier = modifier) {
when (pageStateData.status) {
PageState.LOADING -> loading()
PageState.EMPTY -> empty()
PageState.ERROR -> error()
PageState.CONTENT -> content()
}
}

其实代码非常简单,但是这段代码是个通用逻辑,如果每个页面都要写这一段代码可能也挺烦的

所以这段代码其实是模板代码,我们想到Scaffold脚手架,提供了组合各个组件的API,包括标题栏、底部栏、SnackBar(类似吐司功能)、浮动按钮、抽屉组件、剩余内容布局等,让我们可以快速定义一个基本的页面结构。


仿照Scaffold,我们也可以定义一个模板组件,用户可以传入自定义的looading,empty,error,content等组件,再将它们组合起来,这样就形成了ComposeStateLayout


data class PageStateData(val status: PageState, val tag: Any? = null)

data class StateLayoutData(val pageStateData: PageStateData, val retry: OnRetry = {})

typealias OnRetry = (PageStateData) -> Unit

@Composable
fun ComposeStateLayout(
modifier: Modifier = Modifier,
pageStateData: PageStateData,
onRetry: OnRetry = { },
loading: @Composable (StateLayoutData) -> Unit = {},
empty: @Composable (StateLayoutData) -> Unit = {},
error: @Composable (StateLayoutData) -> Unit = {},
content: @Composable () -> Unit = { }
) {
val stateLayoutData = StateLayoutData(pageStateData, onRetry)
Box(modifier = modifier) {
when (pageStateData.status) {
PageState.LOADING -> loading(stateLayoutData)
PageState.EMPTY -> empty(stateLayoutData)
PageState.ERROR -> error(stateLayoutData)
PageState.CONTENT -> content()
}
}
}

如上所示,代码很简单,主要需要注意以下几点:



  1. PageStateDatatag即传递给自定义loading等页面的信息,为Any类型,没有任何限制,用户可灵活处理

  2. 自定义loading等页面也传入了OnRetry,因此我们也可以处理自定义点击事件


总结


本文主要实现了一个Compose版的StateLayout,它具有以下特性



  1. 支持配置全局默认布局,如默认加载中,默认成功失败等

  2. 支持自定义默认样式文案,图片等细节

  3. 支持完全自定义样式,如自定义加载中样式

  4. 支持自定义处理点击重试事件

  5. 完全使用数据驱动,使用简单,接入方便


项目地址


简单易用的Compose版StateLayout

开源不易,如果项目对你有所帮助,欢迎点赞,Star,收藏~


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

iOS RXSwift 4.3

iOS
MaybeMaybe 是 Observable 的另外一个版本。它介于 Single 和 Completable 之间,它要么只能发出一个元素,要么产生一个 completed&n...
继续阅读 »

Maybe

Maybe 是 Observable 的另外一个版本。它介于 Single 和 Completable 之间,它要么只能发出一个元素,要么产生一个 completed 事件,要么产生一个 error 事件。

  • 发出一个元素或者一个 completed 事件或者一个 error 事件
  • 不会共享附加作用

如果你遇到那种可能需要发出一个元素,又可能不需要发出时,就可以使用 Maybe

如何创建 Maybe

创建 Maybe 和创建 Observable 非常相似:

func generateString() -> Maybe<String> {
return Maybe<String>.create { maybe in
maybe(.success("RxSwift"))

// OR

maybe(.completed)

// OR

maybe(.error(error))

return Disposables.create {}
}
}

之后,你可以这样使用 Maybe

generateString()
.subscribe(onSuccess: { element in
print("Completed with element \(element)")
}, onError: { error in
print("Completed with an error \(error.localizedDescription)")
}, onCompleted: {
print("Completed with no element")
})
.disposed(by: disposeBag)

你同样可以对 Observable 调用 .asMaybe() 方法,将它转换为 Maybe

Driver

Driver(司机?) 是一个精心准备的特征序列。它主要是为了简化 UI 层的代码。不过如果你遇到的序列具有以下特征,你也可以使用它:

  • 不会产生 error 事件
  • 一定在 MainScheduler 监听(主线程监听)
  • 共享附加作用

这些都是驱动 UI 的序列所具有的特征。

为什么要使用 Driver ?

我们举个例子来说明一下,为什么要使用 Driver

这是文档简介页的例子:

let results = query.rx.text
.throttle(0.3, scheduler: MainScheduler.instance)
.flatMapLatest { query in
fetchAutoCompleteItems(query)
}

results
.map { "\($0.count)" }
.bind(to: resultCount.rx.text)
.disposed(by: disposeBag)

results
.bind(to: resultsTableView.rx.items(cellIdentifier: "Cell")) {
(_, result, cell) in
cell.textLabel?.text = "\(result)"
}
.disposed(by: disposeBag)

这段代码的主要目的是:

  • 取出用户输入稳定后的内容
  • 向服务器请求一组结果
  • 将返回的结果绑定到两个 UI 元素上:tableView 和 显示结果数量的label

那么这里存在什么问题?

  • 如果 fetchAutoCompleteItems 的序列产生了一个错误(网络请求失败),这个错误将取消所有绑定,当用户输入一个新的关键字时,是无法发起新的网络请求。
  • 如果 fetchAutoCompleteItems 在后台返回序列,那么刷新页面也会在后台进行,这样就会出现异常崩溃。
  • 返回的结果被绑定到两个 UI 元素上。那就意味着,每次用户输入一个新的关键字时,就会分别为两个 UI 元素发起 HTTP 请求,这并不是我们想要的结果。

一个更好的方案是这样的:

let results = query.rx.text
.throttle(0.3, scheduler: MainScheduler.instance)
.flatMapLatest { query in
fetchAutoCompleteItems(query)
.observeOn(MainScheduler.instance) // 结果在主线程返回
.catchErrorJustReturn([]) // 错误被处理了,这样至少不会终止整个序列
}
.share(replay: 1) // HTTP 请求是被共享的

results
.map { "\($0.count)" }
.bind(to: resultCount.rx.text)
.disposed(by: disposeBag)

results
.bind(to: resultsTableView.rx.items(cellIdentifier: "Cell")) {
(_, result, cell) in
cell.textLabel?.text = "\(result)"
}
.disposed(by: disposeBag)

在一个大型系统内,要确保每一步不被遗漏是一件不太容易的事情。所以更好的选择是合理运用编译器和特征序列来确保这些必备条件都已经满足。

以下是使用 Driver 优化后的代码:

let results = query.rx.text.asDriver()        // 将普通序列转换为 Driver
.throttle(0.3, scheduler: MainScheduler.instance)
.flatMapLatest { query in
fetchAutoCompleteItems(query)
.asDriver(onErrorJustReturn: []) // 仅仅提供发生错误时的备选返回值
}

results
.map { "\($0.count)" }
.drive(resultCount.rx.text) // 这里改用 `drive` 而不是 `bindTo`
.disposed(by: disposeBag) // 这样可以确保必备条件都已经满足了

results
.drive(resultsTableView.rx.items(cellIdentifier: "Cell")) {
(_, result, cell) in
cell.textLabel?.text = "\(result)"
}
.disposed(by: disposeBag)

首先第一个 asDriver 方法将 ControlProperty 转换为 Driver

然后第二个变化是:

.asDriver(onErrorJustReturn: [])

任何可监听序列都可以被转换为 Driver,只要他满足 3 个条件:

  • 不会产生 error 事件
  • 一定在 MainScheduler 监听(主线程监听)
  • 共享附加作用

那么要如何确定条件都被满足?通过 Rx 操作符来进行转换。asDriver(onErrorJustReturn: []) 相当于以下代码:

let safeSequence = xs
.observeOn(MainScheduler.instance) // 主线程监听
.catchErrorJustReturn(onErrorJustReturn) // 无法产生错误
.share(replay: 1, scope: .whileConnected)// 共享附加作用
return Driver(raw: safeSequence) // 封装

最后使用 drive 而不是 bindTo

drive 方法只能被 Driver 调用。这意味着,如果你发现代码所存在 drive,那么这个序列不会产生错误事件并且一定在主线程监听。这样你可以安全的绑定 UI 元素。

收起阅读 »

iOS RXSwift 4.2

iOS
SingleSingle 是 Observable 的另外一个版本。不像 Observable 可以发出多个元素,它要么只能发出一个元素,要么产生一个 error 事件。发出一个元素,或一个...
继续阅读 »

Single

Single 是 Observable 的另外一个版本。不像 Observable 可以发出多个元素,它要么只能发出一个元素,要么产生一个 error 事件。

一个比较常见的例子就是执行 HTTP 请求,然后返回一个应答错误。不过你也可以用 Single 来描述任何只有一个元素的序列。

如何创建 Single

创建 Single 和创建 Observable 非常相似:

func getRepo(_ repo: String) -> Single<[String: Any]> {

return Single<[String: Any]>.create { single in
let url = URL(string: "https://api.github.com/repos/\(repo)")!
let task = URLSession.shared.dataTask(with: url) {
data, _, error in

if let error = error {
single(.error(error))
return
}

guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data, options: .mutableLeaves),
let result = json as? [String: Any] else {
single(.error(DataError.cantParseJSON))
return
}

single(.success(result))
}

task.resume()

return Disposables.create { task.cancel() }
}
}

之后,你可以这样使用 Single

getRepo("ReactiveX/RxSwift")
.subscribe(onSuccess: { json in
print("JSON: ", json)
}, onError: { error in
print("Error: ", error)
})
.disposed(by: disposeBag)

订阅提供一个 SingleEvent 的枚举:

public enum SingleEvent<Element> {
case success(Element)
case error(Swift.Error)
}
  • success - 产生一个单独的元素
  • error - 产生一个错误

你同样可以对 Observable 调用 .asSingle() 方法,将它转换为 Single

Completable

Completable 是 Observable 的另外一个版本。不像 Observable 可以发出多个元素,它要么只能产生一个 completed 事件,要么产生一个 error 事件。

  • 发出零个元素
  • 发出一个 completed 事件或者一个 error 事件
  • 不会共享附加作用

Completable 适用于那种你只关心任务是否完成,而不需要在意任务返回值的情况。它和 Observable<Void> 有点相似。

如何创建 Completable

创建 Completable 和创建 Observable 非常相似:

func cacheLocally() -> Completable {
return Completable.create { completable in
// Store some data locally
...
...

guard success else {
completable(.error(CacheError.failedCaching))
return Disposables.create {}
}

completable(.completed)
return Disposables.create {}
}
}

之后,你可以这样使用 Completable

cacheLocally()
.subscribe(onCompleted: {
print("Completed with no error")
}, onError: { error in
print("Completed with an error: \(error.localizedDescription)")
})
.disposed(by: disposeBag)

订阅提供一个 CompletableEvent 的枚举:

public enum CompletableEvent {
case error(Swift.Error)
case completed
}
  • completed - 产生完成事件
  • error - 产生一个错误
收起阅读 »

iOS RXSwift 4.1

iOS
Observable - 可监听序列所有的事物都是序列之前我们提到,Observable 可以用于描述元素异步产生的序列。这样我们生活中许多事物都可以通过它来表示,例如:Observable<Double> 温度你可以将温度看作...
继续阅读 »

Observable - 可监听序列

1

所有的事物都是序列

之前我们提到,Observable 可以用于描述元素异步产生的序列。这样我们生活中许多事物都可以通过它来表示,例如:

  • Observable<Double> 温度

    你可以将温度看作是一个序列,然后监测这个温度值,最后对这个值做出响应。例如:当室温高于 33 度时,打开空调降温。

    1

  • Observable<OnePieceEpisode> 《海贼王》动漫

    你也可以把《海贼王》的动漫看作是一个序列。然后当《海贼王》更新一集时,我们就立即观看这一集。

    1

  • Observable<JSON> JSON

    你可以把网络请求的返回的 JSON 看作是一个序列。然后当取到 JSON 时,将它打印出来。

    1

  • Observable<Void> 任务回调

    你可以把任务回调看作是一个序列。当任务结束后,提示用户任务已完成。

    1

如何创建序列

现在我们已经可以把生活中的许多事物看作是一个序列了。那么我们要怎么创建这些序列呢?

实际上,框架已经帮我们创建好了许多常用的序列。例如:button的点击,textField的当前文本,switch的开关状态,slider的当前数值等等。

另外,有一些自定义的序列是需要我们自己创建的。这里介绍一下创建序列最基本的方法,例如,我们创建一个 [0, 1, ... 8, 9] 的序列:

1

let numbers: Observable<Int> = Observable.create { observer -> Disposable in

observer.onNext(0)
observer.onNext(1)
observer.onNext(2)
observer.onNext(3)
observer.onNext(4)
observer.onNext(5)
observer.onNext(6)
observer.onNext(7)
observer.onNext(8)
observer.onNext(9)
observer.onCompleted()

return Disposables.create()
}

创建序列最直接的方法就是调用 Observable.create,然后在构建函数里面描述元素的产生过程。 observer.onNext(0) 就代表产生了一个元素,他的值是 0。后面又产生了 9 个元素分别是 1, 2, ... 8, 9 。最后,用 observer.onCompleted() 表示元素已经全部产生,没有更多元素了。

你可以用这种方式来封装功能组件,例如,闭包回调:

1

typealias JSON = Any

let json: Observable<JSON> = Observable.create { (observer) -> Disposable in

let task = URLSession.shared.dataTask(with: ...) { data, _, error in

guard error == nil else {
observer.onError(error!)
return
}

guard let data = data,
let jsonObject = try? JSONSerialization.jsonObject(with: data, options: .mutableLeaves)
else {
observer.onError(DataError.cantParseJSON)
return
}

observer.onNext(jsonObject)
observer.onCompleted()
}

task.resume()

return Disposables.create { task.cancel() }
}

在闭包回调中,如果任务失败,就调用 observer.onError(error!)。如果获取到目标元素,就调用 observer.onNext(jsonObject)。由于我们的这个序列只有一个元素,所以在成功获取到元素后,就直接调用 observer.onCompleted() 来表示任务结束。最后 Disposables.create { task.cancel() } 说明如果数据绑定被清除(订阅被取消)的话,就取消网络请求。

这样一来我们就将传统的闭包回调转换成序列了。然后可以用 subscribe 方法来响应这个请求的结果:

json
.subscribe(onNext: { json in
print("取得 json 成功: \(json)")
}, onError: { error in
print("取得 json 失败 Error: \(error.localizedDescription)")
}, onCompleted: {
print("取得 json 任务成功完成")
})
.disposed(by: disposeBag)

这里subscribe后面的onNext,onErroronCompleted 分别响应我们创建 json 时,构建函数里面的onNext,onErroronCompleted 事件。我们称这些事件为 Event:

Event - 事件

public enum Event<Element> {
case next(Element)
case error(Swift.Error)
case completed
}
  • next - 序列产生了一个新的元素
  • error - 创建序列时产生了一个错误,导致序列终止
  • completed - 序列的所有元素都已经成功产生,整个序列已经完成

你可以合理的利用这些 Event 来实现业务逻辑。

决策树

现在我们知道如何用最基本的方法创建序列。你还可参考 决策树 来选择其他的方式创建序列。

特征序列

我们都知道 Swift 是一个强类型语言,而强类型语言相对于弱类型语言的一个优点是更加严谨。我们可以通过类型来判断出,实例有哪些特征。同样的在 RxSwift 里面 Observable 也存在一些特征序列,这些特征序列可以帮助我们更准确的描述序列。并且它们还可以给我们提供语法糖,让我们能够用更加优雅的方式书写代码,他们分别是:

ℹ️ 提示:由于可被观察的序列(Observable)名字过长,很多时候会增加阅读难度,所以笔者在必要时会将它简写为:序列

收起阅读 »

iOS RXSwift 4

iOS
数据绑定(订阅)在 RxSwift 里有一个比较重要的概念就是数据绑定(订阅)。就是指将可监听序列绑定到观察者上:我们对比一下这两段代码:let image: UIImage = UIImage(named: ...) imageView....
继续阅读 »

数据绑定(订阅)

在 RxSwift 里有一个比较重要的概念就是数据绑定(订阅)。就是指将可监听序列绑定到观察者上:

我们对比一下这两段代码:

let image: UIImage = UIImage(named: ...)
imageView.image = image
let image: Observable<UIImage> = ...
image.bind(to: imageView.rx.image)

第一段代码我们非常熟悉,它就是将一个单独的图片设置到imageView上。

第二段代码则是将一个图片序列 “同步” 到imageView上。这个序列里面的图片可以是异步产生的。这里定义的 image 就是上图中蓝色部分(可监听序列),imageView.rx.image就是上图中橙色部分(观察者)。而这种 “同步机制” 就是数据绑定(订阅)

RxSwift 核心

这一章主要介绍 RxSwift 的核心内容:

// Observable<String>
let text = usernameOutlet.rx.text.orEmpty.asObservable()

// Observable<Bool>
let passwordValid = text
// Operator
.map { $0.characters.count >= minimalUsernameLength }

// Observer<Bool>
let observer = passwordValidOutlet.rx.isHidden

// Disposable
let disposable = passwordValid
// Scheduler 用于控制任务在那个线程队列运行
.subscribeOn(MainScheduler.instance)
.observeOn(MainScheduler.instance)
.bind(to: observer)


...

// 取消绑定,你可以在退出页面时取消绑定
disposable.dispose()

下面几节会详细介绍这几个组件的功能和用法。

ℹ️ 提示:这一章主要介绍一些偏理论方面的知识。你如果觉得阅读起来比较乏味的话,可以先快速地浏览一遍,了解 RxSwift 的核心组件大概有哪些内容。待以后遇到实际问题时,在回来查询。你可以直接跳到 更多例子 章节,去了解如何应用 RxSwift


收起阅读 »

ViewPager2&TabLayout:拓展出一个文本选中放大效果

ViewPager2正式推出已经一年多了,虽然不如3那样新潮,但是也不如老前辈ViewPager那样有众多开源库拥簇,比如它的灵魂伴侣TabLayout明显后援不足,好在TabLayout自身够硬! ViewPager2灵魂伴侣是官方提供的: com.goog...
继续阅读 »

ViewPager2正式推出已经一年多了,虽然不如3那样新潮,但是也不如老前辈ViewPager那样有众多开源库拥簇,比如它的灵魂伴侣TabLayout明显后援不足,好在TabLayout自身够硬!


ViewPager2灵魂伴侣是官方提供的:


com.google.android.material.tabs.TabLayout

TabLayout 利用其良好的设计,使得自定义非常容易。


像匹配ViewPager的优秀开源库FlycoTabLayout的效果,使用TabLayout都能比较容易的实现:


FlycoTabLayout 演示


image.png


实现上图中的几个常用效果TabLayout 仅需在xml重配置即可


tablayout.gif


不过稍微不同的是,上图中第二第三栏选中后的字体是有放大效果的。


这是利用TabLayout.TabcustomView属性达到的。下文便是实现的思路与过程记录。


正文


思路拆解:



  • 介于此功能耦合点仅仅是TabLayoutMediator,选择使用拓展包装TabLayoutMediator,轻量且无侵入性,API还便捷

  • 自定义TabLayoutMediator,设置customView,放入自己的TextView

  • 内部自动添加一个addOnTabSelectedListener,在选中后使用动画渐进式的改变字体大小,同理取消选中时还原


解决过的坑:



  • TextView的文本在Size改变时,宽度动态变化,调用requestLayout()。Tab栏会因此触发重新测量与重绘,出现短促闪烁。塞两个TextView,一个作为最大边界并且设置INVISIBLE

  • 同样是重测问题,导致TabLayout额外多从头绘制一次Indicator时,直观表现就是每次切换Indicator时,会出现闪现消失。采用自定义了一个ScaleTexViewTabView,动态控制是否触发super.requestLayout


(因为已经准备了两个View,负责展示效果的View最大范围是明确无法超过既定范围的,所以这个办法不算“黑”)




  • 核心API:





fun <T : View> TabLayout.createMediatorByCustomTabView(
vp: ViewPager2,
config: CustomTabViewConfig<T>
): TabLayoutMediator {
return TabLayoutMediator(this, vp) { tab, pos ->
val tabView = config.getCustomView(tab.view.context)
tab.customView = tabView
config.onTabInit(tabView, pos)
}
}

fun TabLayout.createTextScaleMediatorByTextView(
vp: ViewPager2,
config: TextScaleTabViewConfig
): TabLayoutMediator {

val mediator = createMediatorByCustomTabView(vp, config)
...
...
return mediator
}



  • 使用:




val mediator = tabLayout.createTextScaleMediatorByTextView(viewPager2,
object : TextScaleTabViewConfig(scaleConfig) {
override fun onBoundTextViewInit(boundSizeTextView: TextView, position: Int) {
boundSizeTextView.textSizePx = scaleConfig.onSelectTextSize
boundSizeTextView.text = tabs[position]
}
override fun onVisibleTextViewInit(dynamicSizeTextView: TextView, position: Int) {
dynamicSizeTextView.setTextColor(Color.WHITE)
dynamicSizeTextView.text = tabs[position]
}
})
mediator.attach()

整个代码去除通用拓展不过100行左右,不过鉴于其独立性还是要单独发布到基础组件库中。这样组件库中就有两个是单文件的组件了哈哈~


点击直达完整源码~,拷贝即用


END


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

JavaScript实现2048小游戏,我终于赢了一把

效果图 实现思路 编写页面和画布代码。 绘制背景。 绘制好全部卡片。 随机生成一个卡片(2或者4)。 键盘事件监听(上、下、左、右键监听)。 根据键盘的方向,处理数字的移动合并。 加入成功、失败判定。 处理其他收尾工作。 代码实现编写页面代码 <...
继续阅读 »

效果图


在这里插入图片描述


实现思路



  1. 编写页面和画布代码。

  2. 绘制背景。

  3. 绘制好全部卡片。

  4. 随机生成一个卡片(2或者4)。

  5. 键盘事件监听(上、下、左、右键监听)。

  6. 根据键盘的方向,处理数字的移动合并。

  7. 加入成功、失败判定。

  8. 处理其他收尾工作。


代码实现

编写页面代码



<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2048</title>
<style>
#box{
width:370px;
height:370px;
position:absolute;
margin:0 auto;
left:0;
right:0;
top:1px;
bottom:0;
}

.rebutton{
position: absolute;
top:370px;
left:38%;
}
</style>
</head>
<body>
<div id='box'></div>
<button onclick="restart()" class='rebutton'>重开</button>
</body>
<script src="js/util.js"></script>
<script src="js/2048.js"></script>
<script type="text/javascript">

</script>
</html>

复制代码

添加画布


在2048.js编写代码



  1. 创建函数


function G2048(){
this.renderArr=[];//渲染数组
this.cards=initCardArray();
//游戏标记
this.flag='start';
}
//初始化数组
function initCardArray(){
var cards = new Array();
for (var i = 0; i < 4; i++) {
cards[i] = new Array();
for (var j = 0; j < 4; j++) {
//cards[i][j]=null;
}
}
return cards;
}


  1. 初始化和绘制背景代码(在2048.js中编写)


//初始化
G2048.prototype.init=function(el,musicObj){
if(!el) return ;
this.el=el;
var canvas = document.createElement('canvas');//创建画布
canvas.style.cssText="background:white;";
var W = canvas.width = 370; //设置宽度
var H = canvas.height = 370;//设置高度

el.appendChild(canvas);//添加到指定的dom对象中
this.ctx = canvas.getContext('2d');

this.draw();
}
//绘制入口
G2048.prototype.draw=function(){
//创建背景
this.drawGB();

//渲染到页面上
this.render();

}

//创建背景
G2048.prototype.drawGB=function(){
var bg = new _.Rect({x:0,y:0,width:364,height:364,fill:true,fillStyle:'#428853'});
this.renderArr.push(bg);
}

//渲染图形
G2048.prototype.render=function(){
var context=this.ctx;
this.clearCanvas();
_.each(this.renderArr,function(item){
item && item.render(context);
});
}
//清洗画布
G2048.prototype.clearCanvas=function() {
this.ctx.clearRect(0,0,parseInt(this.w),parseInt(this.h));
}


  1. 在页面代码中加入以下 js 代码


var box = document.getElementById('box');
g2048.init(box);

在这里插入图片描述
运行效果:
在这里插入图片描述


绘制好全部卡片



  1. 创建Card


//定义Card
function Card(i,j){
this.i=i;//下标i
this.j=j;//下标j
this.x=0;// x坐标
this.y=0;// y坐标
this.h=80;//高
this.w=80;//宽
this.start=10;//偏移量(固定值)
this.num=0;//显示数字
this.merge=false;//当前是否被合并过,如果合并了,则不能继续合并,针对当前轮
//初始化创建
this.obj = this.init();
//创建显示数字对象
this.numText = this.initNumText();
}
//初始创建
Card.prototype.init=function(){
return new _.Rect({x:this.x,y:this.y,width:this.w,height:this.h,fill:true});
}
//根据i j计算x y坐标
Card.prototype.cal=function(){
this.x = this.start + this.j*this.w + (this.j+1)*5;
this.y = this.start + this.i*this.h + (this.i+1)*5;
//更新给obj
this.obj.x=this.x;
this.obj.y=this.y;
//设置填充颜色
this.obj.fillStyle=this.getColor();

//更新文字的位置
this.numText.x = this.x+40;
this.numText.y = this.y+55;
this.numText.text=this.num;
}
//初始化显示数字对象
Card.prototype.initNumText=function(){
var font = "34px 思源宋体";
var fillStyle = "#7D4E33";
return new _.Text({x:this.x,y:this.y+50,text:this.num,fill:true,textAlign:'center',font:font,fillStyle:fillStyle});
}
//获取color
Card.prototype.getColor=function(){
var color;
//根据num设定颜色
switch (this.num) {
case 2:
color = "#EEF4EA";
break;
case 4:
color = "#DEECC8";
break;
case 8:
color = "#AED582";
break;
case 16:
color = "#8EC94B";
break;
case 32:
color = "#6F9430";
break;
case 64:
color = "#4CAE7C";
break;
case 128:
color = "#3CB490";
break;
case 256:
color = "#2D8278";
break;
case 512:
color = "#09611A";
break;
case 1024:
color = "#F2B179";
break;
case 2048:
color = "#DFB900";
break;

default://默认颜色
color = "#5C9775";
break;
}

return color;
}

Card.prototype.render=function(context){
//计算坐标等
this.cal();
//执行绘制
this.obj.render(context);
//是否绘制文字的处理
if(this.num!=0){
this.numText.render(context);
}
}

}


  1. 创建卡片


	//创建卡片
G2048.prototype.drawCard=function(){
var that=this;
var card;
for (var i = 0; i < 4; i++) {
for (var j = 0; j < 4; j++) {
card = new Card(i,j);
that.cards[i][j]=card;
that.renderArr.push(card);
}
}
}


  1. 调用绘制代码


在这里插入图片描述
运行效果:
在这里插入图片描述
4. 修改一下卡片的默认数字
在这里插入图片描述


在这里插入图片描述


随机生成一个卡片,2或者4




  1. 先把Card中 num 默认改成0

  2. 因为2跟4出现的比例是1:4,所以采用随机出1-5的数字,当是1的时候就表示,当得到2、3、4、5的时候就表示要出现数字2.

  3. 随机获取i,j 就可以得到卡片的位置,割接i,j取到card实例,如果卡片没有数字,就表示可以,否则就递归继续取,取到为止。

  4. 把刚才取到的数字,设置到card实例对象中就好了。



代码如下:


//随机创建一个卡片
G2048.prototype.createRandomNumber=function(){
var num = 0;
var index = _.getRandom(1,6);//这样取出来的就是1-5 之间的随机数
//因为2和4出现的概率是1比4,所以如果index是1,则创建数字4,否则创建数字2(1被随机出来的概率就是1/5,而其他就是4/5 就是1:4的关系)
console.log('index==='+index)
if(index==1){
num = 4;
}else {
num = 2;
}
//判断如果格子已经满了,则不再获取,退出
if(this.cardFull()){
return ;
}
//获取随机卡片,不为空的
var card = this.getRandomCard();
//给card对象设置数字
if(card!=null){
card.num=num;
}
}
//获取随机卡片,不为空的
G2048.prototype.getRandomCard=function(){
var i = _.getRandom(0,4);
var j = _.getRandom(0,4);
var card = this.cards[i][j];
if(card.num==0){//如果是空白的卡片,则找到了,直接返回
return card;
}
//没找到空白的,就递归,继续寻找
return this.getRandomCard();
}
//判断格子满了
G2048.prototype.cardFull=function() {
var card;
for (var i = 0; i < 4; i++) {
for (var j = 0; j < 4; j++) {
card = this.cards[i][j];
if(card.num==0){//有一个为空,则没满
return false;
}
}
}
return true;
}

draw方法中调用,表示打开游戏默认一个数字
在这里插入图片描述
运行效果:
在这里插入图片描述


加入键盘事件


同样要在draw方法中调用哦


	//按键的控制
G2048.prototype.control=function(){
var that=this;
global.addEventListener('keydown',function(e){
console.log(that.flag)
if(that.flag!='start') return ;
var dir;
switch (e.keyCode){
case 87://w
case 38://上
dir=1;//上移动
break;
case 68://d
case 39://右
dir=2;//右移动
break;
case 83://s
case 40://下
dir=3;//下移动
break;
case 65://a
case 37://左
dir=4;//左移动
break;
}
//卡片移动的方法
that.moveCard(dir);
});
}


  1. 加入移动逻辑处理代码


//卡片移动的方法
G2048.prototype.moveCard=function(dir) {
//将卡片清理一遍,因为每轮移动会设定合并标记,需重置
this.clearCard();

if(dir==1){//向上移动
this.moveCardTop(true);
}else if(dir==2){//向右移动
this.moveCardRight(true);
}else if(dir==3){//向下移动
this.moveCardBottom(true);
}else if(dir==4){//向左移动
this.moveCardLeft(true);
}
//移动后要创建新的卡片
this.createRandomNumber();
//重绘
this.render();
//判断游戏是否结束
this.gameOverOrNot();
}

//将卡片清理一遍,因为每轮移动会设定合并标记,需重置
G2048.prototype.clearCard=function() {
var card;
for (var i = 0; i < 4; i++) {//i从1开始,因为i=0不需要移动
for (var j = 0; j < 4; j++) {
card = this.cards[i][j];
card.merge=false;
}
}
}


  1. 加入上下左右处理逻辑


//向上移动
G2048.prototype.moveCardTop=function(bool) {
var res = false;
var card;
for (var i = 1; i < 4; i++) {//i从1开始,因为i=0不需要移动
for (var j = 0; j < 4; j++) {
card = this.cards[i][j];
if(card.num!=0){//只要卡片不为空,要移动
if(card.moveTop(this.cards,bool)){//向上移动
res = true;//有一个为移动或者合并了,则res为true
}
}
}
}
return res;
}
//向右移动
G2048.prototype.moveCardRight=function(bool) {
var res = false;
var card;
for (var i = 0; i < 4; i++) {
for (var j = 3; j >=0 ; j--) {//j从COLS-1开始,从最右边开始移动递减
card = this.cards[i][j];
if(card.num!=0){//只要卡片不为空,要移动
if(card.moveRight(this.cards,bool)){//向右移动
res = true;//有一个为移动或者合并了,则res为true
}
}
}
}
return res;
}

//向下移动
G2048.prototype.moveCardBottom=function(bool) {
var res = false;
var card;
for (var i = 3; i >=0; i--) {//i从ROWS-1开始,往下递减移动
for (var j = 0; j < 4; j++) {
card = this.cards[i][j];
if(card.num!=0){//只要卡片不为空,要移动
if(card.moveBottom(this.cards,bool)){//下移动
res = true;//有一个为移动或者合并了,则res为true
}
}
}
}
return res;
}

//向左移动
G2048.prototype.moveCardLeft=function(bool) {
var res = false;
var card;
for (var i = 0; i < 4; i++) {
for (var j = 1; j < 4 ; j++) {//j从1开始,从最左边开始移动
card = this.cards[i][j];
if(card.num!=0){//只要卡片不为空,要移动
if(card.moveLeft(this.cards,bool)){//向左移动
res = true;//有一个为移动或者合并了,则res为true
}
}
}
}
return res;
}


  1. 在Card中加入向上移动的处理逻辑




  1. 从第2行开始移动,因为第一行不需要移动。

  2. 只要卡片的数字不是0,就表示要移动。

  3. 根据 i-1 可以获取到上一个卡片,如果上一个卡片是空,则把当前卡片交换上去,并且递归,因为可能要继续往上移动。

  4. 如果当前卡片与上一个卡片是相同数字的,则要合并。

  5. 以上两种都不是,则不做操作。



//卡片向上移动
Card.prototype.moveTop=function(cards,bool) {
var i=this.i;
var j=this.j;
//设定退出条件
if(i==0){//已经是最上面了
return false;
}
//上面一个卡片
var prev = cards[i-1][j];
if(prev.num==0){//上一个卡片是空
//移动,本质就是设置数字
if(bool){//bool为true才执行,因为flase只是用来判断能否移动
prev.num=this.num;
this.num=0;
//递归操作(注意这里是要 prev 来 move了)
prev.moveTop(cards,bool);
}
return true;
}else if(prev.num==this.num && !prev.merge){//合并操作(如果已经合并了,则不运行再次合并,针对当然轮)
if(bool){////bool为true才执行
prev.merge=true;
prev.num=this.num*2;
this.num=0;
}
return true;
}else {//上一个的num与当前num不同,无法移动,并退出
return false;
}
}

在这里插入图片描述



  1. 在Card中加入其他3个方向的代码


//向下移动
Card.prototype.moveBottom=function(cards,bool) {
var i=this.i;
var j=this.j;
//设定退出条件
if(i==3){//已经是最下面了
return false;
}
//上面一个卡片
var prev = cards[i+1][j];
if(prev.num==0){//上一个卡片是空
//移动,本质就是设置数字
if(bool){//bool为true才执行,因为flase只是用来判断能否移动
prev.num=this.num;
this.num=0;
//递归操作(注意这里是要 prev 来 move了)
prev.moveBottom(cards,bool);
}
return true;
}else if(prev.num==this.num && !prev.merge){//合并操作(如果已经合并了,则不运行再次合并,针对当然轮)
if(bool){////bool为true才执行
prev.merge=true;
prev.num=this.num*2;
this.num=0;
}
return true;
}else {//上一个的num与当前num不同,无法移动,并退出
return false;
}


}
//向右移动
Card.prototype.moveRight=function(cards,bool) {
var i=this.i;
var j=this.j;
//设定退出条件
if(j==3){//已经是最右边了
return false;
}
//上面一个卡片
var prev = cards[i][j+1];
if(prev.num==0){//上一个卡片是空
//移动,本质就是设置数字
if(bool){//bool为true才执行,因为flase只是用来判断能否移动
prev.num=this.num;
this.num=0;
//递归操作(注意这里是要 prev 来 move了)
prev.moveRight(cards,bool);
}
return true;
}else if(prev.num==this.num && !prev.merge){//合并操作(如果已经合并了,则不运行再次合并,针对当然轮)
if(bool){////bool为true才执行
prev.merge=true;
prev.num=this.num*2;
this.num=0;
}
return true;
}else {//上一个的num与当前num不同,无法移动,并退出
return false;
}
}
//向左移动
Card.prototype.moveLeft=function(cards,bool) {
var i=this.i;
var j=this.j;
//设定退出条件
if(j==0){//已经是最左边了
return false;
}
//上面一个卡片
var prev = cards[i][j-1];
if(prev.num==0){//上一个卡片是空
//移动,本质就是设置数字
if(bool){//bool为true才执行,因为flase只是用来判断能否移动
prev.num=this.num;
this.num=0;
//递归操作(注意这里是要 prev 来 move了)
prev.moveLeft(cards,bool);
}
return true;
}else if(prev.num==this.num && !prev.merge){//合并操作(如果已经合并了,则不运行再次合并,针对当然轮)
if(bool){////bool为true才执行
prev.merge=true;
prev.num=this.num*2;
this.num=0;
}
return true;
}else {//上一个的num与当前num不同,无法移动,并退出
return false;
}
}

运行效果:
在这里插入图片描述


做到这里就基本完成了,加入其他一下辅助的东西就行了,比如重新开始、游戏胜利,游戏结束等,也就不多说了。


收起阅读 »

js 实现以鼠标位置为中心滚轮缩放图片

前言 不知道各位前端小伙伴蓝湖使用的多不多,反正我是经常在用,ui将原型图设计好后上传至蓝湖,前端开发人人员就可以开始静态页面的的编写了。对于页面细节看的不是很清楚可以使用滚轮缩放后再拖拽查看,还是很方便的。于是就花了点时间研究了一下。今天分享给大家。 实现 ...
继续阅读 »

前言


不知道各位前端小伙伴蓝湖使用的多不多,反正我是经常在用,ui将原型图设计好后上传至蓝湖,前端开发人人员就可以开始静态页面的的编写了。对于页面细节看的不是很清楚可以使用滚轮缩放后再拖拽查看,还是很方便的。于是就花了点时间研究了一下。今天分享给大家。


实现


HTML


<div class="container">
<img id="image" alt="">
</div>
<div class="log"></div>

js


设置图片宽高且居中展示


// 获取dom
const container = document.querySelector('.container');
const image = document.getElementById('image');
const log = document.querySelector('.log');
// 全局变量
let result,
x,
y,
scale = 1,
isPointerdown = false, // 按下标识
point = { x: 0, y: 0 }, // 第一个点坐标
diff = { x: 0, y: 0 }, // 相对于上一次pointermove移动差值
lastPointermove = { x: 0, y: 0 }; // 用于计算diff
// 图片加载完成后再绑定事件
image.addEventListener('load', function () {
result = getImgSize(image.naturalWidth, image.naturalHeight, window.innerWidth, window.innerHeight);
image.style.width = result.width + 'px';
image.style.height = result.height + 'px';
x = (window.innerWidth - result.width) * 0.5;
y = (window.innerHeight - result.height) * 0.5;
image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(1)';
// 拖拽查看
drag();
// 滚轮缩放
wheelZoom();
});
image.src = '../images/liya.jpg';
/**
* 获取图片缩放尺寸
* @param {number} naturalWidth
* @param {number} naturalHeight
* @param {number} maxWidth
* @param {number} maxHeight
* @returns
*/
function getImgSize(naturalWidth, naturalHeight, maxWidth, maxHeight) {
const imgRatio = naturalWidth / naturalHeight;
const maxRatio = maxWidth / maxHeight;
let width, height;
// 如果图片实际宽高比例 >= 显示宽高比例
if (imgRatio >= maxRatio) {
if (naturalWidth > maxWidth) {
width = maxWidth;
height = maxWidth / naturalWidth * naturalHeight;
} else {
width = naturalWidth;
height = naturalHeight;
}
} else {
if (naturalHeight > maxHeight) {
width = maxHeight / naturalHeight * naturalWidth;
height = maxHeight;
} else {
width = naturalWidth;
height = naturalHeight;
}
}
return { width: width, height: height }
}

拖拽查看图片逻辑


// 拖拽查看
function drag() {
// 绑定 pointerdown
image.addEventListener('pointerdown', function (e) {
isPointerdown = true;
image.setPointerCapture(e.pointerId);
point = { x: e.clientX, y: e.clientY };
lastPointermove = { x: e.clientX, y: e.clientY };
});
// 绑定 pointermove
image.addEventListener('pointermove', function (e) {
if (isPointerdown) {
const current1 = { x: e.clientX, y: e.clientY };
diff.x = current1.x - lastPointermove.x;
diff.y = current1.y - lastPointermove.y;
lastPointermove = { x: current1.x, y: current1.y };
x += diff.x;
y += diff.y;
image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(' + scale + ')';
log.innerHTML = `x = ${x.toFixed(0)}<br>y = ${y.toFixed(0)}<br>scale = ${scale.toFixed(5)}`;
}
e.preventDefault();
});
// 绑定 pointerup
image.addEventListener('pointerup', function (e) {
if (isPointerdown) {
isPointerdown = false;
}
});
// 绑定 pointercancel
image.addEventListener('pointercancel', function (e) {
if (isPointerdown) {
isPointerdown = false;
}
});
}

滚轮缩放逻辑


// 滚轮缩放
function wheelZoom() {
container.addEventListener('wheel', function (e) {
let ratio = 1.1;
// 缩小
if (e.deltaY > 0) {
ratio = 0.9;
}
// 目标元素是img说明鼠标在img上,以鼠标位置为缩放中心,否则默认以图片中心点为缩放中心
if (e.target.tagName === 'IMG') {
const origin = {
x: (ratio - 1) * result.width * 0.5,
y: (ratio - 1) * result.height * 0.5
};
// 计算偏移量
x -= (ratio - 1) * (e.clientX - x) - origin.x;
y -= (ratio - 1) * (e.clientY - y) - origin.y;
}
scale *= ratio;
image.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0) scale(' + scale + ')';
log.innerHTML = `x = ${x.toFixed(0)}<br>y = ${y.toFixed(0)}<br>scale = ${scale.toFixed(5)}`;
e.preventDefault();
});
}

Demo:jsdemo.codeman.top/html/wheelZ…



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

收起阅读 »

深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别

vue
因为 Diff 算法,计算的就是虚拟 DOM 的差异,所以先铺垫一点点虚拟 DOM,了解一下其结构,再来一层层揭开 Diff 算法的面纱,深入浅出,助你彻底弄懂 Diff 算法原理 认识虚拟 DOM 虚拟 DOM 简单说就是 用JS对象来模拟 DOM 结构 那...
继续阅读 »

因为 Diff 算法,计算的就是虚拟 DOM 的差异,所以先铺垫一点点虚拟 DOM,了解一下其结构,再来一层层揭开 Diff 算法的面纱,深入浅出,助你彻底弄懂 Diff 算法原理


认识虚拟 DOM


虚拟 DOM 简单说就是 用JS对象来模拟 DOM 结构


那它是怎么用 JS 对象模拟 DOM 结构的呢?看个例子


<template>
<div id="app" class="container">
<h1>沐华</h1>
</div>
</template>

上面的模板转在虚拟 DOM 就是下面这样的


{
'div',
props:{ id:'app', class:'container' },
children: [
{ tag: 'h1', children:'沐华' }
]
}

这样的 DOM 结构就称之为 虚拟 DOM (Virtual Node),简称 vnode


它的表达方式就是把每一个标签都转为一个对象,这个对象可以有三个属性:tagpropschildren



  • tag:必选。就是标签。也可以是组件,或者函数

  • props:非必选。就是这个标签上的属性和方法

  • children:非必选。就是这个标签的内容或者子节点,如果是文本节点就是字符串,如果有子节点就是数组。换句话说 如果判断 children 是字符串的话,就表示一定是文本节点,这个节点肯定没有子元素


为什么要使用虚拟 DOM 呢? 看个图


image.png


如图可以看出原生 DOM 有非常多的属性和事件,就算是创建一个空div也要付出不小的代价。而使用虚拟 DOM 来提升性能的点在于 DOM 发生变化的时候,通过 diff 算法和数据改变前的 DOM 对比,计算出需要更改的 DOM,然后只对变化的 DOM 进行操作,而不是更新整个视图


在 Vue 中是怎么把 DOM 转成上面这样的虚拟 DOM 的呢,有兴趣的可以关注我另一篇文章详细了解一下 Vue 中的模板编译过程和原理


在 Vue 里虚拟 DOM 的数据更新机制采用的是异步更新队列,就是把变更后的数据变装入一个数据更新的异步队列,就是 patch,用它来做新老 vnode 对比


认识 Diff 算法


Diff 算法,在 Vue 里面就是叫做 patch ,它的核心就是参考 Snabbdom,通过新旧虚拟 DOM 对比(即 patch 过程),找出最小变化的地方转为进行 DOM 操作



扩展

在 Vue1 里是没有 patch 的,每个依赖都有单独的 Watcher 负责更新,当项目规模变大的时候性能就跟不上了,所以在 Vue2 里为了提升性能,改为每个组件只有一个 Watcher,那我们需要更新的时候,怎么才能精确找到组件里发生变化的位置呢?所以 patch 它来了



那么它是在什么时候执行的呢?


在页面首次渲染的时候会调用一次 patch 并创建新的 vnode,不会进行更深层次的比较


然后是在组件中数据发生变化时,会触发 setter 然后通过 Notify 通知 Watcher,对应的 Watcher 会通知更新并执行更新函数,它会执行 render 函数获取新的虚拟 DOM,然后执行 patch 对比上次渲染结果的老的虚拟 DOM,并计算出最小的变化,然后再去根据这个最小的变化去更新真实的 DOM,也就是视图


那么它是怎么计算的? 先看个图


diff.jpg


比如有上图这样的 DOM 结构,是怎么计算出变化?简单说就是



  • 遍历老的虚拟 DOM

  • 遍历新的虚拟 DOM

  • 然后根据变化,比如上面的改变和新增,再重新排序


可是这样会有很大问题,假如有1000个节点,就需要计算 1000³ 次,也就是10亿次,这样是无法让人接受的,所以 Vue 或者 React 里使用 Diff 算法的时候都遵循深度优先,同层比较的策略做了一些优化,来计算出最小变化


Diff 算法的优化


1. 只比较同一层级,不跨级比较


如图,Diff 过程只会把同颜色框起来的同一层级的 DOM 进行比较,这样来简化比较次数,这是第一个方面


diff1.jpg


2. 比较标签名


如果同一层级的比较标签名不同,就直接移除老的虚拟 DOM 对应的节点,不继续按这个树状结构做深度比较,这是简化比较次数的第二个方面


diff2.jpg


3. 比较 key


如果标签名相同,key 也相同,就会认为是相同节点,也不继续按这个树状结构做深度比较,比如我们写 v-for 的时候会比较 key,不写 key 就会报错,这也就是因为 Diff 算法需要比较 key


面试中有一道特别常见的题,就是让你说一下 key 的作用,实际上考查的就是大家对虚拟 DOM 和 patch 细节的掌握程度,能够反应出我们面试者的理解层次,所以这里扩展一下 key


key 的作用


比如有一个列表,我们需要在中间插入一个元素,会发生什么变化呢?先看个图


diff3.jpg


如图的 li1li2 不会重新渲染,这个没有争议的。而 li3、li4、li5 都会重新渲染


因为在不使用 key 或者列表的 index 作为 key 的时候,每个元素对应的位置关系都是 index,上图中的结果直接导致我们插入的元素到后面的全部元素,对应的位置关系都发生了变更,所以全部都会执行更新操作,这可不是我们想要的,我们希望的是渲染添加的那一个元素,其他四个元素不做任何变更,也就不要重新渲染


而在使用唯一 key 的情况下,每个元素对应的位置关系就是 key,来看一下使用唯一 key 值的情况下


diff4.jpg


这样如图中的 li3li4 就不会重新渲染,因为元素内容没发生改变,对应的位置关系也没有发生改变。


这也是为什么 v-for 必须要写 key,而且不建议开发中使用数组的 index 作为 key 的原因


总结一下:



  • key 的作用主要是为了更高效的更新虚拟 DOM,因为它可以非常精确的找到相同节点,因此 patch 过程会非常高效

  • Vue 在 patch 过程中会判断两个节点是不是相同节点时,key 是一个必要条件。比如渲染列表时,如果不写 key,Vue 在比较的时候,就可能会导致频繁更新元素,使整个 patch 过程比较低效,影响性能

  • 应该避免使用数组下标作为 key,因为 key 值不是唯一的话可能会导致上面图中表示的 bug,使 Vue 无法区分它他,还有比如在使用相同标签元素过渡切换的时候,就会导致只替换其内部属性而不会触发过渡效果

  • 从源码里可以知道,Vue 判断两个节点是否相同时主要判断两者的元素类型和 key 等,如果不设置 key,就可能永远认为这两个是相同节点,只能去做更新操作,就造成大量不必要的 DOM 更新操作,明显是不可取的


有兴趣的可以去看一下源码:src\core\vdom\patch.js -35行 sameVnode(),下面也有详细介绍


Diff 算法核心原理——源码


上面说了Diff 算法,在 Vue 里面就是 patch,铺垫了这么多,下面进入源码里看一下这个神乎其神的 patch 干了啥?


patch


其实 patch 就是一个函数,我们先介绍一下源码里的核心流程,再来看一下 patch 的源码,源码里每一行也有注释


它可以接收四个参数,主要还是前两个



  • oldVnode:老的虚拟 DOM 节点

  • vnode:新的虚拟 DOM 节点

  • hydrating:是不是要和真实 DOM 混合,服务端渲染的话会用到,这里不过多说明

  • removeOnly:transition-group 会用到,这里不过多说明


主要流程是这样的:



  • vnode 不存在,oldVnode 存在,就删掉 oldVnode

  • vnode 存在,oldVnode 不存在,就创建 vnode

  • 两个都存在的话,通过 sameVnode 函数(后面有详解)对比是不是同一节点

    • 如果是同一节点的话,通过 patchVnode 进行后续对比节点文本变化或子节点变化

    • 如果不是同一节点,就把 vnode 挂载到 oldVnode 的父元素下

      • 如果组件的根节点被替换,就遍历更新父节点,然后删掉旧的节点

      • 如果是服务端渲染就用 hydrating 把 oldVnode 和真实 DOM 混合






下面看完整的 patch 函数源码,说明我都写在注释里了


源码地址:src\core\vdom\patch.js -700行


// 两个判断函数
function isUndef (v: any): boolean %checks {
return v === undefined || v === null
}
function isDef (v: any): boolean %checks {
return v !== undefined && v !== null
}
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 如果新的 vnode 不存在,但是 oldVnode 存在
if (isUndef(vnode)) {
// 如果 oldVnode 存在,调用 oldVnode 的组件卸载钩子 destroy
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}

let isInitialPatch = false
const insertedVnodeQueue = []

// 如果 oldVnode 不存在的话,新的 vnode 是肯定存在的,比如首次渲染的时候
if (isUndef(oldVnode)) {
isInitialPatch = true
// 就创建新的 vnode
createElm(vnode, insertedVnodeQueue)
} else {
// 剩下的都是新的 vnode 和 oldVnode 都存在的话

// 是不是元素节点
const isRealElement = isDef(oldVnode.nodeType)
// 是元素节点 && 通过 sameVnode 对比是不是同一个节点 (函数后面有详解)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 如果是 就用 patchVnode 进行后续对比 (函数后面有详解)
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 如果不是同一元素节点的话
if (isRealElement) {
// const SSR_ATTR = 'data-server-rendered'
// 如果是元素节点 并且有 'data-server-rendered' 这个属性
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
// 就是服务端渲染的,删掉这个属性
oldVnode.removeAttribute(SSR_ATTR)
hydrating = true
}
// 这个判断里是服务端渲染的处理逻辑,就是混合
if (isTrue(hydrating)) {
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true)
return oldVnode
} else if (process.env.NODE_ENV !== 'production') {
warn('这是一段很长的警告信息')
}
}
// function emptyNodeAt (elm) {
// return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
// }
// 如果不是服务端渲染的,或者混合失败,就创建一个空的注释节点替换 oldVnode
oldVnode = emptyNodeAt(oldVnode)
}

// 拿到 oldVnode 的父节点
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)

// 根据新的 vnode 创建一个 DOM 节点,挂载到父节点上
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)

// 如果新的 vnode 的根节点存在,就是说根节点被修改了,就需要遍历更新父节点
if (isDef(vnode.parent)) {
let ancestor = vnode.parent
const patchable = isPatchable(vnode)
// 递归更新父节点下的元素
while (ancestor) {
// 卸载老根节点下的全部组件
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor)
}
// 替换现有元素
ancestor.elm = vnode.elm
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor)
}
const insert = ancestor.data.hook.insert
if (insert.merged) {
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]()
}
}
} else {
registerRef(ancestor)
}
// 更新父节点
ancestor = ancestor.parent
}
}
// 如果旧节点还存在,就删掉旧节点
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
// 否则直接卸载 oldVnode
invokeDestroyHook(oldVnode)
}
}
}
// 返回更新后的节点
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}

sameVnode


这个是用来判断是不是同一节点的函数


这个函数不长,直接看源码吧


源码地址:src\core\vdom\patch.js -35行


function sameVnode (a, b) {
return (
a.key === b.key && // key 是不是一样
a.asyncFactory === b.asyncFactory && ( // 是不是异步组件
(
a.tag === b.tag && // 标签是不是一样
a.isComment === b.isComment && // 是不是注释节点
isDef(a.data) === isDef(b.data) && // 内容数据是不是一样
sameInputType(a, b) // 判断 input 的 type 是不是一样
) || (
isTrue(a.isAsyncPlaceholder) && // 判断区分异步组件的占位符否存在
isUndef(b.asyncFactory.error)
)
)
)
}

patchVnode


源码地址:src\core\vdom\patch.js -501行


这个是在新的 vnode 和 oldVnode 是同一节点的情况下,才会执行的函数,主要是对比节点文本变化或子节点变化


还是先介绍一下主要流程,再看源码吧,流程是这样的:



  • 如果 oldVnode 和 vnode 的引用地址是一样的,就表示节点没有变化,直接返回

  • 如果 oldVnode 的 isAsyncPlaceholder 存在,就跳过异步组件的检查,直接返回

  • 如果 oldVnode 和 vnode 都是静态节点,并且有一样的 key,并且 vnode 是克隆节点或者 v-once 指令控制的节点时,把 oldVnode.elm 和 oldVnode.child 都复制到 vnode 上,然后返回

  • 如果 vnode 不是文本节点也不是注释的情况下

    • 如果 vnode 和 oldVnode 都有子节点,而且子节点不一样的话,就调用 updateChildren 更新子节点

    • 如果只有 vnode 有子节点,就调用 addVnodes 创建子节点

    • 如果只有 oldVnode 有子节点,就调用 removeVnodes 删除该子节点

    • 如果 vnode 文本为 undefined,就删掉 vnode.elm 文本



  • 如果 vnode 是文本节点但是和 oldVnode 文本内容不一样,就更新文本


  function patchVnode (
oldVnode, // 老的虚拟 DOM 节点
vnode, // 新的虚拟 DOM 节点
insertedVnodeQueue, // 插入节点的队列
ownerArray, // 节点数组
index, // 当前节点的下标
removeOnly // 只有在
) {
// 新老节点引用地址是一样的,直接返回
// 比如 props 没有改变的时候,子组件就不做渲染,直接复用
if (oldVnode === vnode) return

// 新的 vnode 真实的 DOM 元素
if (isDef(vnode.elm) && isDef(ownerArray)) {
// clone reused vnode
vnode = ownerArray[index] = cloneVNode(vnode)
}

const elm = vnode.elm = oldVnode.elm
// 如果当前节点是注释或 v-if 的,或者是异步函数,就跳过检查异步组件
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
// 当前节点是静态节点的时候,key 也一样,或者有 v-once 的时候,就直接赋值返回
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
// hook 相关的不用管
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// 获取子元素列表
const oldCh = oldVnode.children
const ch = vnode.children

if (isDef(data) && isPatchable(vnode)) {
// 遍历调用 update 更新 oldVnode 所有属性,比如 class,style,attrs,domProps,events...
// 这里的 update 钩子函数是 vnode 本身的钩子函数
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
// 这里的 update 钩子函数是我们传过来的函数
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
// 如果新节点不是文本节点,也就是说有子节点
if (isUndef(vnode.text)) {
// 如果新老节点都有子节点
if (isDef(oldCh) && isDef(ch)) {
// 如果新老节点的子节点不一样,就执行 updateChildren 函数,对比子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
// 如果新节点有子节点的话,就是说老节点没有子节点

// 如果老节点文本节点,就是说没有子节点,就清空
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
// 添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 如果新节点没有子节点,老节点有子节点,就删除
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 如果老节点是文本节点,就清空
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 新老节点都是文本节点,且文本不一样,就更新文本
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
// 执行 postpatch 钩子
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}

updateChildren


源码地址:src\core\vdom\patch.js -404行


这个是新的 vnode 和 oldVnode 都有子节点,且子节点不一样的时候进行对比子节点的函数


这里很关键,很关键!


比如现在有两个子节点列表对比,对比主要流程如下


循环遍历两个列表,循环停止条件是:其中一个列表的开始指针 startIdx 和 结束指针 endIdx 重合


循环内容是:{



  • 新的头和老的头对比

  • 新的尾和老的尾对比

  • 新的头和老的尾对比

  • 新的尾和老的头对比。 这四种对比如图


diff2.gif


以上四种只要有一种判断相等,就调用 patchVnode 对比节点文本变化或子节点变化,然后移动对比的下标,继续下一轮循环对比


如果以上四种情况都没有命中,就不断拿新的开始节点的 key 去老的 children 里找



  • 如果没找到,就创建一个新的节点

  • 如果找到了,再对比标签是不是同一个节点

    • 如果是同一个节点,就调用 patchVnode 进行后续对比,然后把这个节点插入到老的开始前面,并且移动新的开始下标,继续下一轮循环对比

    • 如果不是相同节点,就创建一个新的节点




}



  • 如果老的 vnode 先遍历完,就添加新的 vnode 没有遍历的节点

  • 如果新的 vnode 先遍历完,就删除老的 vnode 没有遍历的节点


为什么会有头对尾,尾对头的操作?


因为可以快速检测出 reverse 操作,加快 Diff 效率


function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0 // 老 vnode 遍历的下标
let newStartIdx = 0 // 新 vnode 遍历的下标
let oldEndIdx = oldCh.length - 1 // 老 vnode 列表长度
let oldStartVnode = oldCh[0] // 老 vnode 列表第一个子元素
let oldEndVnode = oldCh[oldEndIdx] // 老 vnode 列表最后一个子元素
let newEndIdx = newCh.length - 1 // 新 vnode 列表长度
let newStartVnode = newCh[0] // 新 vnode 列表第一个子元素
let newEndVnode = newCh[newEndIdx] // 新 vnode 列表最后一个子元素
let oldKeyToIdx, idxInOld, vnodeToMove, refElm

const canMove = !removeOnly

// 循环,规则是开始指针向右移动,结束指针向左移动移动
// 当开始和结束的指针重合的时候就结束循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]

// 老开始和新开始对比
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 是同一节点 递归调用 继续对比这两个节点的内容和子节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// 然后把指针后移一位,从前往后依次对比
// 比如第一次对比两个列表的[0],然后比[1]...,后面同理
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]

// 老结束和新结束对比
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 然后把指针前移一位,从后往前比
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]

// 老开始和新结束对比
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// 老的列表从前往后取值,新的列表从后往前取值,然后对比
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]

// 老结束和新开始对比
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
// 老的列表从后往前取值,新的列表从前往后取值,然后对比
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]

// 以上四种情况都没有命中的情况
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 拿到新开始的 key,在老的 children 里去找有没有某个节点有这个 key
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

// 新的 children 里有,可是没有在老的 children 里找到对应的元素
if (isUndef(idxInOld)) {
/// 就创建新的元素
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 在老的 children 里找到了对应的元素
vnodeToMove = oldCh[idxInOld]
// 判断标签如果是一样的
if (sameVnode(vnodeToMove, newStartVnode)) {
// 就把两个相同的节点做一个更新
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 如果标签是不一样的,就创建新的元素
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
// oldStartIdx > oldEndIdx 说明老的 vnode 先遍历完
if (oldStartIdx > oldEndIdx) {
// 就添加从 newStartIdx 到 newEndIdx 之间的节点
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)

// 否则就说明新的 vnode 先遍历完
} else if (newStartIdx > newEndIdx) {
// 就删除掉老的 vnode 里没有遍历的节点
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}

至此,整个 Diff 流程的核心逻辑源码到这就结束了,再来看一下 Vue 3 里做了哪些改变吧


Vue3 的优化


本文源码版本是 Vue2 的,在 Vue3 里整个重写了 Diff 算法这一块东西,所以源码的话可以说基本是完全不一样的,但是要做的事还是一样的


关于 Vue3 的 Diff 完整源码解析还在撰稿中,过几天就发布了,这里先介绍一下相比 Vue2 优化的部分,尤大公布的数据就是 update 性能提升了 1.3~2 倍ssr 性能提升了 2~3 倍,来看看都有哪些优化



  • 事件缓存:将事件缓存,可以理解为变成静态的了

  • 添加静态标记:Vue2 是全量 Diff,Vue3 是静态标记 + 非全量 Diff

  • 静态提升:创建静态节点时保存,后续直接复用

  • 使用最长递增子序列优化了对比流程:Vue2 里在 updateChildren() 函数里对比变更,在 Vue3 里这一块的逻辑主要在 patchKeyedChildren() 函数里,具体看下面


事件缓存


比如这样一个有点击事件的按钮


<button @click="handleClick">按钮</button>

来看下在 Vue3 被编译后的结果


export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("button", {
onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
}, "按钮"))
}

注意看,onClick 会先读取缓存,如果缓存没有的话,就把传入的事件存到缓存里,都可以理解为变成静态节点了,优秀吧,而在 Vue2 中就没有缓存,就是动态的


静态标记


看一下静态标记是啥?


源码地址:packages/shared/src/patchFlags.ts


export const enum PatchFlags {
TEXT = 1 , // 动态文本节点
CLASS = 1 << 1, // 2 动态class
STYLE = 1 << 2, // 4 动态style
PROPS = 1 << 3, // 8 除去class/style以外的动态属性
FULL_PROPS = 1 << 4, // 16 有动态key属性的节点,当key改变时,需进行完整的diff比较
HYDRATE_EVENTS = 1 << 5, // 32 有监听事件的节点
STABLE_FRAGMENT = 1 << 6, // 64 一个不会改变子节点顺序的fragment (一个组件内多个根元素就会用fragment包裹)
KEYED_FRAGMENT = 1 << 7, // 128 带有key属性的fragment或部分子节点有key
UNKEYEN_FRAGMENT = 1 << 8, // 256 子节点没有key的fragment
NEED_PATCH = 1 << 9, // 512 一个节点只会进行非props比较
DYNAMIC_SLOTS = 1 << 10, // 1024 动态slot
HOISTED = -1, // 静态节点
BAIL = -2 // 表示 Diff 过程中不需要优化
}

先了解一下静态标记有什么用?看个图


在什么地方用到的呢?比如下面这样的代码


<div id="app">
<div>沐华</div>
<p>{{ age }}</p>
</div>

在 Vue2 中编译的结果是,有兴趣的可以自行安装 vue-template-compiler 自行测试


with(this){
return _c(
'div',
{attrs:{"id":"app"}},
[
_c('div',[_v("沐华")]),
_c('p',[_v(_s(age))])
]
)
}

在 Vue3 中编译的结果是这样的,有兴趣的可以点击这里自行测试


const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐华", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_hoisted_2,
_createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
]))
}

看到上面编译结果中的 -11 了吗,这就是静态标记,这是在 Vue2 中没有的,patch 过程中就会判断这个标记来 Diff 优化流程,跳过一些静态节点对比


静态提升


其实还是拿上面 Vue2 和 Vue3 静态标记的例子,在 Vue2 里每当触发更新的时候,不管元素是否参与更新,每次都会全部重新创建,就是下面这一堆


with(this){
return _c(
'div',
{attrs:{"id":"app"}},
[
_c('div',[_v("沐华")]),
_c('p',[_v(_s(age))])
]
)
}

而在 Vue3 中会把这个不参与更新的元素保存起来,只创建一次,之后在每次渲染的时候不停地复用,比如上面例子中的这个,静态的创建一次保存起来


const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐华", -1 /* HOISTED */)

然后每次更新 age 的时候,就只创建这个动态的内容,复用上面保存的静态内容


export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_hoisted_2,
_createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
]))
}

patchKeyedChildren


在 Vue2 里 updateChildren 会进行



  • 头和头比

  • 尾和尾比

  • 头和尾比

  • 尾和头比

  • 都没有命中的对比


在 Vue3 里 patchKeyedChildren



  • 头和头比

  • 尾和尾比

  • 基于最长递增子序列进行移动/添加/删除


看个例子,比如



  • 老的 children:[ a, b, c, d, e, f, g ]

  • 新的 children:[ a, b, f, c, d, e, h, g ]



  1. 先进行头和头比,发现不同就结束循环,得到 [ a, b ]

  2. 再进行尾和尾比,发现不同就结束循环,得到 [ g ]

  3. 再保存没有比较过的节点 [ f, c, d, e, h ],并通过 newIndexToOldIndexMap 拿到在数组里对应的下标,生成数组 [ 5, 2, 3, 4, -1 ]-1 是老数组里没有的就说明是新增

  4. 然后再拿取出数组里的最长递增子序列,也就是 [ 2, 3, 4 ] 对应的节点 [ c, d, e ]

  5. 然后只需要把其他剩余的节点,基于 [ c, d, e ] 的位置进行移动/新增/删除就可以了

作者:沐华
链接:https://juejin.cn/post/7010594233253888013

收起阅读 »

Vue首屏加载优化之使用CND资源

背景 vue项目线上首屏加载速度非常慢,查看网络中加载的资源文件发现main.js文件大小为3.6MB,加载速度也是高达6.5s,已经严重影响了用户的体验效果。经过查看发现项目本地打包后main.js大小也是高达三十多兆,为了减少main.js文件打包后的大...
继续阅读 »

背景



vue项目线上首屏加载速度非常慢,查看网络中加载的资源文件发现main.js文件大小为3.6MB,加载速度也是高达6.5s,已经严重影响了用户的体验效果。经过查看发现项目本地打包后main.js大小也是高达三十多兆,为了减少main.js文件打包后的大小,查阅了众多经验文章后,发现使用CDN替代package引入后,体积可以大大减少。



建议


像echarts这种比较大的库,不要挂载比较大的库,一般使用到的地方不多按需加载就行。


使用CND资源


进入正题,这里修改了vue、vue-router、vuex、element-ui和mint-ui。



  • 首先修改模板文件index.html注意对应之前版本号。


<head> 
...
<!-- element-ui 组件引入样式 -->
<link rel="stylesheet" href="https://cdn.bootcss.com/element-ui/2.5.4/theme-chalk/index.css">
<!-- mint-ui 组件引入样式 -->
<link rel="stylesheet" href="https://cdn.bootcss.com/mint-ui/2.2.13/style.css">
</head>
<body>
<!-- 引入vue -->
<script src="https://cdn.bootcss.com/vue/2.5.2/vue.min.js"></script>
<!-- 引入vuex -->
<script src="https://cdn.bootcss.com/vuex/3.0.1/vuex.min.js"></script>
<!-- 引入vue-router -->
<script src="https://cdn.bootcss.com/vue-router/3.0.1/vue-router.min.js"></script>
<!-- 引入element-ui组件库 -->
<script src="https://cdn.bootcss.com/element-ui/2.5.4/index.js"></script>
<!-- 引入mint-ui组件库 -->
<script src="https://cdn.bootcss.com/mint-ui/2.2.13/index.js"></script>
<div id="app"></div>
</body>


  • 修改 build/webpack.base.conf.js。配置 externals 


/ * 说明:由于本项目是vue-cl2搭建,并有一个node中间层,所以我修改的是webpack.client.config.js文件*/
module.exports = {
...
externals: {
// CDN 的 Element 依赖全局变量 Vue, 所以 Vue 也需要使用 CDN 引入
'vue': 'Vue',
'vuex': 'Vuex',
'vue-router': 'VueRouter',
// 属性名称 element-ui, 表示遇到 import xxx from 'element-ui' 这类引入 'element-ui'的,
// 不去 node_modules 中找,而是去找 全局变量 ELEMENT
'element-ui': 'ELEMENT',
'mint-ui': 'MINT',
},
...
}


  • 修改 src/router/index.js


// 原来的样子
import Router from "vue-router";
Vue.use(Router);
const originalPush = Router.prototype.push
Router.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}
const router = new Router({})

// 修改后的样子
import VueRouter from "vue-router";
const originalPush = VueRouter.prototype.push
VueRouter.prototype.push = function push(location) {
return originalPush.call(this, location).catch(err => err)
}
const router = new VueRouter({})

// 总结
1、由于我们在externals中定义的vue-router的名字是‘VueRouter’,所以我们需要使用VueRouter来接收 import VueRouter from "vue-router";
2、注释掉 Vue.use(Router)


  • 修改 src/store/index.js


... 
// 注释掉
// Vue.use(Vuex)
...


  • 修改 src/main.js


/* 原来的样子 */
import Vue from "vue";
import App from "./App.vue";
import router from "./router";

// mint-ui
import MintUI from 'mint-ui'
import 'mint-ui/lib/style.css'
Vue.use(MintUI);
// element-ui
import ElementUi from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUi);

new Vue({
render: h => h(App),
router,
store
}).$mount("#app");


/* 修改之后的样子 */
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import {sync} from 'vuex-router-sync' // 这里使用了vuex-router-sync工具 作用:是将`vue-router`的状态同步到`vuex`中

// mint-ui
import MINT from 'mint-ui'
Vue.use(MINT);
// element-ui
import ELEMENT from 'element-ui'
Vue.use(ELEMENT);

sync(store, router)

new Vue({
render: h => h(App),
router,
store
}).$mount("#app");

// 总结:
1、element-ui 和 mint-ui 的变量名要使用 ELEMENT 和 MINT,在配置externals时有。

这样操作之后,重新打包一下可以发现,main.js文件大小已经减小到了12MB,当然这也和main.js我文件里引入其他东西的缘故,最后打开页面的时间也是得到了减少,这边文章作为一个记录和简单的介绍,希望能够给你带来帮助。


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

收起阅读 »

3~5年前端开发面经

前言 终于要从宁波去杭州了,经过从8月份结束面试到现在,中秋过完之后就要入职了。提完离职之后,差不多闲了1个月。 今天难得地放下游戏,回忆下面试题,希望能帮助到大家。杭州的大厂几乎面了个遍,阿里,蚂蚁,网易,字节,华为,有赞,只能按照记忆整理下面试题。 面试内...
继续阅读 »

前言


终于要从宁波去杭州了,经过从8月份结束面试到现在,中秋过完之后就要入职了。提完离职之后,差不多闲了1个月。


今天难得地放下游戏,回忆下面试题,希望能帮助到大家。杭州的大厂几乎面了个遍,阿里,蚂蚁,网易,字节,华为,有赞,只能按照记忆整理下面试题。


面试内容


算法,笔试


1.解析URL


出现得挺高频的,把一个url的query参数,解析成指定格式的对象。


2.对象的合并,key值的转化


出现得也比较多,给你一个对象,也是把它转化成指定的格式。比如把 a_b 这种下划线的key值转化为驼峰 aB,或者给你一个些数据,转化成对象。


比如把 a.b.c 变成 { a: { b: c } }


3.实现vue的双向绑定


4.实现eventListner


5.数组的操作


这个就挺多的,leecode多刷一刷,字节的题感觉都是从leecode找的,一眼看到就直接认出了。。。。。


这个题,难易程度其实相差很多的。有的题很简单,有的题很难。不过碰到的最难的也就是滑动窗口了。因为之前没碰到过类似的题,没有用双指针,磕磕绊绊做出来了,但是挺吃力的。


6.promise的使用


比如把fallback的函数改造成使用promise的。或者使用promise实现输出。这种题真挺烦的,要么不出,一出就挺搞脑子的,主要是绕。


字节对promise真的有偏爱,每个面试官绝对都会问。


笔试总结


虽然每个厂都会考算法,但是总体来说真的不难。最看重算法的应该是华为跟字节吧。


技术面试


技术的内容遇到的题目就很五花八门的,因为每个岗位需要的技能可能也不一样,但是高频出现的题目也是有很多的。


1 webpack的plugin和loader有啥区别,有写过什么loader和plugin吗


这个题真的是被问到无数次了,但是我依旧不知悔改,每次都是,了解过,没写过。不清楚区别,你敢问,我就敢说不知道。


2 打包优化,性能提升


这个也是,我永远都是回答那几个实际会用到的,多了就是不会,我特别反感背面试题,我高考古诗词填空都懒得背,滕王阁序永远只会那一句 落霞与孤鹜齐飞,秋水共长天一色 ,反正高考时候诗词填空错了好几个,让我为了面试去背这种东西 ?


如果是实际中用到了,肯定会记得,但是去硬记,不存在的。


3 promise


没错,promise,永远的噩梦。还有async await。


4 import 和 require


5 原型链, new


6 跨域(cors), http请求


7 XSS 和 CSRF


8 框架原理


业务面试


问一下具体做的业务,业务方向难点。


如果讲到业务中解决了什么困难,或者又使用了新的框架。一定要知其所以然了,再拿出来说。面试官很喜欢在这里,问你是如果决策,为什么要使用,以及原理是什么。


如果只是简单的用一用,就别说了,很有可能一问三不知,心态直接绷不住了。


总结


主要时间也过去一个月。只有一些高频出现的还记得比较清楚,希望对大家有所帮助。


但我还是觉得,背面试题,可能不是太好。除非理解得很深入,不然问起来,可能很容易被听出来是背题的。其实简单想想也是,回答起来切入面很大,又浅又泛经不起推敲的,一下就知道是背题的,大厂的面试官水平一般来说肯定是优于我们的。


就跟上学时候,低头看课外杂志以为老师在讲台上会看不到一样,自欺欺人罢了。


所以嘛,努力工作,努力积累才是硬道理,笔试题或者基础概念题临时抱抱佛脚问题不大,其他的还是积累大于一切吧。


希望大家,能找到心仪的工作。继续打炉石去了~


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

收起阅读 »

用 VSCode 调试网页的 JS 代码有多香

相比纯看代码来说,我更推荐结合 debugger 来看,它可以让我们看到代码实际的执行路线,每一个变量的变化。可以大段大段代码跳着看,也可以对某段逻辑一步步的执行来看。 Javascript 代码主要有两个运行环境,一个是 Node.js ,一个是浏览器。一般...
继续阅读 »

相比纯看代码来说,我更推荐结合 debugger 来看,它可以让我们看到代码实际的执行路线,每一个变量的变化。可以大段大段代码跳着看,也可以对某段逻辑一步步的执行来看。


Javascript 代码主要有两个运行环境,一个是 Node.js ,一个是浏览器。一般来说,调试 Node.js 上跑的 JS 代码我会用 VSCode 的 debugger,调试浏览器上的 JS 代码我会用 chrome devtools。直到有一天我发现 VSCode 也能调试浏览器上的的 JS 代码,试了一下,是真的香。


具体有多香呢?我们一起来看一下。


在项目的根目录下有个 .vscode/launch.json 的文件,保存了 VSCode 的调试配置。


我们点击 Add Configuration 按钮添加一个调试 chrome 的配置。



配置是这样的:



url 是网页的地址,我们可以把本地的 dev server 跑起来,然后把地址填在这里。


然后点击 debug 运行:



VSCode 就会起一个 Chrome 浏览器加载该网页,并且在我们的断点处断住。会在左侧面板现实调用栈、作用域的变量等。


最底层当然是 webpack 的入口,我们可以单步调试 webpack 的 runtime 部分。



也可以看下从 render 的流程,比如 ReactDOM.render 到渲染到某个子组件,中间都做了什么。



或者看下某个组件的 hooks 的值是怎么变化的(hooks 的值都存在组件的 fiberNode 的 memerizedState 属性上):


image.png


可以看到,调试 webpack runtime 代码,或者调试 React 源码、或者是业务代码,都很方便。


可能你会说,这个在 chrome devtools 里也可以啊,有啥特别的地方么?


确实,chrome devtools 也能做到一样的事情,但 VSCode 来调试网页代码有两个主要的好处:




  1. 在编辑器里面给代码打断点,还可以边调试边改代码。




  2. 调试 Node.js 的代码和调试网页的代码用同样的工具,经验可以复用,体验也一致。




对于第一点,chrome devtools 的 sources 其实也可以修改代码然后保存,但是毕竟不是专门的编辑器,用它来写代码比较别扭。我个人是比较习惯边 debug 边改代码的,这点 VSCode 胜出。


调试 Node.js 我们一般用 VSCode,而调试网页也可以用 VSCode,那么只要用熟了一个工具就行了,不用再去学 chrome devtools 怎么用,而且用 VSCode 调试体验也更好,毕竟是我们每天都用的编辑器,更顺手,这点也是 VSCode 胜出。


但你可能说那我想看 profile 信息呢? 也就是每个函数的耗时,这对于分析代码性能很重要。


这点 VSCode debugger 也支持了:



点击左侧的按钮,就可以录制一段时间内的耗时信息,可以手动停止、可以指定固定的时间、可以指定到某个断点,这样 3 种方式来选择某一段代码的执行过程记录 profile 信息。


它会在项目根目录保存一个 xxx.cpuprofile 的文件,里面记录了执行每一个函数的耗时,可以层层分析某段代码的耗时,来定位问题从而优化性能。



如果装了 vscode-js-profile-flame 的 VSCode extension 后,还可以换成火焰图的展示。



有的同学可能看不懂火焰图,我来讲一下:


我们知道某个函数的执行路径是有 call stack 的,可以看到从哪个函数一步步调用过来的,是一条线。



但其实这个函数调用的函数并不只一个,可能是多个:



调用栈只是保存了执行到某个函数的一条路线,而火焰图则保存了所有的执行路线。


所以你会在火焰图中看到这样的分叉:



其实就是这样的执行过程:



来算一道题:


函数 A 总耗时 50 ms,它调用的函数 B 耗时 10 ms,它调用的函数 C 耗时 20 ms,问:函数 A 的其余逻辑耗时多少 ms?



很明显可以算出是 50 - 10 - 20= 20 ms,可能你觉得函数 D 耗时太长了,那就去看下具体代码,然后看看是不是可以优化,之后再看下耗时。


就这么简单,profile 的性能分析就是这么做的,简单的加减法。


火焰图中的每个方块的宽度也反应了耗时,所以更直观一些。


JS 引擎是 event loop 的方式不断执行 JS 代码,因为火焰图是反应所有的代码的执行时间,所以会看到每一个 event loop 的代码执行,具体耗时多少。



每个长条的宽度代表了每个 loop 的耗时,那当然是越细越好,这样就不会阻塞渲染了。所以性能优化目标就是让火焰图变成一个个小细条,不能粗了。


绕回正题,VSCode 的 cpu profile 和火焰图相比 chrome devtools 的 performance 其实更简洁易用,可以满足大多数的需求。


我觉得,除非你想看 rendering、memory 这些信息,因为 VSCode 没有支持需要用 chrome devtools 以外,调试 JS 代码,看 profile 信息和火焰图,用 VSCode 足够了。


反正我觉得 VSCode 调试网页的 JS 代码挺香的,你觉得呢?


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

收起阅读 »

Dialog 按照顺序弹窗

背景: 产品需求,在同一个页面弹窗需要按照顺序实现: 利用PriorityQueue现实,支持相同优先级,按插入时间排序,目前仅支持Activity,不支持Fragment代码: DialogPriorityUtil 实现优先级弹窗/** ...
继续阅读 »

背景: 产品需求,在同一个页面弹窗需要按照顺序

实现: 利用PriorityQueue现实,支持相同优先级,按插入时间排序,目前仅支持Activity,不支持Fragment

代码: DialogPriorityUtil 实现优先级弹窗

/**
* ClassName: DialogPriorityUtil
* Description: show dialog by priority
* author Neo
* since 2021-09-15 20:15
* version 1.0
*/
object DialogPriorityUtil : LifecycleObserver {

private val dialogPriorityQueue = PriorityQueue<PriorityDialogWrapper>()

private var hasDialogShowing = false

@MainThread
fun bindLifeCycle(appCompatActivity: AppCompatActivity) {
appCompatActivity.lifecycle.addObserver(this)
}

@MainThread
fun showDialogByPriority(dialogWrapper: PriorityDialogWrapper? = null) {
if (dialogWrapper != null) {
dialogPriorityQueue.offer(dialogWrapper)
}
if (hasDialogShowing) return
val maxPriority: PriorityDialogWrapper = dialogPriorityQueue.poll() ?: return
if (!maxPriority.isShowing()) {
hasDialogShowing = true
maxPriority.showDialog()
}
maxPriority.setDismissListener {
hasDialogShowing = false
showDialogByPriority()
}
}

@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
dialogPriorityQueue.clear()
}
}
/**
* 定义dialog优先级
* @property priority Int
* @constructor
*/
sealed class DialogPriority(open val priority: Int) {
sealed class HomeMapFragment(override val priority: Int) : DialogPriority(priority) {
/**
* App更新
*/
object UpdateDialog : HomeMapFragment(0)

/**
* 等级提升
*/
object LevelUpDialog : HomeMapFragment(1)

/**
* 金币打卡
*/
object CoinClockInDialog : HomeMapFragment(2)
}
}

/**
* ClassName: PriorityDialogWrapper
* Description: 优先级弹窗包装类
* author Neo
* since 2021-09-15 20:20
* version 1.0
*/
class PriorityDialogWrapper(private val dialog: Dialog, private val dialogPriority: DialogPriority) : Comparable<PriorityDialogWrapper> {

private var dismissCallback: (() -> Unit)? = null

private val timestamp = SystemClock.elapsedRealtimeNanos()

init {
dialog.setOnDismissListener {
dismissCallback?.invoke()
}
}

fun isShowing(): Boolean = dialog.isShowing

fun setDismissListener(callback: () -> Unit) {
this.dismissCallback = callback
}

fun showDialog() {
dialog.show()
}

override fun compareTo(other: PriorityDialogWrapper): Int {
return when {
dialogPriority.priority > other.dialogPriority.priority -> {
// 当前对象比目标对象大,则返回 1
1
}
dialogPriority.priority < other.dialogPriority.priority -> {
// 当前对象比目标对象小,则返回 -1
-1
}
else -> {
// 若是两个对象相等,则返回 0
when {
timestamp > other.timestamp -> {
1
}
timestamp < other.timestamp -> {
-1
}
else -> {
0
}
}
}
}
}
}

使用:

AppCompatActivity

DialogPriorityUtil.bindLifeCycle(this)
DialogPriorityUtil.showDialogByPriority(...)
收起阅读 »

kotlin的协程异步,并发(同步)

一:协程的异步任务private fun task(){ println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, start"...
继续阅读 »

一:协程的异步

任务

private fun task(){
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, start")
Thread.sleep(1000)
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, end")
}

下面使用协程异步的方式,让任务task()在子线程中处理。

方式1:launch()+Dispatchers.IO

launch创建协程;

Dispatchers.IO调度,在子线程处理网络耗时

fun testNotSync() {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
// 重复执行3次,模拟点击3次
repeat(3) {
CoroutineScope(Dispatchers.IO).launch {
task()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main, time:1631949431058, 方法start
currentThread:main, time:1631949431166, 方法end

currentThread:DefaultDispatcher-worker-3 @coroutine#3, time:1631949431176, start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631949431182, start
currentThread:DefaultDispatcher-worker-2 @coroutine#1, time:1631949431182, start

currentThread:DefaultDispatcher-worker-3 @coroutine#3, time:1631949432176, end
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631949432182, end
currentThread:DefaultDispatcher-worker-2 @coroutine#1, time:1631949432183, end

显示:主线程内容先执行,然后会在3个子线程异步的执行

方式2:async()+Dispatchers.IO

fun testByCoroutineAsync() {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).async {
task()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")
// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main @coroutine#1, time:1631957324981, 方法start
currentThread:main @coroutine#1, time:1631957325007, 方法end
currentThread:DefaultDispatcher-worker-1 @coroutine#3, time:1631957325007, start
currentThread:DefaultDispatcher-worker-2 @coroutine#2, time:1631957325007, start
currentThread:DefaultDispatcher-worker-4 @coroutine#4, time:1631957325007, start
currentThread:DefaultDispatcher-worker-1 @coroutine#3, time:1631957326007, end
currentThread:DefaultDispatcher-worker-4 @coroutine#4, time:1631957326007, end
currentThread:DefaultDispatcher-worker-2 @coroutine#2, time:1631957326007, end

显示:主线程内容先执行,然后会在3个子线程异步的执行

看源码发现CoroutineScope.async 等同 CoroutineScope.launch,不同是返回值。

方式3:withContext+Dispatchers.IO

/**
* 单个withContext的异步任务
*/
fun testByWithContext() = runBlocking {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")

withContext(Dispatchers.IO) {
task()
}

println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10 *1000)
}

结果:

currentThread:main @coroutine#1, time:1631958195591, 方法start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958195669, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958196669, end
currentThread:main @coroutine#1, time:1631958196671, 方法end

发现:withContext的task是在子线程中执行,但是也阻塞了main线程,最后执行了"方法end"

因为withContext切io线程后,还挂起了外部的协程(可以理解线程),需要等withCotext执行完成,才会回到原来的协程,也直接可以理解为阻塞了当前的线程。

上面是单个withCotext的异步执行,看多个withContext是怎么样的

fun testByWithContext() = runBlocking {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
// 重复3次,模拟点击3次
repeat(3) {
println("repeat it = $it")
withContext(Dispatchers.IO) {
task()
}
}

println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10 *1000)
}

结果:

currentThread:main @coroutine#1, time:1631958027834, 方法start
repeat it = 0
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958027870, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958028870, end
repeat it = 1
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958028873, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958029873, end
repeat it = 2
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958029874, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631958030874, end
currentThread:main @coroutine#1, time:1631958030874, 方法end

发现:先main线程执行,然后一个withcontext异步执行完成,才能执行下一个withcontext的异步

实现了多个异步任务的同步,当我们有多个接口请求,需要按顺序执行时,可以使用

二:协程的并发(同步)

Java中并发concurrent的处理,基本使用同步synchronized,Lock,join等来处理。下面我们看看协程怎麽处理的。

1:@Synchronized 注解

我们将上面的任务task修改一下,方法上面加个注解@Synchronized,然后执行launch的异步看能不能同步任务?

使用

/**
* @Synchronized 修改普通函数ok,可以同步
*/
@Synchronized
private fun taskSynchronize(){
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, start")
Thread.sleep(1000)
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, end")
}

测试:aunch异步同时访问taskSynchronize()任务

fun testCoroutineWithSync() {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).launch {
taskSynchronize()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main, time:1631959341585, 方法start
currentThread:main, time:1631959341657, 方法end
currentThread:DefaultDispatcher-worker-4 @coroutine#3, time:1631959341657, start
currentThread:DefaultDispatcher-worker-4 @coroutine#3, time:1631959342658, end
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631959342658, start
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631959343658, end
currentThread:DefaultDispatcher-worker-2 @coroutine#2, time:1631959343658, start
currentThread:DefaultDispatcher-worker-2 @coroutine#2, time:1631959344658, end

发现:先main先执行完成,然后每个线程任务,同步执行完成了

问题

当@Synchronized 注解的方法中,有挂起函数且是阻塞的,就不行了

修改一下任务,其中的Thread.sleep(1000)改为delay(1000),看看如何?

/**
* 和方法taskSynchronize(), 不同的是内部使用了delay的挂起函数,而其它会阻塞,需要等它完成后面的才能开始
*
* @Synchronized 关键字不要修饰方法中有suspend挂起函数,因为内部又挂起了,就不会同步了
*/
@Synchronized
suspend fun taskSynchronizeByDelay(){
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, start")
delay(1000)
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, end")
}
/**
* 执行体是taskSynchronizeByDelay(), 内部会使用delay函数,导致他外部的线程挂起,其他线程可以访问执行体,
*
* 所以:@Synchronized 同步注解,尽量不用修饰suspend的函数
*/
fun testCoroutineWithSync2() {
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).launch {
taskSynchronizeByDelay()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main, time:1631961179390, 方法start
currentThread:main, time:1631961179451, 方法end
currentThread:DefaultDispatcher-worker-1 @coroutine#1, time:1631961179456, start
currentThread:DefaultDispatcher-worker-4 @coroutine#3, time:1631961179464, start
currentThread:DefaultDispatcher-worker-3 @coroutine#2, time:1631961179464, start
currentThread:DefaultDispatcher-worker-3 @coroutine#1, time:1631961180462, end
currentThread:DefaultDispatcher-worker-3 @coroutine#3, time:1631961180464, end
currentThread:DefaultDispatcher-worker-4 @coroutine#2, time:1631961180464, end

发现:加了@Synchronized注解,还是异步的执行,因为task中有delay这个挂起函数,它会挂起外部协程,直到执行完成才会执行其他的。

2:Mutex()

使用:

var mutex = Mutex()
mutex.withLock {
// TODO
}

测试:

fun testSyncByMutex() = runBlocking {
var mutex = Mutex()
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).launch {
mutex.withLock {
task()
}
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main @coroutine#1, time:1631951230155, 方法start
currentThread:main @coroutine#1, time:1631951230178, 方法end
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631951230178, start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631951231178, end
currentThread:DefaultDispatcher-worker-2 @coroutine#3, time:1631951231183, start
currentThread:DefaultDispatcher-worker-2 @coroutine#3, time:1631951232183, end
currentThread:DefaultDispatcher-worker-1 @coroutine#4, time:1631951232183, start
currentThread:DefaultDispatcher-worker-1 @coroutine#4, time:1631951233184, end

发现:多个异步任务同步完成了。

3:Job.join()

Job创建协程返回的句柄,它支持join()操作,类是java线程的join功能,可以等待任务执行完成,实现同步

测试:

fun testSyncByJob() = runBlocking{
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
var job = CoroutineScope(Dispatchers.IO).launch {
task()
}
job.start()
job.join()
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main @coroutine#1, time:1631959997427, 方法start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631959997507, start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631959998507, end
currentThread:DefaultDispatcher-worker-1 @coroutine#3, time:1631959998509, start
currentThread:DefaultDispatcher-worker-1 @coroutine#3, time:1631959999509, end
currentThread:DefaultDispatcher-worker-1 @coroutine#4, time:1631959999510, start
currentThread:DefaultDispatcher-worker-1 @coroutine#4, time:1631960000510, end
currentThread:main @coroutine#1, time:1631960000510, 方法end

发现:多个任务可以同步一个个完成,并且阻塞了main线程,和withContext的效果一样哦。

4:ReentrantLock

使用:

val lock = ReentrantLock()
lock.lock()
task()
lock.unlock()

测试:

fun testReentrantLock2() = runBlocking {

val lock = ReentrantLock()
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法start")
repeat(3){
CoroutineScope(Dispatchers.IO).launch {
lock.lock()
task()
lock.unlock()
}
}
println("currentThread:${Thread.currentThread().name}, time:${System.currentTimeMillis()}, 方法end")

// 防止main函数执行结束,就不管其他线程的打印工作了,哈哈
Thread.sleep(10000)
}

结果:

currentThread:main @coroutine#1, time:1631960884403, 方法start
currentThread:main @coroutine#1, time:1631960884445, 方法end
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631960884445, start
currentThread:DefaultDispatcher-worker-1 @coroutine#2, time:1631960885446, end
currentThread:DefaultDispatcher-worker-5 @coroutine#4, time:1631960885446, start
currentThread:DefaultDispatcher-worker-5 @coroutine#4, time:1631960886446, end
currentThread:DefaultDispatcher-worker-2 @coroutine#3, time:1631960886446, start
currentThread:DefaultDispatcher-worker-2 @coroutine#3, time:1631960887447, end

发现:同步完成。

收起阅读 »

Kotlin中的高阶函数,匿名函数、Lambda表达式

高阶函数、匿名函数与lambda 表达式 Kotlin 函数都是头等的,这意味着它们可以存储在变量与数据结构中、作为参数传递给其他高阶函数以及从其他高阶函数返回。可以像操作任何其他非函数值一样操作函数。 头等函数:头等函数(first-class functi...
继续阅读 »

高阶函数、匿名函数与lambda 表达式

 Kotlin 函数都是头等的,这意味着它们可以存储在变量与数据结构中、作为参数传递给其他高阶函数以及从其他高阶函数返回。可以像操作任何其他非函数值一样操作函数。

 头等函数:头等函数(first-class function)是指在程序设计语言中,函数被当作头等公民。这意味着,函数可以作为别的函数的参数、函数的返回值,赋值给变量或存储在数据结构中

高阶函数

高阶函数是将函数用作参数或返回值的函数。

 //learnHighFun是一个高阶函数,因为他有一个函数类型的参数funParam,注意这里有一个新的名词,函数类型,函数在kotlin中也是一种类型。那他是什么类型的函数呢?注意(Int)->Int,这里表示这个函数是一个,接收一个Int,并返回一个Int类型的参数。
 fun learnHighFun(funParam:(Int)->Int,param:Int){}

 以上就是一个最简单的高阶函数了。了解高阶函数之前,显然,我们有必要去了解一下上面的新名词,函数类型

函数类型

如何声明一个函数类型的参数

 在kotlin中,声明一个函数类型的格式很简单,在kotlin中我们是通过->符号来组织参数类型和返回值类型,左右是函数的参数,右边是函数的返回值,函数的参数,必须在()中,多个参数的时候,用,将参数分开。如下:

 //表示该函数类型,接收一个Int类型的参数,并且返回值为Int类型
 (Int)->Int
 
 //表示该函数类型,接收两个参数,一个Int类型的参数,一个String类型的参数,并且返回值为Int类型
 (Int,Stirng)->Int

 那没有函数参数,和无返回值函数怎么声明?如下:

 //声明一个没有参数,返回值是Int的函数类型,函数类型中,函数没有参数的时候,()不可以省略
 ()->Int
 
 //明一个没有参数,没有返回值的函数类型,函数类型中,函数没有返回值的时候,Unit不可以省略
 ()->Unit
 

 以上就是简单的函数类型的声明了。那么如果是一个高阶函数,它的参数类型也是一个高阶函数,那要怎么声明?比如以下的式子表示什么含义:

 private fun learnHigh(funParams:((Int)->Int)->Int){}
 //这里表示的是一个高阶函数learnHigh,他有一个函数类型的参数funParams。而这个funParams的类型也是一个高阶函数的类型。funParams这个函数类型表示,它接受一个普通函数类(Int)->Int的参数,并返回一个Int类型。这段话读起来确实很绕,但是你明白了这个复杂的例子之后,基本所有的高阶函数你都能看懂什么意思了。
 
 //这里这个highParam的类型,就符合上面learnHigh函数所要接收的函数类型
 fun highParam(param: (Int)->Int):Int{
     return  1
 }

 讲了参数为函数类型的高阶函数,返回值类型为函数的高阶函数也基本参照上面的这些看就可以了。那么下一个问题来了,我是讲了这么多高阶函数,这么多函数类型的知识点。那么这些函数类型的参数要怎么传?换句话说,应该怎么样把这些函数类型的参数,传给的高阶函数?直接使用函数名可以吗?显然是不行的,因为函数名并不是一个表达式,不具备类型信息。那么我们这时候就需要一个单纯的方法引用表达式

函数引用

 在kotlin中,使用两个冒号的来实现对某个类的方法进行引用。 这句话包含了哪些信息呢?第一,既然是引用,那么说明是对象。也就是使用双冒号实现的引用也是一个对象。 它是一个函数类型的对象。第二,既然对象,那么他就需要被创建,也就是说,这里创建了一个函数类型的对象,这个对象是具有和这个函数功能相同的对象。还是举例子来说明一下上面两句话是什么意思:

 fun testFunReference(){
     funReference(1)  //普通函数,直接通过函数名然后附带参数来调用。
     val funObject = ::funReference //函数的引用,他本质上已经是一个对象了
     testHighFun(funObject) //通过一个函数引用,将这个函数类型的对象,传递给高阶函数。所以高阶函数里面接收的参数本质上还是对象。
 
     funObject.invoke(1) //等同于funReference(1)
     funObject(1) //等同于funReference(1),等同于funObject.invoke(1)
 }
 
 fun funReference(param:Int){
     //doSomeThing
 }
 
 fun testHighFun(funParam:(Int)->Unit){
     //doSomeThing
 }
 //这是反编译出来的java代码
 public final void testFunReference() {
     this.funReference(1);
     //val funObject = ::funReference 这句代码反编译出来就是这样的,可以看出这里是新创建了一个对象
     KFunction funObject = new Function1((TestFun)this) {
         // $FF: synthetic method
         // $FF: bridge method
         public Object invoke(Object var1) {
             this.invoke(((Number)var1).intValue());
             return Unit.INSTANCE;
        }
 
         public final void invoke(int p1) {
            ((TestFun)this.receiver).funReference(p1);
        }
    };
     this.testHighFun((Function1)funObject);
    ((Function1)funObject).invoke(1);
    ((Function1)funObject).invoke(1);//funObject(1)最终是调用的funObject.invoke(1)
 }
 
 public final void funReference(int param) {
 }
 //可以看出这个testHighFun接收的是一个Function1类型的对象
 public final void testHighFun(@NotNull Function1 funParam) {
     Intrinsics.checkNotNullParameter(funParam, "funParam");
 }

以上就是关于函数引用的知识点了。

 理解了以上的用法,但是这种写法好像每次都需要去声明一个函数,那么有没有其他不需要重新声明函数的方法去调用高阶函数呢?那肯定还是有的,如果这都不支持那Kotlin的这个高阶函数这个特性不就有点鸡肋了吗?接下来就讲解另外两个知识点,kotlin中的匿名函数Lambda表达式

匿名函数

 来讲匿名函数,看定义就知道这是一个没有名字的'函数',注意这里的'函数'这两个字是带有引号的。首先来看看怎么在高阶函数中使用吧。

 //接着上面的例子讲
 //除了这种通过引用对象调用testHighFun(funObject)的方法,还可以直接把一个函数当做这个高阶函数的参数。
 val param = fun (param:Int){ //注意这里是没有函数名的,所以是匿名'函数'
     //doSomeThing
 }
 testHighFun(param)

 注意:通过之前的分析,我们可以知道,这个高阶函数testHighFun接收的参数是一个函数对象的引用,也就是说我们定义的val param是一个函数对象的引用,那么可以得出这个匿名'函数' fun(param:Int){},他的本质是一个函数对象。他并不是'函数'。我们可以看一下反编译出来的java代码

 //param是一个Function1类型的对象的引用
 Function1 param = (Function1)null.INSTANCE;
 this.testHighFun(param);

所以记住一点,Kotlin中的匿名函数,它的本质不是函数。而是对象。它和函数不是一个东西,它是一个函数类型的对象。对象和函数,它们是两个东西。

Lambda表达式

Lambda 表达式的完整语法形式如下:

 val sum: (Int, Int) -> Int = { x: Int, y: Int -> x + y }

 Lambda 表达式总是括在花括号中, 完整语法形式的参数声明放在花括号内,并有可选的类型标注, 函数体跟在一个 -> 符号之后。如果推断出的该Lambda 的返回类型不是 Unit,那么该 Lambda 主体中的最后一个(或可能是单个) 表达式会视为返回值。

由于Kotlin中是支持类型推到的,所以以上的写法可以简化成如下两个格式:

 val sum= { x: Int, y: Int -> x + y }
 val sum: (Int, Int) -> Int = { x, y -> x + y }

 在kotlin中还支持,如果函数的最后一个参数是函数,那么作为相应参数传入的 Lambda 表达式可以放在圆括号之外:

 //比如我们上面的那个例子testHighFun,可以将lambda放到原括号之外
 testHighFun(){
 //doSomeThing
 }
 
 //如果该 lambda 表达式是调用时唯一的参数,那么圆括号可以完全省略:如下
 testHighFun{
 //doSomeThing
 }
 
 //一个 lambda 表达式只有一个参数是很常见的。
 //如果编译器自己可以识别出签名,也可以不用声明唯一的参数并忽略 ->。 该参数会隐式声明为 it: 如下
 testHighFun{
     //doSomeThing
     it.toString(it)
 }

从 lambda 表达式中返回一个值

 我们可以使用限定的返回语法从 lambda 显式返回一个值。 否则,将隐式返回最后一个表达式的值。参考官网的例子如下

 ints.filter {
     val shouldFilter = it > 0
     shouldFilter
 }
 
 ints.filter {
     val shouldFilter = it > 0
     return@filter shouldFilter
 }

 好了,以上就是Lambda的基本用法了。

 讲了这么多,我们只是讲解了Lambda怎么使用,那么它的本质是什么?其实仔细思考一下上面的testHighFun可以传入一个Lambda表达式就可以大概知道,Lambda的本质也是一个函数类型的对象。这一点也可以通过发编译的java代码去看。

匿名函数与Lambda表达式的总结:

  1. 两者都能作为高阶函数的参数进行传递。
  2. 两者的本质都是函数类型的对象。

备注:以上就是我个人对高阶函数,匿名函数,Lambda表达式的理解,有什么不对的地方,还请各位大佬指正。

收起阅读 »

高仿小米加载动画效果

前言 首先看一下小米中的加载动画是怎么样的,恩恩~~~~虽然只是张图片,因为录制不上全部,很多都是刚一加载就成功了,一点机会都不提供给我,所以就截了一张图,他这个加载动画特点就是左面圆圈会一直转。 仿照的效果如下: 实现过程 这个没有难度,只是学会一个公式...
继续阅读 »

前言


首先看一下小米中的加载动画是怎么样的,恩恩~~~~虽然只是张图片,因为录制不上全部,很多都是刚一加载就成功了,一点机会都不提供给我,所以就截了一张图,他这个加载动画特点就是左面圆圈会一直转。


image.png


仿照的效果如下:


录屏_选择区域_20210917141950.gif


实现过程


这个没有难度,只是学会一个公式就可以,也就是已知圆心,半径,角度,求圆上的点坐标,算出来的结果在这个点绘制一个实心圆即可,下面是自定义Dialog,让其在底部现实,其中的View也是自定义的一个。



class MiuiLoadingDialog(context: Context) : Dialog(context) {
private var miuiLoadingView : MiuiLoadingView= MiuiLoadingView(context);
init {
setContentView(miuiLoadingView)
setCancelable(false)
}

override fun show() {
super.show()
val window: Window? = getWindow();
val wlp = window!!.attributes

wlp.gravity = Gravity.BOTTOM
window.setBackgroundDrawable( ColorDrawable(Color.TRANSPARENT));
wlp.width=WindowManager.LayoutParams.MATCH_PARENT;
window.attributes = wlp
}
}

下面是主要的逻辑,在里面,首先通过clipPath方法裁剪出一个上边是圆角的形状,然后绘制一个外圆,这是固定的。


中间的圆需要一个公式,如下。


x1   =   x0   +   r   *   cos(a   *   PI   /180   ) 
y1   =   y0   +   r   *   sin(a   *   PI  /180   ) 

x0、y0就是外边大圆的中心点,r是中间小圆大小,a是角度,只需要一直变化这个角度,得出的x1、y1通过drawCircle绘制出来即可。


image.png



class MiuiLoadingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

//Dialog上面圆角大小
val CIRCULAR: Float = 60f;

//中心移动圆位置
var rx: Float = 0f;
var ry: Float = 0f;

//左边距离
var MARGIN_LEFT: Int = 100;

//中心圆大小
var centerRadiusSize: Float = 7f;

var textPaint: Paint = Paint().apply {
textSize = 50f
color = Color.BLACK
}

var circlePaint: Paint = Paint().apply {
style = Paint.Style.STROKE
strokeWidth = 8f
isAntiAlias = true
color = Color.BLACK
}

var centerCirclePaint: Paint = Paint().apply {
style = Paint.Style.FILL
isAntiAlias = true
color = Color.BLACK
}

var degrees = 360;

val TEXT = "正在加载中,请稍等";
var textHeight = 0;

init {

var runnable = object : Runnable {
override fun run() {
val r = 12;
rx = MARGIN_LEFT + r * Math.cos(degrees.toDouble() * Math.PI / 180).toFloat()
ry =
((measuredHeight.toFloat() / 2) + r * Math.sin(degrees.toDouble() * Math.PI / 180)).toFloat();
invalidate()
degrees += 5
if (degrees > 360) degrees = 0
postDelayed(this, 1)
}
}
postDelayed(runnable, 0)


var rect = Rect()
textPaint.getTextBounds(TEXT, 0, TEXT.length, rect)
textHeight = rect.height()
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(widthMeasureSpec, 220);
}

override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

var path = Path()
path.addRoundRect(
RectF(0f, 0f, measuredWidth.toFloat(), measuredHeight.toFloat()),
floatArrayOf(CIRCULAR, CIRCULAR, CIRCULAR, CIRCULAR, 0f, 0f, 0f, 0f), Path.Direction.CW
);
canvas.clipPath(path)
canvas.drawColor(Color.WHITE)


canvas.drawCircle(
MARGIN_LEFT.toFloat(), measuredHeight.toFloat() / 2,
35f, circlePaint
)

canvas.drawCircle(
rx, ry,
centerRadiusSize, centerCirclePaint
)


canvas.drawText(TEXT, (MARGIN_LEFT + 80).toFloat(), ((measuredHeight / 2)+(textHeight/2)).toFloat(), textPaint)
}
}

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

探究 Kotlin 的隐藏性能开销与避坑指南

在 2019 年 Google I/O 大会上,Google 宣布了今后 Android 开发将优先使用 Kotlin ,即 Kotlin-first,随之在 Android 开发界兴起了一阵全民学习 Kotlin 的热潮。之后 Google 也推出了一系列用...
继续阅读 »

在 2019 年 Google I/O 大会上,Google 宣布了今后 Android 开发将优先使用 Kotlin ,即 Kotlin-first,随之在 Android 开发界兴起了一阵全民学习 Kotlin 的热潮。之后 Google 也推出了一系列用 Kotlin 实现的 ktx 扩展库,例如 activity-ktxfragment-ktxcore-ktx等,提供了各种方便的扩展方法用于简化开发者的工作,Kotlin 协程目前也是官方在 Android 上进行异步编程的推荐解决方案


Google 推荐优先使用 Kotlin,也宣称不会放弃 Java,但目前各种 ktx 扩展库还是需要由 Kotlin 代码进行使用才能最大化地享受到其便利性,Java 代码来调用显得有点不伦不类。作为 Jetpack 主要组件之一的 Paging 3.x 版本目前也已经完全用 Kotlin 实现,为 Kotlin 协程提供了一流的支持。刚出正式版本不久的 Jetpack Compose 也只支持 Kotlin,Java 无缘声明式 UI


开发者可以感受到 Kotlin 在 Android 开发中的重要性在不断提高,虽然 Google 说不会放弃 Java,但以后的事谁说得准呢?开发者还是需要尽早迁移到 Kotlin,这也是必不可挡的技术趋势


Kotlin 在设计理念上有很多和 Java 不同的地方,开发者能够直观感受到的是语法层面上的差异性,背后也包含有一系列隐藏的性能开销以及一些隐藏得很深的“坑”,本篇文章就来介绍在使用 Kotlin 过程中存在的隐藏性能开销,帮助读者避坑,希望对你有所帮助 🤣🤣


慎用 @JvmOverloads


@JvmOverloads 注解大家应该不陌生,其作用在具有默认参数的方法上,用于向 Java 代码生成多个重载方法


例如,以下的 println 方法对于 Java 代码来说就相当于两个重载方法,默认使用空字符串作为入参参数


//Kotlin
@JvmOverloads
fun println(log: String = "") {

}

//Java
public void println(String log) {

}

public void println() {
println("");
}

@JvmOverloads 很方便,减少了 Java 代码调用 Kotlin 代码时的调用成本,使得 Java 代码也可以享受到默认参数的便利,但在某些特殊场景下也会引发一个隐藏得很深的 bug


举个例子


我们知道 Android 系统的 View 类包含有多个构造函数,我们在实现自定义 View 时至少就要声明一个包含有两个参数的构造函数,参数类型必须依次是 Context 和 AttributeSet,这样该自定义 View 才能在布局文件中使用。而 View 类的构造函数最多包含有四个入参参数,最少只有一个,为了省事,我们在用 Kotlin 代码实现自定义 View 时,就可以用 @JvmOverloads 来很方便地继承 View 类,就像以下代码


open class BaseView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0, defStyleRes: Int = 0
) : View(context, attrs, defStyleAttr, defStyleRes)

如果我们是像 BaseView 一样直接继承于 View 的话,此时使用@JvmOverloads就不会产生任何问题,可如果我们继承的是 TextView 的话,那么问题就来了


直接继承于 TextView 不做任何修改,在布局文件中分别使用 MyTextView 和 TextView,给它们完全一样的参数,看看运行效果


open class MyTextView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0, defStyleRes: Int = 0
) : TextView(context, attrs, defStyleAttr, defStyleRes)

    <github.leavesc.demo.MyTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="业志陈"
android:textSize="42sp" />

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="业志陈"
android:textSize="42sp" />

此时两个 TextView 就会呈现出不一样的文本颜色了,十分神奇



这就是 @JvmOverloads 带来的一个隐藏问题。因为 TextView 的 defStyleAttr 实际上是有一个默认值的,即 R.attr.textViewStyle,当中就包含了 TextView 的默认文本颜色,而由于 MyTextView 为 defStyleAttr 指定了一个默认值 0,这就导致 MyTextView 丢失了一些默认风格属性


public TextView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.textViewStyle);
}

因此,如果我们要直接继承的是 View 类的话可以直接使用@JvmOverloads,此时不会有任何问题,而如果我们要继承的是现有控件的话,就需要考虑应该如何设置默认值了


慎用 解构声明


有时我们会有把一个对象拆解成多个变量的需求,Kotlin 也提供了这类语法糖支持,称为解构声明


例如,以下代码就将 People 变量解构为了两个变量:name 和 nickname,变量名可以随意取,每个变量就按顺序对应着 People 中的字段


data class People(val name: String, val nickname: String)

private fun printInfo(people: People) {
val (name, nickname) = people
println(name)
println(nickname)
}

每个解构声明其实都会被编译成以下代码,解构操作其实就是在按照顺序获取特定方法的返回值


String name = people.component1();

String nickname = people.component2();

component1()component2() 函数是 Kotlin 为数据类自动生成的方法,People 反编译为 Java 代码后就可以看到,每个方法返回的其实都是成员变量,方法名包含的数字对应的就是成员变量在数据类中的声明顺序


public final class People {
@NotNull
private final String name;
@NotNull
private final String nickname;

@NotNull
public final String component1() {
return this.name;
}

@NotNull
public final String component2() {
return this.nickname;
}

}

解构声明和数据类配套使用时就有一个隐藏的知识点,看以下例子


假设后续我们为 People 添加了一个新字段 city,此时 printInfo 方法一样可以正常调用,但 nickname 指向的其实就变成了 people 变量内的 city 字段了,含义悄悄发生了变化,此时就会导致逻辑错误了


data class People(val name: String, val city: String, val nickname: String)

private fun printInfo(people: People) {
val (name, nickname) = people
println(name)
println(nickname)
}

数据类中的字段是可以随时增减或者变换位置的,从而使得解构结果和我们一开始预想的不一致,因此我觉得解构声明和数据类不太适合放在一起使用


慎用 toLowerCase 和 toUpperCase


当我们要以忽略大小写的方式比较两个字符串是否相等时,通常想到的是通过 toUpperCasetoLowerCase 方法将两个字符串转换为全大写或者全小写,然后再进行比较,这种方式完全可以满足需求,但当中也包含着一个隐藏开销


例如,以下的 Kotlin 代码反编译为 Java 代码后,可以看到每次调用toUpperCase方法都会创建一个新的临时变量,然后再去调用临时变量的 equals 方法进行比较


fun main() {
val name = "leavesC"
val nickname = "leavesc"
println(name.toUpperCase() == nickname.toUpperCase())
}

public static final void main() {
String name = "leavesC";
String nickname = "leavesc";
String var10000 = name.toUpperCase();
String var10001 = nickname.toUpperCase();
boolean var2 = Intrinsics.areEqual(var10000, var10001);
System.out.println(var2);
}

以上代码就多创建了两个临时变量,这样的代码无疑会比较低效


有一个更好的解决方案,就是通过 Kotlin 提供的支持忽略大小写的 equals 扩展方法来进行比较,此方法内部会调用 String 类原生的 equalsIgnoreCase来进行比较,从而避免了创建临时变量,相对来说会比较高效一些


fun main() {
val name = "leavesC"
val nickname = "leavesc"
println(name.equals(other = nickname, ignoreCase = true))
}

public static final void main() {
String name = "leavesC";
String nickname = "leavesc";
boolean var2 = StringsKt.equals(name, nickname, true);
boolean var3 = false;
System.out.println(var2);
}

慎用 arrayOf


Kotlin 中的数组类型可以分为两类:



  • IntArray、LongArray、FloatArray 形式的基本数据类型数组,通过 intArrayOf、longArrayOf、floatArrayOf 等方法来声明

  • Array<T> 形式的对象类型数组,通过 arrayOf、arrayOfNulls 等方法来声明


例如,以下的 Kotlin 代码都是用于声明整数数组,但实际上存储的数据类型并不一样


val intArray: IntArray = intArrayOf(1, 2, 3)

val integerArray: Array<Int> = arrayOf(1, 2, 3)

将以上代码反编译为 Java 代码后,就可以明确地看出一种是基本数据类型 int,一种是包装类型 Integer,arrayOf 方法会自动对入参值进行装箱


private final int[] intArray = new int[]{1, 2, 3};

private final Integer[] integerArray = new Integer[]{1, 2, 3};

为了表示基本数据类型的数组,Kotlin 为每一种基本数据类型都提供了若干相应的类并做了特殊的优化。例如,IntArray、ByteArray、BooleanArray 等类型都会被编译成普通的 Java 基本数据类型数组:int[]、byte[]、boolean[],这些数组中的值在存储时不会进行装箱操作,而是使用了可能的最高效的方式


因此,如果没有必要的话,我们在开发中要慎用 arrayOf 方法,避免不必要的装箱消耗


慎用 vararg


和 Java 一样,Kotlin 也支持可变参数,允许将任意多个参数打包到一个数组中再一并传给函数,Kotlin 通过使用 varage 关键字来声明可变参数


我们可以向 printValue 方法传递任意数量的入参参数,也可以直接传入一个数组对象,但 Kotlin 要求显式地解包数组,以便每个数组元素在函数中能够作为单独的参数来调用,这个功能被称为展开运算符,使用方式就是在数组前加一个 *


fun printValue(vararg values: Int) {
values.forEach {
println(it)
}
}

fun main() {
printValue()
printValue(1)
printValue(2, 3)
val values = intArrayOf(4, 5, 6)
printValue(*values)
}

如果我们是以直接传递若干个入参参数的形式来调用 printValue 方法的话,Kotlin 会自动将这些参数打包为一个数组进行传递,这里面就包含着创建数组的开销,这方面和 Java 保持一致。 如果我们传入的参数就已经是数组的话,Kotlin 相比 Java 就存在着一个隐藏开销,Kotlin 会复制现有数组作为参数拿来使用,相当于多分配了额外的数组空间,这可以从反编译后的 Java 代码看出来


   public static final void printValue(@NotNull int... values) {
Intrinsics.checkNotNullParameter(values, "values");
int $i$f$forEach = false;
int[] var3 = values;
int var4 = values.length;

for(int var5 = 0; var5 < var4; ++var5) {
int element$iv = var3[var5];
int var8 = false;
boolean var9 = false;
System.out.println(element$iv);
}

}

public static final void main() {
printValue();
printValue(1);
printValue(2, 3);
int[] values = new int[]{4, 5, 6};
//复制后再进行调用
printValue(Arrays.copyOf(values, values.length));
}

// $FF: synthetic method
public static void main(String[] var0) {
main();
}

可以看到 Kotlin 会通过 Arrays.copyOf 复制现有数组,将复制后的数组作为参数进行调用,这样做的好处就是可以避免 printValue 方法影响到原有数组,坏处就是会额外消耗多一份的内存空间


慎用 lazy


我们经常会使用lazy()函数来惰性加载只读属性,将加载操作延迟到需要使用的时候,适用于某些不适合立刻加载或者加载成本较高的情况


例如,以下的 lazyValue 只会等到我们调用到的时候才会被赋值


val lazyValue by lazy {
"it is lazy value"
}

而在使用lazy()函数时很容易被忽略的地方就是其包含有一个可选的 model 参数:



  • LazyThreadSafetyMode.SYNCHRONIZED。只允许由单个线程来完成初始化,且初始化操作包含有双重锁检查,从而使得所有线程都得到相同的值

  • LazyThreadSafetyMode.PUBLICATION。允许多个线程同时执行初始化操作,但只有第一个初始化成功的值会被当做最终值,最终所有线程也都会得到相同的值

  • LazyThreadSafetyMode.NONE。允许多个线程同时执行初始化操作,不进行任何线程同步,导致不同线程可能会得到不同的初始化值,因此不应该用于多线程环境


lazy()函数默认情况下使用的就是LazyThreadSafetyMode.SYNCHRONIZED,从 SynchronizedLazyImpl 可以看到,其内部就使用到了synchronized来实现多线程同步,以此避免多线程竞争


public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required to enable safe publication of constructed instance
private val lock = lock ?: this

override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}

return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}

override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

private fun writeReplace(): Any = InitializedLazyImpl(value)
}

对于 Android 开发者来说,大多数情况下我们都是在主线程中调用 lazy() 函数,此时使用 LazyThreadSafetyMode.SYNCHRONIZED 就会带来不必要的线程同步开销,因此可以根据实际情况考虑替换为LazyThreadSafetyMode.NONE


慎用 lateinit var


lateinit var 适用于某些不方便马上就初始化变量的场景,用于将初始化操作延后,同时也存在一些使用上的限制:如果在未初始化的情况下就使用该变量的话会导致 NPE


例如,如果在 name 变量还未初始化时就调用了 print 方法的话,此时就会导致 NPE。且由于 lateinit var 变量不允许为 null,因此此时我们也无法通过判空来得知 name 是否已经被初始化了,而且判空操作本身也相当于在调用 name 变量,在未初始化的时候一样会导致 NPE


lateinit var name: String

fun print() {
println(name)
}

我们可以通过另一种方式来判断 lateinit 变量是否已初始化


lateinit 实际上是通过代理机制来实现的,关联的是 KProperty0 接口,KProperty0 就提供了一个扩展属性用于判断其代理的值是否已经初始化了


@SinceKotlin("1.2")
@InlineOnly
inline val @receiver:AccessibleLateinitPropertyLiteral KProperty0<*>.isInitialized: Boolean
get() = throw NotImplementedError("Implementation is intrinsic")

因此我们可以通过以下方式来进行判断,从而避免不安全的访问操作


lateinit var name: String

fun print() {
if (this::name.isInitialized) {
println("isInitialized true")
println(name)
} else {
println("isInitialized false")
println(name) //会导致 NPE
}
}

lambda 表达式


lambda 表达式在语义上很简洁,既避免了冗长的函数声明,也解决了以前需要强类型声明函数类型的情况


例如,以下代码就通过 lambda 表达式声明了一个回调函数 callback,我们无需创建一个具体的函数类型,而只需声明需要的入参参数、入参类型、函数返回值就可以


fun requestHttp(callback: (code: Int, data: String) -> Unit) {
callback(200, "success")
}

fun main() {
requestHttp { code, data ->
println("code: $code")
println("data: $data")
}
}

lambda 表达式语法虽然方便,但也隐藏着两个性能问题:



  • 每次调用 lambda 表达式都相当于在创建一个对象

  • lambda 表达式内部隐藏了自动装箱和自动拆箱的操作


将以上代码反编译为 Java 代码后,可以看到 callback 最终的实际类型就是 Function2,每次调用requestHttp 方法就相当于是在创建一个 Function2 变量


   public static final void requestHttp(@NotNull Function2 callback) {
Intrinsics.checkNotNullParameter(callback, "callback");
callback.invoke(200, "success");
}

Function2 是 Kotlin 提供的一个的泛型接口,数字 2 即代表其包含两个入参值


public interface Function2<in P1, in P2, out R> : Function<R> {
/** Invokes the function with the specified arguments. */
public operator fun invoke(p1: P1, p2: P2): R
}

Kotlin 会在编译阶段将开发者声明的 lambda 表达式转换为相应的 FunctionX 对象,调用 lambda 表达式就相当于在调用其 invoke 方法,以此为低版本 JVM 平台(例如 Java 6 / 7)也能提供 lambda 表达式功能。此外,我们也知道泛型类型不可能是基本数据类型,因此我们在 Kotlin 中声明的 Int 最终会被自动装箱为 Integer,lambda 表达式内部自动完成了装箱和拆箱的操作


所以说,简洁的 lambda 表达式背后就隐藏了自动创建 Function 对象进行中转调用,自动装箱和自动拆箱的过程,且最终创建的方法总数要多于表面上看到的


如果想要避免 lambda 表达式的以上开销,可以通过使用 inline 内联函数来实现


在使用 inline 关键字修饰 requestHttp 方法后,可以看到此时 requestHttp 的逻辑就相当于被直接复制到了 main 方法内部,不会创建任何多余的对象,且此时使用的也是 int 而非 Integer


inline fun requestHttp(callback: (code: Int, data: String) -> Unit) {
callback(200, "success")
}

fun main() {
requestHttp { code, data ->
println("code: $code")
println("data: $data")
}
}

   public static final void main() {
String data = "success";
int code = 200;
String var4 = "code: " + code;
System.out.println(var4);
var4 = "data: " + data;
System.out.println(var4);
}

通过内联函数,可以使得编译器直接在调用方中使用内联函数体中的代码,相当于直接把内联函数中的逻辑复制到了调用方中,完全避免了调用带来的开销。对于高阶函数,作为参数传递的 lambda 表达式的主体也将被内联,这使得:



  • 声明和调用 lambda 表达式时,不会实例化 Function 对象

  • 没有自动装箱和拆箱的操作

  • 不会导致方法数增多,但如果内联函数方法体较大且被多处调用的话,可能导致最终代码量显著增加


init 的声明顺序很重要


看以下代码,我们可以在 init 块中调用 parameter1,却无法调用 parameter2,从 IDE 的提示信息 Variable 'parameter2' must be initialized也可以看出来,对于 init 块来说 parameter2 此时还未赋值,自然就无法使用了


class KotlinMode {

private val parameter1 = "leavesC"

init {
println(parameter1)
//error: Variable 'parameter2' must be initialized
//println(parameter2)
}

private val parameter2 = "业志陈"

}

从反编译出的 Java 代码也可以看出来,由于 parameter2 是声明在 init 块之后,所以 parameter2 的赋值操作其实是放在构造函数中的最后面,因此 IDE 的语法检查器就会阻止我们在 init 块中来调用 parameter2 了


public final class KotlinMode {
private final String parameter1 = "leavesC";
private final String parameter2;

public KotlinMode() {
String var1 = this.parameter1;
System.out.println(var1);
this.parameter2 = "业志陈";
}
}

IDE 会阻止开发者去调用还未初始化的变量,防止我们写出不安全的代码,我们也可以用以下方式来绕过语法检查,但同时也写出了不安全的代码


我们可以通过在 init 块中调用 print() 方法的方式来间接访问 parameter2,此时代码是可以正常编译的,但此时 parameter2 也只会为 null


class KotlinMode {

private val parameter1 = "leavesC"

init {
println(parameter1)
print()
}

private fun print() {
println(parameter2)
}

private val parameter2 = "业志陈"

}

从反编译出的 Java 代码可以看出来,print()方法依旧是会在 parameter2 初始化之前被调用,此时print()方法访问到的 parameter2 也只会为 null,从而引发意料之外的 NPE


public final class KotlinMode {
private final String parameter1 = "leavesC";
private final String parameter2;

private final void print() {
String var1 = this.parameter2;
System.out.println(var1);
}

public KotlinMode() {
String var1 = this.parameter1;
System.out.println(var1);
this.print();
this.parameter2 = "业志陈";
}
}

所以说,init 块和成员变量之间的声明顺序决定了在构造函数中的初始化顺序,我们应该先声明成员变量再声明 init 块,否则就有可能导致 NPE


Gosn & data class


来看个小例子,猜猜其运行结果会是怎样的


UserBean 是一个 dataClass,其 userName 字段被声明为非 null 类型,而 json 字符串中 userName 对应的值明确就是 null,那用 Gson 到底能不能反序列化成功呢?程序能不能成功运行完以下三个步骤?


data class UserBean(val userName: String, val userAge: Int)

fun main() {
val json = """{"userName":null,"userAge":26}"""
val userBean = Gson().fromJson(json, UserBean::class.java) //第一步
println(userBean) //第二步
printMsg(userBean.userName) //第三步
}

fun printMsg(msg: String) {

}

实际上程序能够正常运行到第二步,但在执行第三步的时候反而直接报 NPE 异常了


UserBean(userName=null, userAge=26)
Exception in thread "main" java.lang.NullPointerException: Parameter specified as non-null is null: method temp.TestKt.printMsg, parameter msg
at temp.TestKt.printMsg(Test.kt)
at temp.TestKt.main(Test.kt:16)
at temp.TestKt.main(Test.kt)

printMsg 方法接收了参数后实际上什么也没做,为啥会抛出 NPE ?


printMsg反编译为 Java 方法,可以发现方法内部会对入参进行空校验,当发现为 null 时就会直接抛出 NPE。这个比较好理解,毕竟 Kotlin 的类型系统会严格区分 可 null不可为 null 两种类型,其区分手段之一就是会自动在我们的代码里插入一些类型校验逻辑,即自动加上了非空断言,当发现不可为 null 的参数传入了 null 的话就会马上抛出 NPE,即使我们并没有使用到该参数


   public static final void printMsg(@NotNull String msg) {
Intrinsics.checkNotNullParameter(msg, "msg");
}

那既然 UserBean 中的 userName 字段已经被声明为非 null 类型了,那么为什么还可以反序列化成功呢?按照我自己的第一直觉,应该在进行反序列的时候就直接抛出异常才对


将 UserBean 反编译为 Java 代码后,也可以看到其构造函数中是有对 userName 进行 null 检查的,当发现为 null 的话会直接抛出 NPE


public final class UserBean {
@NotNull
private final String userName;
private final int userAge;

@NotNull
public final String getUserName() {
return this.userName;
}

public final int getUserAge() {
return this.userAge;
}

public UserBean(@NotNull String userName, int userAge) {
//进行 null 检查
Intrinsics.checkNotNullParameter(userName, "userName");
super();
this.userName = userName;
this.userAge = userAge;
}

···

}

那 Gson 是怎么绕过 Kotlin 的 null 检查的呢?


其实,通过查看 Gson 内部源码,可以知道 Gson 是通过 Unsafe 包来实例化 UserBean 对象的,Unsafe 提供了一个非常规实例化对象的方法:allocateInstance,该方法提供了通过 Class 对象就可以创建出相应实例的功能,而且不需要调用其构造函数、初始化代码、JVM 安全检查等,即使构造函数是 private 的也能通过此方法进行实例化。因此 Gson 实际上并不会调用到 UserBean 的构造函数,相当于绕过了 Kotlin 的 null 检查,所以即使 userName 值为 null 最终也能够反序列化成功



此问题的出现场景大多是在移动端解析服务端传来的数据的时候,移动端将数据声明为非空类型,但服务端给过来的数据却为 null 值,此时用户看到的可能就是应用崩溃了……


一方面,我觉得移动端应该对服务端传来的数据保持不信任的态度,不能觉得对方传来的数据就一定是符合约定的,为了保证安全需要将数据均声明为可空类型。另一方面,这也无疑导致移动端需要加上很多多余的判空操作,简直有点无解 =_=


ARouter & JvmField


在 Java 中,字段和其访问器的组合被称作属性。在 Kotlin 中,属性是头等的语言特性,完全替代了字段和访问器方法。在类中声明一个属性和声明一个变量一样是使用 val 和 var 关键字,两者在使用上的差异就在于赋值后是否还允许修改,在字节码上的差异性之一就在于是否会自动生成相应的 setValue 方法


例如,以下的 Kotlin 代码在反编译为 Java 代码后,可以看到两个属性的可见性都变为了 private, name 变量会同时包含有getValuesetValue 方法,而 nickname 变量只有 getValue 方法,这也是我们在 Java 代码中只能以 kotlinMode.getName() 的方式来访问 name 变量的原因


class KotlinMode {

var name = "业志陈"

val nickname = "leavesC"

}

public final class KotlinMode {
@NotNull
private String name = "业志陈";
@NotNull
private final String nickname = "leavesC";

@NotNull
public final String getName() {
return this.name;
}

public final void setName(@NotNull String var1) {
Intrinsics.checkNotNullParameter(var1, "<set-?>");
this.name = var1;
}

@NotNull
public final String getNickname() {
return this.nickname;
}
}

为了不让 Kotlin 的 var / val 变量自动生成 getValuesetValue 方法,达到和在 Java 代码中声明公开变量一样的效果,此时就需要为属性添加 @JvmField 注解了,添加后就会变为 public 类型的成员变量,且不包含任何 getValuesetValue 方法


class KotlinMode {

@JvmField
var name = "业志陈"

@JvmField
val nickname = "leavesC"

}

public final class KotlinMode {
@JvmField
@NotNull
public String name = "业志陈";
@JvmField
@NotNull
public final String nickname = "leavesC";
}



@JvmField 的一个使用场景就是在配套使用 ARouter 的时候。我们在使用 ARouter 进行参数自动注入时,就需要为待注入的参数添加 @JvmField注解,就像以下代码一样,不添加的话就会导致编译失败


@Route(path = RoutePath.USER_HOME)
class UserHomeActivity : AppCompatActivity() {

@Autowired(name = RoutePath.USER_HOME_PARAMETER_ID)
@JvmField
var userId: Long = 0

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_user_home)
ARouter.getInstance().inject(this)
}

}

那为什么不添加该注解就会导致编译失败呢?


其实,ARouter 实现参数自动注入是需要依靠注解处理器生成的辅助文件来实现的,即会生成以下的辅助代码,当中会以 substitute.userIdsubstitute.userName的形式来调用 Activity 中的两个参数值,如果不添加 @JvmField注解,辅助文件就没法以直接调用变量名的方式来完成注入,自然就会导致编译失败了


public class UserHomeActivity$$ARouter$$Autowired implements ISyringe {

private SerializationService serializationService;

@Override
public void inject(Object target) {
serializationService = ARouter.getInstance().navigation(SerializationService.class);
UserHomeActivity substitute = (UserHomeActivity)target;
substitute.userId = substitute.getIntent().getLongExtra("userHomeId", substitute.userId);
}
}

Kotlin 这套为属性自动生成 getValuesetValue 方法的机制有一个缺点,就是可能会导致方法数极速膨胀,使得 Android App 的 dex 文件很快就达到最大方法数限制,不得不进行分包处理


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

Android 弹幕的两种实现及性能对比 | 自定义 LayoutManager

引子 上一篇用“动画”方案实现了弹幕效果,自定义容器控件,每一条弹幕都作为其子控件,将子弹幕的初始位置置于容器控件右边的外侧,每条弹幕都通过从右向左的动画来实现贯穿屏幕的平移。 这个方案的性能有待改善,打开 GPU 呈现模式: 原因在于容器控件会提前构建所有...
继续阅读 »

引子


上一篇用“动画”方案实现了弹幕效果,自定义容器控件,每一条弹幕都作为其子控件,将子弹幕的初始位置置于容器控件右边的外侧,每条弹幕都通过从右向左的动画来实现贯穿屏幕的平移。


这个方案的性能有待改善,打开 GPU 呈现模式:


1629556466944.gif


原因在于容器控件会提前构建所有弹幕视图并将它们堆积在屏幕的右侧。若弹幕数据量大,则容器控件会因为子视图过多而耗费大量 measure + layout 时间。


既然是因为提前加载了不需要的弹幕才导致的性能问题,那是不是可以只预加载有限个弹幕?


只加载有限个子视图且可滚动的控件,不就是 RecyclerView 吗!它并不会把 Adapter 中所有的数据提前全部转换成 View,而是只预加载一屏的数据,然后随着滚动再持续不断地加载新数据。


为了用 RecyclerView 实现弹幕效果,就得 “自定义 LayoutManager”


自定义布局参数


自定义 LayoutManager 的第一步:继承RecyclerView.LayoutManger


class LaneLayoutManager: RecyclerView.LayoutManager() {
override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {}
}

根据 AndroidStudio 的提示,必须实现一个generateDefaultLayoutParams()的方法。它用于生成一个自定义的LayoutParams对象,目的是在布局参数中携带自定义的属性。


当前场景中没有自定义布局参数的需求,遂可以这样实现这个方法:


override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
return RecyclerView.LayoutParams(
RecyclerView.LayoutParams.WRAP_CONTENT,
RecyclerView.LayoutParams.WRAP_CONTENT
)
}

表示沿用RecyclerView.LayoutParams


初次填充弹幕


自定义 LayoutManager 最重要的环节就是定义如何布局表项。


对于LinearLayoutManager来说,表项沿着一个方向线性铺开。当列表第一次展示时,从列表顶部到底部,表项被逐个填充,这称为“初次填充”。


对于LaneLayoutManager来说,初次填充即是“将一列弹幕填充到紧挨着列表尾部的地方(在屏幕之外,可不见)”。


关于LinearLayoutManager如何填充表项的源码分析,在之前的一篇RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?中分析过,现援引结论如下:




  1. LinearLayoutManager 在onLayoutChildren()方法中布局表项。

  2. 布局表项的关键方法包括fill()layoutChunk(),前者表示列表的一次填充动作,后者表示填充单个表项。

  3. 在一次填充动作中通过一个while循环不断地填充表项,直到列表剩余空间用完。用伪代码表示这个过程如下所示:


public class LinearLayoutManager {
// 布局表项
public void onLayoutChildren() {
// 填充表项
fill() {
while(列表有剩余空间){
// 填充单个表项
layoutChunk(){
// 让表项成为子视图
addView(view)
}
}
}
}
}


  1. 为了避免每次填充新表项时都重新创建视图,需要从 RecyclerView 的缓存中获取表项视图,即调用Recycler.getViewForPosition()。关于该方法的详解可以点击RecyclerView 缓存机制 | 如何复用表项?



看过源码,理解原理后,弹幕布局就可以仿照着写:


class LaneLayoutManager : RecyclerView.LayoutManager() {
private val LAYOUT_FINISH = -1 // 标记填充结束
private var adapterIndex = 0 // 列表适配器索引

// 弹幕纵向间距
var gap = 5
get() = field.dp

// 布局孩子
override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
fill(recycler)
}
// 填充表项
private fun fill(recycler: RecyclerView.Recycler?) {
// 可供弹幕布局的高度,即列表高度
var totalSpace = height - paddingTop - paddingBottom
var remainSpace = totalSpace
// 只要空间足够,就继续填充表项
while (goOnLayout(remainSpace)) {
// 填充单个表项
val consumeSpace = layoutView(recycler)
if (consumeSpace == LAYOUT_FINISH) break
// 更新剩余空间
remainSpace -= consumeSpace
}
}

// 是否还有剩余空间用于填充 以及 是否有更多数据
private fun goOnLayout(remainSpace: Int) = remainSpace > 0 && currentIndex in 0 until itemCount

// 填充单个表项
private fun layoutView(recycler: RecyclerView.Recycler?): Int {
// 1. 从缓存池中获取表项视图
// 若缓存未命中,则会触发 onCreateViewHolder() 和 onBindViewHolder()
val view = recycler?.getViewForPosition(adapterIndex)
view ?: return LAYOUT_FINISH // 获取表项视图失败,则结束填充
// 2. 将表项视图成为列表孩子
addView(view)
// 3. 测量表项视图
measureChildWithMargins(view, 0, 0)
// 可供弹幕布局的高度,即列表高度
var totalSpace = height - paddingTop - paddingBottom
// 弹幕泳道数,即列表纵向可以容纳几条弹幕
val laneCount = (totalSpace + gap) / (view.measuredHeight + gap)
// 计算当前表项所在泳道
val index = currentIndex % laneCount
// 计算当前表项上下左右边框
val left = width // 弹幕左边位于列表右边
val top = index * (view.measuredHeight + gap)
val right = left + view.measuredWidth
val bottom = top + view.measuredHeight
// 4. 布局表项(该方法考虑到了 ItemDecoration)
layoutDecorated(view, left, top, right, bottom)
val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
// 继续获取下一个表项视图
adapterIndex++
// 返回填充表项消耗像素值
return getDecoratedMeasuredHeight(view) + verticalMargin
}
}

每一条水平的,供弹幕滚动的,称之为“泳道”。


泳道是从列表顶部往底部垂直铺开的,列表高度/泳道高度 = 泳道的数量。


fill()方法中就以“列表剩余高度>0”为循环条件,不断地向泳道中填充表项,它得经历了四个步骤:



  1. 从缓存池中获取表项视图

  2. 将表项视图成为列表孩子

  3. 测量表项视图

  4. 布局表项


这四步之后,表项相对于列表的位置就确定下来,并且表项的视图已经渲染完成。


运行下 demo,果然~,什么也没看到。。。


列表滚动逻辑还未加上,所以布局在列表右边外侧的表项依然处于不可见位置。但可以利用 AndroidStudio 的Layout Inspector工具来验证初次填充代码的正确性:


微信截图_20210919225802.png


Layout Inspector中会用线框表示屏幕以外的控件,如图所示,列表右边的外侧被四个表项占满。


自动滚动弹幕


为了看到填充的表项,就得让列表自发地滚动起来。


最直接的方案就是不停地调用RecyclerView.smoothScrollBy()。为此写了一个扩展法方法用于倒计时:


fun <T> countdown(
duration: Long, // 倒计时总时长
interval: Long, // 倒计时间隔
onCountdown: suspend (Long) -> T // 倒计时回调
): Flow<T> =
flow { (duration - interval downTo 0 step interval).forEach { emit(it) } }
.onEach { delay(interval) }
.onStart { emit(duration) }
.map { onCountdown(it) }
.flowOn(Dispatchers.Default)

使用Flow构建了一个异步数据流,该流每次都会发射一个倒计时的剩余时间。关于Flow的详细解释可以点击Kotlin 异步 | Flow 应用场景及原理


然后就能像这样实现列表自动滚动:


countdown(Long.MAX_VALUE, 50) {
recyclerView.smoothScrollBy(10, 0)
}.launchIn(MainScope())

每 50 ms 向左滚动 10 像素。效果如下图所示:


1632126431947.gif


持续填充弹幕


因为只做了初次填充,即每个泳道只填充了一个表项,所以随着第一排的表项滚入屏幕后,就没有后续弹幕了。


LayoutManger.onLayoutChildren()只会在列表初次布局时调用一次,即初次填充弹幕只会执行一次。为了持续不断地展示弹幕,必须在滚动时不停地填充表项。


之前的一篇RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?分析过列表滚动时持续填充表项的源码,现援引结论如下:




  1. RecyclerView 在滚动发生之前,会根据预计滚动位移大小来决定需要向列表中填充多少新的表项。

  2. 表现在源码上,即是在scrollVerticallyBy()中调用fill()填充表项:


public class LinearLayoutManager {
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
return scrollBy(dy, recycler, state);
}

int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
...
// 填充表项
final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
...
}
}


对于弹幕的场景,也可以仿照着写一个类似的:


class LaneLayoutManager : RecyclerView.LayoutManager() {
override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
return scrollBy(dx, recycler)
}

override fun canScrollHorizontally(): Boolean {
return true // 表示列表可以横向滚动
}
}

重写canScrollHorizontally()返回 true 表示列表可横向滚动。


RecyclerView 的滚动是一段一段进行的,每一段滚动的位移都会通过scrollHorizontallyBy()传递过来。通常在该方法中根据位移大小填充新的表项,然后再触发列表的滚动。关于列表滚动的源码分析可以点击RecyclerView 的滚动是怎么实现的?(一)| 解锁阅读源码新姿势


scrollBy()封装了根据滚动持续填充表项的逻辑。(稍后分析)


持续填充表项比初次填充的逻辑更复杂一点,初次填充只要将表项按照泳道从上到下依次铺开填满列表的高度即可。而持续填充得根据滚动距离计算出哪个泳道即将枯竭(没有弹幕展示的泳道),只对枯竭的泳道填充表项。


为了快速获取枯竭泳道,得抽象出一个“泳道”结构,以保存该泳道的滚动信息:


// 泳道
data class Lane(
var end: Int, // 泳道末尾弹幕横坐标
var endLayoutIndex: Int, // 泳道末尾弹幕的布局索引
var startLayoutIndex: Int // 泳道头部弹幕的布局索引
)

泳道结构包含三个数据,分别是:



  1. 泳道末尾弹幕横坐标:它是泳道中最后一个弹幕的 right 值,即它的右侧相对于 RecyclerView 左侧的距离。该值用于判断经过一段位移的滚动后,该泳道是否会枯竭。

  2. 泳道末尾弹幕的布局索引:它是泳道中最后一个弹幕的布局索引,记录它是为了方便地通过getChildAt()获取泳道中最后一个弹幕的视图。(布局索引有别于适配器索引,RecyclerView 只会持有有限个表项,所以布局索引的取值范围是[0,x],x的取值比一屏表项稍多一点,而对于弹幕来说,适配器索引的取值是[0,∞])

  3. 泳道头部弹幕的布局索引:与 2 类似,为了方便地获得泳道第一个弹幕的视图。


借助于泳道这个结构,我们得重构下初次填充表项的逻辑:


class LaneLayoutManager : RecyclerView.LayoutManager() {
// 初次填充过程中的上一个被填充的弹幕
private var lastLaneEndView: View? = null
// 所有泳道
private var lanes = mutableListOf<Lane>()
// 初次填充弹幕
override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
fillLanes(recycler, lanes)
}
// 通过循环填充弹幕
private fun fillLanes(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>) {
lastLaneEndView = null
// 如果列表垂直方向上还有空间则继续填充弹幕
while (hasMoreLane(height - lanes.bottom())) {
// 填充单个弹幕到泳道中
val consumeSpace = layoutView(recycler, lanes)
if (consumeSpace == LAYOUT_FINISH) break
}
}
// 填充单个弹幕,并记录泳道信息
private fun layoutView(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>): Int {
val view = recycler?.getViewForPosition(adapterIndex)
view ?: return LAYOUT_FINISH
measureChildWithMargins(view, 0, 0)
val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
val consumed = getDecoratedMeasuredHeight(view) + if (lastLaneEndView == null) 0 else verticalGap + verticalMargin
// 若列表垂直方向还可以容纳一条新得泳道,则新建泳道,否则停止填充
if (height - lanes.bottom() - consumed > 0) {
lanes.add(emptyLane(adapterIndex))
} else return LAYOUT_FINISH

addView(view)
// 获取最新追加的泳道
val lane = lanes.last()
// 计算弹幕上下左右的边框
val left = lane.end + horizontalGap
val top = if (lastLaneEndView == null) paddingTop else lastLaneEndView!!.bottom + verticalGap
val right = left + view.measuredWidth
val bottom = top + view.measuredHeight
// 定位弹幕
layoutDecorated(view, left, top, right, bottom)
// 更新泳道末尾横坐标及布局索引
lane.apply {
end = right
endLayoutIndex = childCount - 1 // 因为是刚追加的表项,所以其索引值必然是最大的
}

adapterIndex++
lastLaneEndView = view
return consumed
}
}

初次填充弹幕也是一个不断在垂直方向上追加泳道的过程,判断是否追加的逻辑如下:列表高度 - 当前最底部泳道的 bottom 值 - 这次填充弹幕消耗的像素值 > 0,其中lanes.bottom()是一个List<Lane>的扩展方法:


fun List<Lane>.bottom() = lastOrNull()?.getEndView()?.bottom ?: 0

它获取泳道列表中的最后一个泳道,然后再获取该泳道中最后一条弹幕视图的 bottom 值。其中getEndView()被定义为Lane的扩展方法:


class LaneLayoutManager : RecyclerView.LayoutManager() {
data class Lane(var end: Int, var endLayoutIndex: Int, var startLayoutIndex: Int)
private fun Lane.getEndView(): View? = getChildAt(endLayoutIndex)
}

理论上“获取泳道中最后一条弹幕视图”应该是Lane提供的方法。但偏偏把它定义成Lane的扩展方法,并且还定义在LaneLayoutManager的内部,这是多此一举吗?


若定义在 Lane 内部,则在该上下文中无法访问到LayoutManager.getChildAt()方法,若只定义为LaneLayoutManager的私有方法,则无法访问到endLayoutIndex。所以此举是为了综合两个上下文环境。


再回头看一下滚动时持续填充弹幕的逻辑:


class LaneLayoutManager : RecyclerView.LayoutManager() {
override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int {
return scrollBy(dx, recycler)
}
// 根据位移大小决定填充多少表项
private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
// 若列表没有孩子或未发生滚动则返回
if (childCount == 0 || dx == 0) return 0
// 在滚动还未开始前,更新泳道信息
updateLanesEnd(lanes)
// 获取滚动绝对值
val absDx = abs(dx)
// 遍历所有泳道,向其中的枯竭泳道填充弹幕
lanes.forEach { lane ->
if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
}
// 滚动列表的落脚点:将表项向手指位移的反方向平移相同的距离
offsetChildrenHorizontal(-absDx)
return dx
}
}

滚动时持续填充弹幕逻辑遵循这样的顺序:



  1. 更新泳道信息

  2. 向枯竭泳道填充弹幕

  3. 触发滚动


其中 1,2 都发生在真实的滚动之前,在滚动之前,已经拿到了滚动位移,根据位移就可以计算出滚动发生之后即将枯竭的泳道:


// 泳道是否枯竭
private fun Lane.isDrainOut(dx: Int): Boolean = getEnd(getEndView()) - dx < width
// 获取表项的 right 值
private fun getEnd(view: View?) =
if (view == null) Int.MIN_VALUE
else getDecoratedRight(view) + (view.layoutParams as RecyclerView.LayoutParams).rightMargin

泳道枯竭的判定依据是:泳道最后一个弹幕的右边向左平移 dx 后是否小于列表宽度。若小于则表示泳道中的弹幕已经全展示完了,此时就要继续填充弹幕:


// 弹幕滚动时填充新弹幕
private fun layoutViewByScroll(recycler: RecyclerView.Recycler?, lane: Lane) {
val view = recycler?.getViewForPosition(adapterIndex)
view ?: return
measureChildWithMargins(view, 0, 0)
addView(view)

val left = lane.end + horizontalGap
val top = lane.getEndView()?.top ?: paddingTop
val right = left + view.measuredWidth
val bottom = top + view.measuredHeight
layoutDecorated(view, left, top, right, bottom)
lane.apply {
end = right
endLayoutIndex = childCount - 1
}
adapterIndex++
}

填充逻辑和初次填充的几乎一样,唯一的区别是,滚动时的填充不可能因为空间不够而提前返回,因为是找准了泳道进行填充的。


为什么要在填充枯竭泳道之前更新泳道信息?


// 更新泳道信息
private fun updateLanesEnd(lanes: MutableList<Lane>) {
lanes.forEach { lane ->
lane.getEndView()?.let { lane.end = getEnd(it) }
}
}

因为 RecyclerView 的滚动是一段一段进行的,看似滚动了一丢丢距离,scrollHorizontallyBy()可能要回调十几次,每一次回调,弹幕都会前进一小段,即泳道末尾弹幕的横坐标会发生变化,这变化得同步到Lane结构中。否则泳道枯竭的计算就会出错。


无限滚动弹幕


经过初次和持续填充,弹幕已经可以流畅的滚起来了。那如何让仅有的弹幕数据无限轮播呢?


只需要在Adapter上做一个小手脚:


class LaneAdapter : RecyclerView.Adapter<ViewHolder>() {
// 数据集
private val dataList = MutableList()
override fun getItemCount(): Int {
// 设置表项为无穷大
return Int.MAX_VALUE
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val realIndex = position % dataList.size
...
}

override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
val realIndex = position % dataList.size
...
}
}

设置列表的数据量为无穷大,当创建表项视图及为其绑定数据时,对适配器索引取模。


回收弹幕


剩下的最后一个难题是,如何回收弹幕。若没有回收,也对不起RecyclerView这个名字。


LayoutManager中就定义有回收表项的入口:


public void removeAndRecycleView(View child, @NonNull Recycler recycler) {
removeView(child);
recycler.recycleView(child);
}

回收逻辑最终会委托给Recycler实现,关于回收表项的源码分析,可以点击下面的文章:



  1. RecyclerView 缓存机制 | 回收些什么?

  2. RecyclerView 缓存机制 | 回收到哪去?

  3. RecyclerView 动画原理 | 换个姿势看源码(pre-layout)

  4. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系

  5. RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?


对于弹幕场景,什么时候回收弹幕?


当然是弹幕滚出屏幕的那一瞬间!


如何才能捕捉到这个瞬间 ?


当然是通过在每次滚动发生之前用位移计算出来的!


在滚动时除了要持续填充弹幕,还得持续回收弹幕(源码里就是这么写的,我只是抄袭一下):


private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int {
if (childCount == 0 || dx == 0) return 0
updateLanesEnd(lanes)
val absDx = abs(dx)
// 持续填充弹幕
lanes.forEach { lane ->
if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
}
// 持续回收弹幕
recycleGoneView(lanes, absDx, recycler)
offsetChildrenHorizontal(-absDx)
return dx
}

这是scrollBy()的完整版,滚动时先填充,紧接着马上回收:


fun recycleGoneView(lanes: List<Lane>, dx: Int, recycler: RecyclerView.Recycler?) {
recycler ?: return
// 遍历泳道
lanes.forEach { lane ->
// 获取泳道头部弹幕
getChildAt(lane.startLayoutIndex)?.let { startView ->
// 如果泳道头部弹幕已经滚出屏幕则回收它
if (isGoneByScroll(startView, dx)) {
// 回收弹幕视图
removeAndRecycleView(startView, recycler)
// 更新泳道信息
updateLaneIndexAfterRecycle(lanes, lane.startLayoutIndex)
lane.startLayoutIndex += lanes.size - 1
}
}
}
}

回收和填充一样,也是通过遍历找到即将消失的弹幕,回收之。


判断弹幕消失的逻辑如下:


fun isGoneByScroll(view: View, dx: Int): Boolean = getEnd(view) - dx < 0

如果弹幕的 right 向左平移 dx 后小于 0 则表示弹幕已经滚出列表。


回收弹幕之后,会将其从 RecyclerView 中 detach,这个操作会影响列表中其他弹幕的布局索引值。就好像数组中某一元素被删除,其后面的所有元素的索引值都会减一:


fun updateLaneIndexAfterRecycle(lanes: List<Lane>, recycleIndex: Int) {
lanes.forEach { lane ->
if (lane.startLayoutIndex > recycleIndex) {
lane.startLayoutIndex--
}
if (lane.endLayoutIndex > recycleIndex) {
lane.endLayoutIndex--
}
}
}

遍历所有泳道,只要泳道头部弹幕的布局索引大于回收索引,则将其减一。


性能


再次打开 GPU 呈现模式:


1629555943171.gif


这次体验上就很丝滑,柱状图也没有超过警戒线。


talk is cheap, show me the code


完整代码可以点击这里,在这个repo中搜索LaneLayoutManager


总结


之前花了很多时间看源码,也产生过“看源码那么费时,到底有什么用?”这样的怀疑。这次性能优化是一次很好的回应。因为看过 RecyclerView 的源码,它解决问题的思想方法就种在脑袋里了。当遇到弹幕性能问题时,这颗种子就会发芽。解决方案是多种多样的,脑袋中有怎样的种子,就会长出怎样的芽。所以看源码是播撒种子,虽不能立刻发芽,但总有一天会结果。


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

iOS RXSwift 3.2

iOS
函数式编程 -> 函数响应式编程现在大家已经了解我们是如何运用函数式编程来操作序列的。其实我们可以把这种操作序列的方式再升华一下。例如,你可以把一个按钮的点击事件看作是一个序列:// 假设用户在进入页面到离开页面期间,总共点击按钮 3 次 // 按钮点...
继续阅读 »

函数式编程 -> 函数响应式编程

现在大家已经了解我们是如何运用函数式编程来操作序列的。其实我们可以把这种操作序列的方式再升华一下。例如,你可以把一个按钮的点击事件看作是一个序列:

// 假设用户在进入页面到离开页面期间,总共点击按钮 3 次

// 按钮点击序列
let taps: Array<Void> = [(), (), ()]

// 每次点击后弹出提示框
taps.forEach { showAlert() }

这样处理点击事件是非常理想的,但是问题是这个序列里面的元素(点击事件)是异步产生的,传统序列是无法描叙这种元素异步产生的情况。为了解决这个问题,于是就产生了可监听序列Observable<Element>。它也是一个序列,只不过这个序列里面的元素可以是同步产生的,也可以是异步产生的:

// 按钮点击序列
let taps: Observable<Void> = button.rx.tap.asObservable()

// 每次点击后弹出提示框
taps.subscribe(onNext: { showAlert() })

这里 taps 就是按钮点击事件的序列。然后我们通过弹出提示框,来对每一次点击事件做出响应。这种编程方式叫做响应式编程。我们结合函数式编程以及响应式编程就得到了函数响应式编程

passwordOutlet.rx.text.orEmpty
.map { $0.characters.count >= minimalPasswordLength }
.bind(to: passwordValidOutlet.rx.isHidden)
.disposed(by: disposeBag)

我们通过不同的构建函数,来创建所需要的数据序列。最后通过适当的方式来响应这个序列。这就是函数响应式编程

收起阅读 »

iOS RXSwift 3.1

iOS
函数响应式编程函数响应式编程是种编程范式。它是通过构建函数操作数据序列,然后对这些序列做出响应的编程方式。它结合了函数式编程以及响应式编程这里先介绍一下函数式编程。函数式编程函数式编程是种编程范式,它需要我们将函数作为参数传递,或者作为返回值返还。我们可以通过...
继续阅读 »

函数响应式编程

函数响应式编程是种编程范式。它是通过构建函数操作数据序列,然后对这些序列做出响应的编程方式。它结合了函数式编程以及响应式编程

这里先介绍一下函数式编程


函数式编程

函数式编程是种编程范式,它需要我们将函数作为参数传递,或者作为返回值返还。我们可以通过组合不同的函数来得到想要的结果。

我们来看一下这几个例子:

// 全校学生
let allStudents: [Student] = getSchoolStudents()

// 三年二班的学生
let gradeThreeClassTwoStudents: [Student] = allStudents
.filter { student in student.grade == 3 && student.class == 2 }

由于我们想要得到三年二班的学生,所以我们把三年二班的判定函数作为参数传递给 filter 方法,这样就能从全校学生中过滤出三年二班的学生。

// 三年二班的每一个男同学唱一首《一剪梅》
gradeThreeClassTwoStudents
.filter { student in student.sex == .male }
.forEach { boy in boy.singASong(name: "一剪梅") }

同样的我们将性别的判断函数传递给 filter 方法,这样就能从三年二班的学生中过滤出男同学,然后将唱歌作为函数传递给 forEach 方法。于是每一个男同学都要唱《一剪梅》😄。

// 三年二班学生成绩高于90分的家长上台领奖
gradeThreeClassTwoStudents
.filter { student in student.score > 90 }
.map { student in student.parent }
.forEach { parent in parent.receiveAPrize() }

用分数判定来筛选出90分以上的同学,然后用map转换为学生家长,最后用forEach让每个家长上台领奖。

// 由高到低打印三年二班的学生成绩
gradeThreeClassTwoStudents
.sorted { student0, student1 in student0.score > student1.score }
.forEach { student in print("score: \(student.score), name: \(student.name)") }

将排序逻辑的函数传递给 sorted方法,这样学生就按成绩高低排序,最后用forEach将成绩和学生名字打印出来。

整体结构

值得注意的是,我们先从三年二班筛选出男同学,后来又从三年二班筛选出分数高于90的学生。都是用的 filter 方法,只是传递了不同的判定函数,从而得出了不同的筛选结果。如果现在要实现这个需求:二年一班分数不足60的学生唱一首《我有罪》。

相信大家要不了多久就可以找到对应的实现方法。

这就是函数式编程,它使我们可以通过组合不同的方法,以及不同的函数来获取目标结果。你可以想象如果我们用传统的 for 循环来完成相同的逻辑,那将会是一件多么繁琐的事情。所以函数试编程的优点是显而易见的:

  • 灵活
  • 高复用
  • 简洁
  • 易维护
  • 适应各种需求变化

如果想了解更多有关于函数式编程的知识。可以参考这本书籍 《函数式 Swift》

收起阅读 »

iOS RXSwift 二

iOS
你好 RxSwift!我的第一个 RxSwift 应用程序 - 输入验证:这是一个模拟用户登录的程序。当用户输入用户名时,如果用户名不足 5 个字就给出红色提示语,并且无法输入密码,当用户名符合要求时才可以输入密码。同样的当用户输入的密码不到 5 个字时也给出...
继续阅读 »

你好 RxSwift!

我的第一个 RxSwift 应用程序 - 输入验证:

这是一个模拟用户登录的程序。

  • 当用户输入用户名时,如果用户名不足 5 个字就给出红色提示语,并且无法输入密码,当用户名符合要求时才可以输入密码。
  • 同样的当用户输入的密码不到 5 个字时也给出红色提示语。
  • 当用户名和密码有一个不符合要求时底部的绿色按钮不可点击,只有当用户名和密码同时有效时按钮才可点击。
  • 当点击绿色按钮后弹出一个提示框,这个提示框只是用来做演示而已。

你可以下载这个例子并在模拟器上运行,这样可以帮助于你理解整个程序的交互:

这个页面主要由 5 各元素组成:

  1. 用户名输入框
  2. 用户名提示语(红色)
  3. 密码输入框
  4. 密码提示语(红色)
  5. 操作按钮(绿色)
class SimpleValidationViewController : ViewController {

@IBOutlet weak var usernameOutlet: UITextField!
@IBOutlet weak var usernameValidOutlet: UILabel!

@IBOutlet weak var passwordOutlet: UITextField!
@IBOutlet weak var passwordValidOutlet: UILabel!

@IBOutlet weak var doSomethingOutlet: UIButton!
...
}

这里需要完成 4 个交互:

  • 当用户名输入不到 5 个字时显示提示语,并且无法输入密码

    override func viewDidLoad() {
    super.viewDidLoad()

    ...

    // 用户名是否有效
    let usernameValid = usernameOutlet.rx.text.orEmpty
    // 用户名 -> 用户名是否有效
    .map { $0.count >= minimalUsernameLength }
    .share(replay: 1)

    ...

    // 用户名是否有效 -> 密码输入框是否可用
    usernameValid
    .bind(to: passwordOutlet.rx.isEnabled)
    .disposed(by: disposeBag)

    // 用户名是否有效 -> 用户名提示语是否隐藏
    usernameValid
    .bind(to: usernameValidOutlet.rx.isHidden)
    .disposed(by: disposeBag)

    ...
    }

    当用户修改用户名输入框的内容时就会产生一个新的用户名, 然后通过 map 方法将它转化成用户名是否有效, 最后通过 bind(to: ...) 来决定密码输入框是否可用以及提示语是否隐藏。

  • 当密码输入不到 5 个字时显示提示文字

    override func viewDidLoad() {
    super.viewDidLoad()

    ...

    // 密码是否有效
    let passwordValid = passwordOutlet.rx.text.orEmpty
    // 密码 -> 密码是否有效
    .map { $0.count >= minimalPasswordLength }
    .share(replay: 1)

    ...

    // 密码是否有效 -> 密码提示语是否隐藏
    passwordValid
    .bind(to: passwordValidOutlet.rx.isHidden)
    .disposed(by: disposeBag)

    ...
    }

    这个和用用户名来控制提示语的逻辑是一样的。

  • 当用户名和密码都符合要求时,绿色按钮才可点击

    override func viewDidLoad() {
    super.viewDidLoad()

    ...

    // 用户名是否有效
    let usernameValid = ...

    // 密码是否有效
    let passwordValid = ...

    ...

    // 所有输入是否有效
    let everythingValid = Observable.combineLatest(
    usernameValid,
    passwordValid
    ) { $0 && $1 } // 取用户名和密码同时有效
    .share(replay: 1)

    ...

    // 所有输入是否有效 -> 绿色按钮是否可点击
    everythingValid
    .bind(to: doSomethingOutlet.rx.isEnabled)
    .disposed(by: disposeBag)

    ...
    }

    通过 Observable.combineLatest(...) { ... } 来将用户名是否有效以及密码是都有效合并出两者是否同时有效,然后用它来控制绿色按钮是否可点击。

  • 点击绿色按钮后,弹出一个提示框

    override func viewDidLoad() {
    super.viewDidLoad()

    ...

    // 点击绿色按钮 -> 弹出提示框
    doSomethingOutlet.rx.tap
    .subscribe(onNext: { [weak self] in self?.showAlert() })
    .disposed(by: disposeBag)
    }

    func showAlert() {
    let alertView = UIAlertView(
    title: "RxExample",
    message: "This is wonderful",
    delegate: nil,
    cancelButtonTitle: "OK"
    )

    alertView.show()
    }

    在点击绿色按钮后,弹出一个提示框

这样 4 个交互都完成了,现在我们纵观全局看下这个程序是一个什么样的结构:

然后看一下完整的代码:

override func viewDidLoad() {
super.viewDidLoad()

usernameValidOutlet.text = "Username has to be at least \(minimalUsernameLength) characters"
passwordValidOutlet.text = "Password has to be at least \(minimalPasswordLength) characters"

let usernameValid = usernameOutlet.rx.text.orEmpty
.map { $0.count >= minimalUsernameLength }
.share(replay: 1)

let passwordValid = passwordOutlet.rx.text.orEmpty
.map { $0.count >= minimalPasswordLength }
.share(replay: 1)

let everythingValid = Observable.combineLatest(
usernameValid,
passwordValid
) { $0 && $1 }
.share(replay: 1)

usernameValid
.bind(to: passwordOutlet.rx.isEnabled)
.disposed(by: disposeBag)

usernameValid
.bind(to: usernameValidOutlet.rx.isHidden)
.disposed(by: disposeBag)

passwordValid
.bind(to: passwordValidOutlet.rx.isHidden)
.disposed(by: disposeBag)

everythingValid
.bind(to: doSomethingOutlet.rx.isEnabled)
.disposed(by: disposeBag)

doSomethingOutlet.rx.tap
.subscribe(onNext: { [weak self] in self?.showAlert() })
.disposed(by: disposeBag)
}

func showAlert() {
let alertView = UIAlertView(
title: "RxExample",
message: "This is wonderful",
delegate: nil,
cancelButtonTitle: "OK"
)

alertView.show()
}

你会发现你可以用几行代码完成如此复杂的交互。这可以大大提升我们的开发效率。

更多疑问

  • share(replay: 1) 是用来做什么的?

    我们用 usernameValid 来控制用户名提示语是否隐藏以及密码输入框是否可用。shareReplay 就是让他们共享这一个源,而不是为他们单独创建新的源。这样可以减少不必要的开支。

  • disposed(by: disposeBag) 是用来做什么的?

    和我们所熟悉的对象一样,每一个绑定也是有生命周期的。并且这个绑定是可以被清除的。disposed(by: disposeBag)就是将绑定的生命周期交给 disposeBag 来管理。当 disposeBag 被释放的时候,那么里面尚未清除的绑定也就被清除了。这就相当于是在用 ARC 来管理绑定的生命周期。 这个内容会在 Disposable 章节详细介绍。

收起阅读 »

iOS RXSwift 一

iOS
为什么要使用 RxSwift ?我们先看一下 RxSwift 能够帮助我们做些什么:Target Action传统实现方法:button.addTarget(self, action: #selector(buttonTapped), for: .touchU...
继续阅读 »

为什么要使用 RxSwift ?

我们先看一下 RxSwift 能够帮助我们做些什么:

Target Action

传统实现方法:

button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
func buttonTapped() {
print("button Tapped")
}

通过 Rx 来实现:

button.rx.tap
.subscribe(onNext: {
print("button Tapped")
})
.disposed(by: disposeBag)

你不需要使用 Target Action,这样使得代码逻辑清晰可见。

代理

传统实现方法:

class ViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()
scrollView.delegate = self
}
}

extension ViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("contentOffset: \(scrollView.contentOffset)")
}
}

通过 Rx 来实现:

class ViewController: UIViewController {
...
override func viewDidLoad() {
super.viewDidLoad()

scrollView.rx.contentOffset
.subscribe(onNext: { contentOffset in
print("contentOffset: \(contentOffset)")
})
.disposed(by: disposeBag)
}
}

你不需要书写代理的配置代码,就能获得想要的结果。

闭包回调

传统实现方法:

URLSession.shared.dataTask(with: URLRequest(url: url)) {
(data, response, error) in
guard error == nil else {
print("Data Task Error: \(error!)")
return
}

guard let data = data else {
print("Data Task Error: unknown")
return
}

print("Data Task Success with count: \(data.count)")
}.resume()

通过 Rx 来实现:

URLSession.shared.rx.data(request: URLRequest(url: url))
.subscribe(onNext: { data in
print("Data Task Success with count: \(data.count)")
}, onError: { error in
print("Data Task Error: \(error)")
})
.disposed(by: disposeBag)

回调也变得十分简单

通知

传统实现方法:

var ntfObserver: NSObjectProtocol!

override func viewDidLoad() {
super.viewDidLoad()

ntfObserver = NotificationCenter.default.addObserver(
forName: .UIApplicationWillEnterForeground,
object: nil, queue: nil) { (notification) in
print("Application Will Enter Foreground")
}
}

deinit {
NotificationCenter.default.removeObserver(ntfObserver)
}

通过 Rx 来实现:

override func viewDidLoad() {
super.viewDidLoad()

NotificationCenter.default.rx
.notification(.UIApplicationWillEnterForeground)
.subscribe(onNext: { (notification) in
print("Application Will Enter Foreground")
})
.disposed(by: disposeBag)
}

你不需要去管理观察者的生命周期,这样你就有更多精力去关注业务逻辑。

多个任务之间有依赖关系

例如,先通过用户名密码取得 Token 然后通过 Token 取得用户信息,

传统实现方法:

/// 用回调的方式封装接口
enum API {

/// 通过用户名密码取得一个 token
static func token(username: String, password: String,
success: (String)
-> Void,
failure: (Error) -> Void) { ... }

/// 通过 token 取得用户信息
static func userinfo(token: String,
success: (UserInfo)
-> Void,
failure: (Error) -> Void) { ... }
}
/// 通过用户名和密码获取用户信息
API.token(username: "beeth0ven", password: "987654321",
success: { token in
API.userInfo(token: token,
success: { userInfo in
print("获取用户信息成功: \(userInfo)")
},
failure: { error in
print("获取用户信息失败: \(error)")
})
},
failure: { error in
print("获取用户信息失败: \(error)")
})

通过 Rx 来实现:

/// 用 Rx 封装接口
enum API {

/// 通过用户名密码取得一个 token
static func token(username: String, password: String) -> Observable<String> { ... }

/// 通过 token 取得用户信息
static func userInfo(token: String) -> Observable<UserInfo> { ... }
}
/// 通过用户名和密码获取用户信息
API.token(username: "beeth0ven", password: "987654321")
.flatMapLatest(API.userInfo)
.subscribe(onNext: { userInfo in
print("获取用户信息成功: \(userInfo)")
}, onError: { error in
print("获取用户信息失败: \(error)")
})
.disposed(by: disposeBag)

这样你可以避免回调地狱,从而使得代码易读,易维护。

等待多个并发任务完成后处理结果

例如,需要将两个网络请求合并成一个,

通过 Rx 来实现:

/// 用 Rx 封装接口
enum API {

/// 取得老师的详细信息
static func teacher(teacherId: Int) -> Observable<Teacher> { ... }

/// 取得老师的评论
static func teacherComments(teacherId: Int) -> Observable<[Comment]> { ... }
}
/// 同时取得老师信息和老师评论
Observable.zip(
API.teacher(teacherId: teacherId),
API.teacherComments(teacherId: teacherId)
).subscribe(onNext: { (teacher, comments) in
print("获取老师信息成功: \(teacher)")
print("获取老师评论成功: \(comments.count) 条")
}, onError: { error in
print("获取老师信息或评论失败: \(error)")
})
.disposed(by: disposeBag)

这样你可用寥寥几行代码来完成相当复杂的异步操作。


那么为什么要使用 RxSwift ?

  • 复合 - Rx 就是复合的代名词
  • 复用 - 因为它易复合
  • 清晰 - 因为声明都是不可变更的
  • 易用 - 因为它抽象的了异步编程,使我们统一了代码风格
  • 稳定 - 因为 Rx 是完全通过单元测试的
收起阅读 »

从最简单的角度走上读源码

1.前言 很早前就想多看一些源码,也看过不少源码的分析,自己简单的去浏览过,但等到想静下心来自己去分析一些的时候,却一直没有时间被搁置,其实可能也是因为自己对相关操作比较陌生,潜意识里又一点抵触。最近想开始行动又在想如何开始,从哪个源码,从什么部分去入手的时候...
继续阅读 »

1.前言


很早前就想多看一些源码,也看过不少源码的分析,自己简单的去浏览过,但等到想静下心来自己去分析一些的时候,却一直没有时间被搁置,其实可能也是因为自己对相关操作比较陌生,潜意识里又一点抵触。最近想开始行动又在想如何开始,从哪个源码,从什么部分去入手的时候,想起若川大佬经常有一些源码的研究,并参加了他发起的源码共读活动,受益良多。本次读的部分是vue3的工具函数,这是若川哥文章的地址juejin.cn/post/699497…


这里从一个源码初学者的角度对这一次共读进行一些记录和总结


2.项目准备


万事开头难,很多时候正是因为没有想好怎样去有一个好的开始,而一直搁置。


先是在若川的引导下,我先去看了vue-next的readme和相关协作文档,其实之前也会看,但没有想法去仔细想一下,并动手去实践起来,虽然是英文的,但是可以先稍微读慢一些


vue-next贡献文档里有写到过,当有一些多个编译方式都要用的方法函数的时候,要写到share模块里,当时我会感觉这是一个很困难的部分,但还是继续去做好了


当对文档有一定的了解之后,开始把vue-next下载到本地进行浏览


git clone https://github.com/vuejs/vue-next.git

cd vue-next

npm install --global yarn

yarn

yarn build

以上流程大家应该都比较熟悉


有个要说的就是,在大家yarn的时候 很有可能也会遇到The engine "node" is incompatible with this module的错误


这是vue-next的代码不久前有一个在engine里对node版本有限制,大家只要把node更新到相应的版本就可以了,用nvm可以很方便的进行。或者用yarn install --ignore-engines 对这个限制进行无视


还有个重要的就是在我们build之后,因为vue-next的代码基本都用ts进行了重构,build完会有一个vue-next/packages/shared/dist/shared.esm-bundler.js 文件,这是对本文件夹ts的js转义输出,这里的文件位置可以在tsconfig里找到。(忽然找到一个一边学源码一边复习ts的好方法!


3.源码调试


在源码调试的时候有一个困难的事情,就是代码经过各种步骤输出后,是没有办法直接调试的,所以我们往往会通过sourceMap去进行帮助,sourcemap是一个记录位置的文件,让我们能在经过巨大变化的代码里找到我们原来开发的样子


这是贡献指南里说提供的:Build with Source Maps Use the --sourcemap or -s flag to build with source maps. Note this will make the build much slower.


所以在 vue-next/package.json 追加 "dev:sourcemap": "node scripts/dev.js --sourcemap",yarn dev:sourcemap执行,即可生成sourcemap,或者直接 build。


然后会在控制台输出类似vue-next/packages/vue/src/index.ts → packages/vue/dist/vue.global.js的信息。


我们在文件里引入这个文件,就会有效果啦~


4.工具代码


上文有说道过,当初觉得这个模块是困难的,但其实真正去看的话,很多写法其实也都是平时会用到的,我们看这类源码,要抛开其他,对一些对我们有帮助的代码写法进行学习。我们从vue-next/packages/shared/src/index.ts开始。


前边的一些其实都是为了更加方便使用和严谨,但其实并不难。但有一些我们平时没有那么常用的方法,其实某些时候也都会有用,至少都应该有印象,比如对象的方法,Object.freeze({}),还有es6的字符串方法Startwith等


还有很有用的是,在学习工具源码的过程中,复习到了一些之前的知识,比如原型链的一些相关


hasOwn:判断一个属性是否属于某个对象


const hasOwnProperty = Object.prototype.hasOwnProperty
export const hasOwn = (
val: object,
key: string | symbol
): key is keyof typeof val => hasOwnProperty.call(val, key)

toRawType:对象转字符串


const objectToString = Object.prototype.toString;
const toTypeString = (value) => objectToString.call(value);
const toRawType = (value) => {
// extract "RawType" from strings like "[object RawType]"
return toTypeString(value).slice(8, -1);
};

这里是三个函数,我把三个放在一起去进行总结,typeof很多时候是不准的,这个时候用这个方法可以进行一些补充


比如可以分出array和普通object


// typeof 返回值目前有以下8种
'undefined'
'object'
'boolean'
'number'
'bigint'
'string'
'symobl'
‘function'

isPromise判断是不是promise


const isPromise = (val) => {
return isObject(val) && isFunction(val.then) && isFunction(val.catch);
};

// 判断是不是Promise对象
const p1 = new Promise(function(resolve, reject){
resolve('');
});
isPromise(p1); // true

之前没有想到这种思路,很简单实用


cacheStringFunction函数缓存


const cacheStringFunction = (fn) => {
const cache = Object.create(null);
return ((str) => {
const hit = cache[str];
return hit || (cache[str] = fn(str));
});
};

5.总结


能写出这篇文章要很感谢若川哥的帮助


这一期的读源码很有收获



  1. 有了开始研究困难源码的信心和方向

  2. 对项目的github文档更加重视并懂得去理解

  3. 学会了通过sourcemap帮助我们调试源码

  4. 学习了vue工具函数的写法,复习了相关知识,并在工作中有意识借鉴


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

收起阅读 »

巧用CSS counter属性

前言 你一定遇到过这样的排版,如实现步骤条、给文章编号等。如果写固定的编号,增删步骤或者章节时,后续的编号都需要手动更改。这样会很麻烦。 CSS 提供了计数器功能,可以动态设置编号。 CSS计数器 要实现CSS计数器的,先了解CSS计数器的属性和方法 co...
继续阅读 »

前言


你一定遇到过这样的排版,如实现步骤条、给文章编号等。如果写固定的编号,增删步骤或者章节时,后续的编号都需要手动更改。这样会很麻烦。


image.png
image.png


CSS 提供了计数器功能,可以动态设置编号。


CSS计数器


要实现CSS计数器的,先了解CSS计数器的属性和方法


counter-reset
counter-increment
counter()

counter-reset


counter-reset 用于定义和初始化一个或者多个css计数器。设置计数器的名称和初始值。
使用语法:


counter-reset:[<标识符><整数>?]+|none|inherit

每个计数器名称后面都可以跟一个可选的<整数>值,该值指定计数器的初始值。
计数器的初始值不是计数器显示时的第一个数字,如果希望计数器从1开始显示,则需要设置coutter-reset中的初始值设置为0。


someSelector{
counter-reset:counterA;/*计数器counterA初始,初始值为0*/
counter-reset:counterA 6;/*计数器counterA初始,初始值为6*/
counter-reset:counterA 4 counter B;/*计数器counterA初始,初始值为4,计数器counterB初始,初始值为0*/
counter-reset:counterA 4 counterB 2;/*计数器counterA初始,初始值为4,计数器counterB初始,初始值为2*/
}

counter-increment


counter-increment属性用于指定一个或多个CSS计数器的增量值。它将一个或多个标识符作为值,指定要递增的计数器的名称。


使用语法:


counter-increment:[<标识符><整数>?]+|none|inherit

每个计数器名称(标识符)后面都可以跟一个可选<整数>值,该值指定对于我们所编号的元素每次出现时,计数器需要递增多少。默认增量为1。允许零和负整数。如果指定了负整数,则计数器被递减。


counter-increment属性必须和counter-reset属性配合使用


article{/*定义和初始化计数器*/
  counter-reset:section;/*'section'是计数器的名称*/
}
article h2{/*每出现一次h2,计数器就增加1*/
  counter-increment:section;/*相当于计数器增量:第1节;*/
}

couter()


counter()函数必须和content属性一起使用,用来显示CSS计数器。它以CSS计数器名称作为参数,并作为值传递给content属性,而content属性就会使用:before伪元素将计数器显示为生成的内容。


h2:before{
  content:counter(section);
}

counter()函数有两种形式:counter(name)和counter(name,style)。
name参数就是要显示的计数器的名称;使用counter-reset属性就可以指定计数器的名称。


couters()


counters()函数也必须和content属性一起使用,用来显示CSS计数器。和counter()函数一样,counters()函数也作为值传递给content属性;然后,content属性在使用:before伪元素将计数器显示为生成的内容。


counters()函数也有两种形式:counters(name,string)和counters(name,string,style)。
name参数也是要显示的计数器的名称。可以使用counter-reset属性来指定计数器的名称。


而counters()函数与counter()函数(单数形式)区别在于:counters()函数可以用于设置嵌套计数器。


嵌套计数器是用于为嵌套元素(如嵌套列表)提供自动编号。如果您要将计数器应用于嵌套列表,则可以对第一级项目进行编号,例如,1,2,3等。第二级列表项目将编号为1.1,1.2,1.3等。第三级项目将是1.1.1,1.1.2,1.1.3,1.2.1,1.2.2,1.2.3等。


string参数用作不同嵌套级别的数字之间的分隔符。例如,在'1.1.2'中,点('.')用于分隔不同的级别编号。如果我们使用该counters()函数将点指定为分隔符,则它可能如下所示:


  content:counters(counterName,".")

  如果希望嵌套计数器由另一个字符分隔,例如,如果希望它们显示为“1-1-2”,则可以使用短划线而不是点作为字符串值:


  content:counters(counterName,"-")

总结


使用CSS Counters给元素创建自动递增计算器不仅仅是依赖于某一个CSS属性来完成,他需要几个属性一起使用才会有效果。使用的到属性包括:使用CSS Counters给元素创建自动递增计算器不仅仅是依赖于某一个CSS属性来完成,他需要几个属性一起使用才会有效果。使用的到属性包括:



  • counter-reset: 定义计数器的名称和初始值。

  • counter-increment:用来标识计数器与实际相关联的范围。

  • content:用来生成内容,其为:before:after::before::after的一个属性。在生成计数器内容,主要配合counter()一起使用。

  • counter():该函数用来设置插入计数器的值。

  • :before :after:配合content用来生成计数器内容。



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

收起阅读 »

rgb和hex相互转换

前言 这里使用了一些位运算进行计算,如果对位运算不了解的,可以了解一下,位运算 hex(16进制):#FFF,#ffffff等等16进制颜色 rgb:rgb(255,255,255),rgb(123,125,241)等等 笔者第一次遇到颜色转换时,懵了,没有思...
继续阅读 »

前言


这里使用了一些位运算进行计算,如果对位运算不了解的,可以了解一下,位运算


hex(16进制):#FFF,#ffffff等等16进制颜色


rgb:rgb(255,255,255),rgb(123,125,241)等等


笔者第一次遇到颜色转换时,懵了,没有思路,害,想着放着后面再来看看,结果放着放着,哦豁,再一次遇到了它,唉,被它逮住了。


这题,必须得剿,不剿不行呀,码着代码看着题,结果它来了。哈哈哈哈


因为笔者想自己输入hex或者rgb然后转换,就想着写个输入框,获取转换前后颜色。


设计



  • test1和test2的背景颜色由输入颜色和转化后颜色决定

  • 需要一个输入框inChange来让我输入

  • 提交按钮colorBtn提交我输入颜色


html片段:


<div>
<div>
输入颜色
</div>
<div>
转换颜色
</div>
</div>
<div>
<input type="text">
<button>提交</button>
</div>

css片段:


    * {
padding: 0;
margin: 0;
}

.box {
display: flex;
justify-content: center;
}

.test1,
.test2 {
width: 200px;
height: 100px;
text-align: center;
border: 1px solid red;
margin: 10px 20px;
}

.boxin {
width: 100%;
text-align: center;
}

这里的js我把实现转换的核心代码在下面注释给分割出来,方便复制


注意:



  • 16进制每个字符所占4位,超过32位溢出.

  • hex有6个16进制字符,24位。


js


//hex转换成rgb
function hexToRgb(hex) {
   //用于判断hex的格式对不对
let regExp = /^#([0-9A-F]{3}|[0-9A-F]{6})$/i;
//判断hex的格式是否正确
if (!regExp.test(hex)) {
return false;
}

   //-----hex到rgb转换核心代码
   //获取#后的16进制数
let str = hex.substr(1,);
//当str长度为3时,它是简写,需要把它装回6位
if (str.length == 3) {
let tempStr = "";
for (let i = 0; i < 3; i++) {
tempStr += str[i] + str[i];
}
str = tempStr;
}
//16进制
str = "0x" + str;
   //16进制每个字符占4个字节,16/4=4,对应的是(例子:0xaf54ff)af
let r = str >> 16;
   //对应af54在与运算符后,对应54
let g = str >> 8 & 0xff;
   //对应ff
let b = str & 0xff;
   let rgb = `rgb(${r}, ${g}, ${b})`;
   //-----hex到rgb转换完毕

document.querySelector(".test1").style.backgroundColor = hex;
   document.querySelector(".test1").innerHTML = hex;
   document.querySelector(".test2").style.backgroundColor = rgb;
   document.querySelector(".test2").innerHTML = rgb;
}

function rgbToHex(rgb) {
   //正则太长,直接复制粘贴会有空格,请自行删除(有空格报错噢,嘿嘿嘿)
let regExp = /^rgb(\s*((1\d{2}|2(5[0-5]|[0-4]\d))|\d{1,2})\s*,\s*((1\d{2}|2(5[0-5]|[0-4]\d))|\d{1,2})\s*,\s*((1\d{2}|2(5[0-5]|[0-4]\d))|\d{1,2})\s*)$/i;
   //判断rgb的格式是否正确
if (!regExp.test(rgb)) {
return false;
}

   //-----rgb到hex转换核心代码
   //获取rgb的数字
let arr = rgb.split(",");
let r = + arr[0].split("(")[1];
let g = + arr[1];
let b = + arr[2].split(")")[0];
   //hex有6位,占了4*6=24字节,每一步将rgb还原
let value = (1 << 24) + (r << 16) + (g << 8) + b;
   //只要24位,其实有25位,16进制转换后高位多出了一个1,提取1后面的16进制数。
let hex = "#" + value.toString(16).slice(1);
   //-----rgb到hex转换完毕

document.querySelector(".test1").style.backgroundColor = rgb;
   document.querySelector(".test1").innerHTML = rgb;
   document.querySelector(".test2").style.backgroundColor = hex;
   document.querySelector(".test2").innerHTML = hex;
}
function hexOrRgb() {
   //获取输入框的值
let val = document.querySelector(".inChange").value;
   //调用,不是执行下一个
hexToRgb(val) || rgbToHex(val);
}
function init() {
//监听按钮的点击
let btn = document.querySelector(".colorBtn");
btn.addEventListener("click", () => {
hexOrRgb();
  });
}
//入口
init();


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

收起阅读 »

React 的 Fiber 树是什么?

我发现,如果不搞清楚 React 的更新流程,就无法理解 useEffect 的原理,于是分享 React 更新流程的文章就来了。 其实我本想把整个更新流程放到一篇文章里去的,但是昨天查了一天资料后,发现这太不现实了,要是写在一篇里,中秋假期仅剩的一个下午也没...
继续阅读 »

我发现,如果不搞清楚 React 的更新流程,就无法理解 useEffect 的原理,于是分享 React 更新流程的文章就来了。


其实我本想把整个更新流程放到一篇文章里去的,但是昨天查了一天资料后,发现这太不现实了,要是写在一篇里,中秋假期仅剩的一个下午也没了,还写不完,并且我现在的能力并不能很好的组织它们。


所以,我还是放弃了,我决定把它拆开,分多篇博客更新,拆分的结果大概是这样子的:



  1. React 的 Fiber 树是什么;

  2. 更新流程中的 Render 阶段;

  3. 更新流程中的 Commit 阶段;

  4. 通过 useEffect 里调用 useSate, 把 2、3 结合起来。



What I cannot [re]create, I do not understand.



分享完上面的内容后,我们应该就可以有能力自己实现一个 Mini 版 React 了。为了真正的掌握,我会和大家一起实现一个支持 Hooks 的 Mini 版 React,可能以文章的形式放出,也可能就把源码贴在这里,不过,那肯定是 10 月份或者 11 月份的事情了。


刚开始听到 「Fiber」 这个词的时候,觉得高端极了,当时甚至没有去网上搜索一下这个到底是什么,就默认自己不可能理解了。逃的了一时,逃不了一世,为了不当框架熟练工,最终还是要克服它。


幸运的是,了解之后发现,这东西既没有想得那么难,也没有想得那么简单,只要花一点时间,大家都还是能理解的。


你要试着了解一下吗,如果选择是的话,那我们就开始吧。


Fiber 树与 DOM 树


DOM 树大家都很熟悉,下面是我们的一段 HTML 的片段:


<div>
<ul>
<li>1</li>
<li>2</li>
</ul>
<button>submit</button>
<div>

对应到 DOM 结构,就是下面这样子:


image.png


大家可能都知道 React 使用了虚拟 DOM 来提高性能。


虚拟 DOM 是一个描述 DOM 节点信息的 JS 对象,操作 DOM 是一个比较昂贵的操作,使用虚拟 DOM 这项技术,我们就能通过在 JS 对象中进行新老节点的对比,尽量减小查询、更新 DOM 操作的频次和范围。在 React 中,虚拟 DOM 对应的就是 Fiber 树。


说到 Fiber 树,名字中带一个「树」字,大家的第一印象会把它和树结构联系起来,认为它和 DOM 树的结构是一样的,但这里还真就有点不同了,它和我们见过的树都不一样。


还是用上面那段 HTMl 代码,我们假设它是一段用 JSX 语法书写的,它的结构实际是如下图这般:


image.png


父节点只和它的第一个孩子节点相连接,而第一个孩子和后面的兄弟节点相连接,它们之间构成了一个单项链表的结构。最后,每个孩子都有一个指向父节点的指针。


因为比较重要,我们再来复述一遍:在上面的结构中,我们会有一个 child 指针指向它的孩子节点,也会有一个 return 指针指向它的父节点,另外会有一个叫做 sibling 的指针指向它的兄弟节点,如果它没有孩子节点、兄弟节点或父节点,他们的指向就为空。


乍一看,这种结构还是很奇怪的,你可能会疑问,为什么不用树结构,这个我们放在本文的后面讨论。


Fiber 树的遍历


在我们学习树的时候,我们学的第一个算法往往是遍历算法,在继续下面的内容之前,我们也先来看一下怎么去遍历下面这样结构的 Fiber 树。


这一块还是很重要的,因为在后面 React 的更新流程中,它要遍历整个 Fiber 去收集更新,理解了这一块就有助于我们理解后面它的遍历过程。


我们先来描述一下它的遍历顺序:



  1. 把当前遍历的节点名记作 aa

  2. 遍历当前节点 aa,完成对这个节点要做的事

  3. 判断 a.childa.child 是否为空

  4. a.childa.child 不为空,则把 a.childa.child 记作 aa,回到 [步骤 1]

  5. a.childa.child 为空,则判断 a.sibinga.sibing 是否为空,不为空将 a.sibinga.sibing 记为 aa,回到 [步骤 1]

  6. a.childa.childa.siblinga.sibling 都为空,则证明当前节点和和他兄弟节点都遍历完了,那就返回它的父节点,找父节点中还没有遍历的兄弟节点,找到了,回到步骤 1

  7. 如此反复,直到遍历到顶点,结束。


只看逻辑可能不太直观,我们举一个例子。


<div id="a"> 
<ul id="b">
<li id="c">1</li>
<li id="d">2</li>
</ul>
<button id="e">submit</button>
<div>

对于上面这段代码,我们的遍历顺序会是:a -> b -> c -> d -> e,和正常树结构的前序遍历的结果是一样的。


如果看着还是有点懵,没关系,这很正常,接下来我会和大家演示代码。


为了方便起见,我们就固定写好的一个 Fiber 树结构,它对应我们上面那段 HTML。


// 为了简单起见,我把 TextNode 节点省略了
function createFiberTree() {
let rootFiber = {
type: 'div',
sibling: null,
return: null,
child: {
type: 'ul',
return: null,
sibling: {
type: 'button',
return: null,
sibling: null,
child: null
},
child: {
type: 'li',
return: null,
child: null,
sibling: {
type: 'li',
return: null,
child: null
}
}
}
}


rootFiber.return = null;
rootFiber.child.return = rootFiber
rootFiber.child.sibling.return = rootFiber;

let ul = rootFiber.child;
rootFiber.child.child.return = ul;
rootFiber.child.child.sibling.return = ul;

return rootFiber;
}

上面那段代码很有点长,不用管,大家就知道它根据上面的 HTML 结构构造了 Fiber 对象就好了。


接下来我们要去遍历这个树,下面就是我们的遍历方法,大家可以稍微停一会看一下这个算法,在React 的更新流程的 Render 阶段,遍历 Fiber 树的地方都是沿用这个思路。


function traverse(node) {
const root = node;
let current = node;

while(true) {
console.log('当前遍历的节点是:' + current.type)

if (current.child) {
current = current.child
continue
}

if (current.sibling) {
current = current.sibling
continue
}

while(!current.sibling) {
if (
current.return === null || current.return === root) {
return;
}
current = current.return;
}
current = current.sibling
}
}

我们在控制台运行上面遍历方法的结果如下:


image.png


Fiber 树结构的优势


好了,现在我们就已经和大家讨论清楚 Fiber 树大体是什么样了,并且我们了解了怎样去遍历一棵 Fiber 树,接下来讨论一下,为什么需要这么样的设计。


刚开始的时候,我也很疑惑,为什么不和 DOM 一样,使用普通的多叉树呢?


type Fiber {
type: string;
children: Array<Fiber>
}

这样子的话,我们不需要维护孩子节点之间的指针,找某个节点的孩子的话,直接读取 children 属性就好了。这样看起来是没问题的,我们知道,在遍历树的时候,我们最常用的是使用递归去写,如果我们采用上面的多叉树结构,遍历节点可能就是这样的:


function traverse(node) {
if (!node || !node.children) {
return;
}

for (let i = 0; i < node.children.length; i++) {
traverse(node.children[i]);
}
}

看起来确实是简洁了很多,但是如果我们的 DOM 层级很深就会引发严重的性能问题,在一个普通的项目里,几百层的 DOM 嵌套是经常发生的,这样以来,使用递归会占用大量的 JS 调用栈,不仅如此,我们的调用栈肯定不是只给这一块遍历 Fiber 节点的呀,我们还有其他的事情要去做,这对性能来说是很不能接受的。


但是,如果用我们上面提到的那种架构,我们就能做到不使用递归去遍历链表,就能始终保持遍历时,调用栈只使用了一个层,这就很大的提升了性能。


除此之外,上述遍历 Fiber 节点的过程是发生在整个更新流程的 Render 阶段,在这个阶段,React 是允许低优先级的任务被更高优先级的任务所打断的。所以说,遍历过程也可能随时被中断。为了能在下次更新时继续从上次中断的点开始,我们就需要记录下上一次的中断点。


如果使用普通的树结构,是很难记录下中断点的,假设我们有一段这样一段 HTML:


<div>
<ul>
<li>
<a>在这里中断了</a>
</li>
<!-- 可能还有很多项 -->
</ul>
<!-- 可能还有很多项 -->
</div>

按照上面的遍历算法,假设我们在遍历到 a 标签的时候中断了。


当遍历到 a 标签的时候,我们还有很多节点没有遍历的,包括 ul 的其他孩子节点、div 的其他孩子节点,也就是我标注 '可能还有很多项' 的那个地方,为了下一次能继续下去,我们就需要把这些都保存下来,当这些节点很多的时候,这在内存上是一个巨大的开销。


使用当前 Fiber 架构呢?只需要把当前节点记录在一个变量里就好了,等下次更新,它还是可以按照一样的逻辑,先遍历自己,再遍历 child 节点,再遍历 sibling 节点......


因此,我们最终选用了刚开始看起来有点怪的 Fiber 树结构。


Fiber 节点部分属性介绍


在 React官网的这一章节,讲述了 Diff 算法的大致流程,这里 Diff 的东西就是两棵新旧 Fiber 树。


说了这么多,我们还没看过一个 Fiber 节点到底长什么样。


不妨,我们先用 Babel 转译一段 JSX 看看。就编译下面这一小段吧:


<div>
<span key="1" className="box">hello world</span>
</div>


结果是下面这样的:


const a = React.createElement("div", null, 
React.createElement("span", {
key: "1",
className: "box"
}, "hello world"))

我们会根据这个结果去构建 Fiber 对象,就是这样:


image.png



注意:
上面的截图并不是全部的属性,本人只截取了一部分。



我们再根据上面的图,介绍几个 Fiber 节点常用的属性。


alternate:Diff 过程要新老节点对比,他们就是通过这个找到对方。所以,新节点的 Fiber.alternate 就指向它对应的老节点;同时,老节点的 alternate 也指向新节点。


child: 指向第一个孩子节点,我们这里就是指向了 span 那个节点。


elementType: 和 React.createElement 的第一个参数相同,DOM 元素是它的类型,组件的话就是对应的构造函数,比如函数式组件就是对应的函数,类组件就是对应的类。


sibling:指向下一个兄弟节点


return:指向父节点


stateNode:对应的 DOM 节点


memoizedProps 存储的计算好了的 props,可能是已经更新到页面上的了;也可能是刚根据 pendingProps 计算好,还没有来得及更新到页面上,准备和旧节点进行对比


memoizedState:和 memoziedProps 一样。像 usetState 能保存状态,就是因为上一次的值被存到了这个属性里面。


关于 Fiber 的属性,我们就先介绍这几个,后面等我们用到了再介绍更多。


好了,这就是我们今天的全部内容了,相信看完了上面的内容就对 Fiber 树是什么有大体印象了吧。之前我写的 useState 源码解读可能不是特别好,可能原因就是不太明白某些朋友不了解 Fiber 到底是什么,现在我通过这篇文章把它补上了,希望能弥补一下吧。


中秋回家的朋友,你们现在在归程了吗?


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

收起阅读 »

让你用最简单的方式使用Vue2 + Web Worker + js-xlsx 解析excel数据

vue
最简单的应该就是 C V 大法了吧!!! 说明 本文重点在于实现功能,没有过多去关注其他。 就想使用的话直接cv到自己的项目即可,想深入学习下边也有官方网址自行查看咯🍺 由SheetJS出品的js-xlsx是一款非常方便的只需要纯JS即可读取和导出excel...
继续阅读 »

最简单的应该就是 C V 大法了吧!!!


cv.jpg


说明


本文重点在于实现功能,没有过多去关注其他。 就想使用的话直接cv到自己的项目即可,想深入学习下边也有官方网址自行查看咯🍺


SheetJS出品的js-xlsx是一款非常方便的只需要纯JS即可读取和导出excel的工具库,功能强大,支持格式众多,支持xls、xlsx、ods(一种OpenOffice专有表格文件格式)等十几种格式。本文以xlsx格式为例。github:github.com/SheetJS/she…


为什么使用Web Worker呢?为了加快解析速度,提高用户体验度🤡。Web Worker具体介绍看阮老师的博客就好😀
Web Worker 使用教程 - 阮一峰的网络日志


本文配套demo仓库:gitee.com/ardeng/work…


效果演示


演示效果


上代码


HTML


普普通通、简简单单的element ui 上传组件


<el-upload
ref="input"
action="/"
:show-file-list="false"
:auto-upload="false"
:on-change="importExcel"
type="file"
>
<el-button type="primary">上传</el-button>
</el-upload>

JS部分


先来个无 Web Worker 版


Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。


Web Worker 有以下几个使用注意点。



  1. 同源限制 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源。

  2. DOM 限制 Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用documentwindowparent这些对象。但是,Worker 线程可以navigator对象和location对象。

  3. 通信联系 Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。

  4. 脚本限制 Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。

  5. 文件限制 Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。


由于以上的限制,所以不想搞Worker也可以。 直接解析文件对象 转换数据即可。


importExcel(file) {
// 验证文件是否为excel
if (!/\.(xlsx|xls|csv)$/.test(file.name)) {
alert("格式错误!请重新选择")
return
}
this.fileToExcel(file).then(tabJson => {
// 这里拿到excel的数据
console.log(tabJson)
})
},
// excel数据转为json数组
fileToExcel(file) {
// 不使用 Promise 也可以 只是把读文件做成异步更合理
return new Promise(function (resolve, reject) {
const reader = new FileReader()
reader.onload = function (e) {
// 拿到file数据
const result = e.target.result
// XLSX 解析的配置 type: 'binary' 必写
const excelData = XLSX.read(result, { type: 'binary' })
// 注意要加 { header: 1 }, 此配置项 可生成二维数组
const data = XLSX.utils.sheet_to_json(excelData.Sheets[excelData.SheetNames[0]],
{ header: 1 }) //! 读取去除工作簿中的数据
resolve(data)
}
// 调用方法 读取二进制字符串
reader.readAsBinaryString(file.raw)
})
}

Web Worker版


想用Worker 一些前置工作是必不可少的



  1. 下载 worker-loader


npm i -D worker-loader


  1. vue.config.js中配置loader


// 设置解析以worker.js 结尾的文件使用worker-loader 解析
chainWebpack: config => {
config.module.rule('worker')
.test(/\.worker\.js$/)
.use('worker-loader')
.loader('worker-loader')
.options({ inline: 'fallback' })
}

正式进入使用Web Worker


封装一下 Web Worker 命名规则如下:xxx.worker.js


下面代码中,self代表子线程自身,即子线程的全局对象。


// src\utils\excel.worker.js

import XLSX from 'xlsx'

/**
* 处理错误的函数 主线程可以监听 Worker 是否发生错误。
* 如果发生错误,Worker 会触发主线程的`error`事件。
*/
const ERROR = () => {
// 发送错误信息
self.postMessage({ message: 'error', data: [] })

// `self.close()`用于在 Worker 内部关闭自身。
self.close()
}

// 错误处理
self.addEventListener('error', (event) => {
ERROR()

// 输出错误信息
console.log('ERROR: Line ', event.lineno, ' in ', event.filename, ': ', event.message)
})

/**
* @description: Worker 线程内部需要有一个监听函数,监听`message`事件。 工作线程接收到主线程的消息
* @param {object} event event.data 获取到主线程发送过来的数据
*/
self.addEventListener('message', async (event) => {
// 向主线程发送消息
// postMessage(event.data);

// 解析excel数据
parsingExcel(event.data)
}, false)

/**
* @description: 解析excel数据
* @param {object} data.excelFileData 文件数据
* @param {object} data.config 配置信息
*/
const parsingExcel = (data) => {
try {
// 注意 { header: 1 }, 此配置项 可生成二维数组
const { excelFileData, config = { header: 1 } } = data

// 创建实例化对象
const reader = new FileReader()

// 处理数据
reader.onload = function (e) {
// 拿到file数据
const result = e.target.result;
const excelData = XLSX.read(result, { type: 'binary' })
const data = XLSX.utils.sheet_to_json(excelData.Sheets[excelData.SheetNames[0]], config) //! 读取去除工作簿中的数据

// 发送消息
self.postMessage({ message: 'success', data })
};
// 调用方法 读取二进制字符串
reader.readAsBinaryString(excelFileData.raw);
} catch (err) {
ERROR()
console.log('解析excel数据时 catch到的错误===>', err)
}
}

使用


引入文件


import Worker from '@/utils/excel.worker.js'

业务相关的逻辑


importExcel(file) {
if (!/\.(xlsx|xls|csv)$/.test(file.name)) {
alert("格式错误!请重新选择")
return;
}

// 创建实列
const worker = new Worker()

// 主线程调用`worker.postMessage()`方法,向 Worker 发消息
worker.postMessage({
excelFileData: file,
config: { header: 1 }
})

// 主线程通过`worker.onmessage`指定监听函数,接收子线程发回来的消息
worker.onmessage = (event) => {
const { message, data } = event.data
if (message === 'success') {
// data是个二维数组 表头在上边
console.log(data)
// Worker 完成任务以后,主线程就可以把它关掉。
worker.terminate()
}
}
}

可能遇到的问题



  1. npm run dev 启动不了,并且 webpack 报错:检查下 webpack worker-loader 的版本。

  2. 控制台报错包含 not a function 或 not a constructor:检查下 webpck 配置。最好是查看英文文档 webpack,因为中文文档更新不及时。

  3. 控制台报错 window is not defined:改成 self 试试。参考 Webpack worker-loader - import doesn’t work


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

收起阅读 »

react-native拆包&热更体系搭建-代码拆包

一、前言 触过react-native的小伙伴都知道热更是rn最大的特点之一,掌握了热更就可以随时上线新的迭代、线上bug hotfix,这样一来发版也更有底气了从此告别跑路的担忧,code-push(以下简称cp)是大多数人接触的第一款热更库,功能很强大但是...
继续阅读 »

一、前言


触过react-native的小伙伴都知道热更是rn最大的特点之一,掌握了热更就可以随时上线新的迭代、线上bug hotfix,这样一来发版也更有底气了从此告别跑路的担忧,code-push(以下简称cp)是大多数人接触的第一款热更库,功能很强大但是也有一些弊端,在刚开始接触热更的时候我有遇到bundle包因为某种来自东方的神秘力量阻挡无法下载的问题,后来我又发现了code-push-server这个开源的配套服务,细心的小伙伴们会发现这个项目最后一次commit已经在两年前,而且配套的cli工具早就升级到与某软的xxx center强绑定了,在此我个人不推荐再使用这个库来实现热更了。


二.CLI工具 (rn-multi-bundle)


image.png


Github:rn-multi-bundle


这是我开发的一款辅助拆包的cli工具,使用方法很简单


安装cli工具


npm install rn-multi-bundle -D
yarn add rn-multi-bundle -D

修改模块注册格式如下:
必须修改,cli工具通过分析ComponentMap对象拆离每个业务包


const ComponentMap = {
[appName]: Home,
[ComponentName.Home]: Home,
[ComponentName.Test]: Test,
};

Object.keys(ComponentMap).forEach(name => {
AppRegistry.registerComponent(name, () => ComponentMap[name]);
});

打包方法(公共包和所有业务包)


yarn rn-multi-bundle

打业务增量包方法


yarn rn-multi-bundle -u

后面考虑会做内联优化RAM Bundles 和内联引用优化


三、拆包与热更的关系


image.png


在大型项目中一般会使用AppRegistry.registerComponent来注册多个模块,每个模块各司其职可以是衍生产品或者零时活动,研究过cp的人都知道它的原理是替换单个bundle包,即时你改动一行代码发版那也是替换整个bundle包,这样造成的问题:一是对资源的浪费很多代码其实还是能够复用的用户的流量也是要钱的、二是不利于优化rn的启动速度。我想要实现的是每个模块可以独立更新,所以拆包对热更来说很重要!!!


四、如何实现拆包?


image.png


react-native的官方打包工具叫metro,metro提供了两个重要的apiprocessModuleFiltercreateModuleIdFactory,两个api都能获取到模块(文件)的路径,也就是栗如/src/utils/constant.tsx这样的字符串,从方法名可以了解到processModuleFilter可以判断是否要过滤出某个文件返回true表示这个文件需要打入bundle中,false则相反。createModuleIdFactory是为每个文件生成一个id,这个id其实就是commonjs规范中每个模块生成的id也就是一个索引。


五、拆出公共包


image.png


公共包顾名思义里边全是公共的代码可复用程度高,可以被各个模块使用到,比如node_modules中的第三方依赖,公共组件components、以及一些工具方法utils,所以我们只需要把文件路径属于这几个文件夹的文件用processModuleFilterapi过滤出来,这样就产生了一个公共包。


六、拆出业务包


image.png


在拆公共包的过程中,生成一个source map记录每个文件的id。利用这个文件使用processModuleFilter把已经存在公共包中的模块过滤掉,然后再使用createModuleIdFactory返回对应的id,这样业务包就能调用公共包中的各种模块


七、打包结果


初始包


image.png


业务增量包


image.png


作者:soul96816
链接:https://juejin.cn/post/7010014852307484685

收起阅读 »

kotlin修炼指南6-Sealed到底密封了啥

在代码中,我们经常需要限定一些有限集合的状态值,例如:网络请求:成功——失败账户状态:VIP——穷逼VIP——普通工具栏:展开——半折叠——收缩等等。通常情况下,我们会使用enum class来做封装,将可见的状态值通过枚举来使用。enum class Net...
继续阅读 »

在代码中,我们经常需要限定一些有限集合的状态值,例如:

  • 网络请求:成功——失败
  • 账户状态:VIP——穷逼VIP——普通
  • 工具栏:展开——半折叠——收缩

等等。

通常情况下,我们会使用enum class来做封装,将可见的状态值通过枚举来使用。

enum class NetworkState(val value: Int) {
SUCCESS(0),
ERROR(1)
}

但枚举的缺点也很明显,首先,枚举比普通代码更占内存,同时,每个枚举只能定义一个实例,不能拓展更多信息。

除此之外,还有种方式,通过抽象类来对状态进行封装,但这种方式的缺点也很明显,它打破了枚举的限制性,所以,Kotlin给出了新的解决方案——Sealed Class(密封类)。

创建状态集

下面我们以网络请求的例子来看下具体如何使用Sealed Class来进行状态的封装。

和抽象类类似,Sealed Class可用于表示层级关系。它的子类可以是任意的类:data class、普通Kotlin对象、普通的类,甚至也可以是另一个密封类,所以,我们定义一个Result Sealed Class:

sealed class Result<out T : Any> {
data class Success<out T : Any>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
}

当然,也不一定非要写在顶层类中:

sealed class Result<out T : Any> 
data class Success<out T : Any>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()

这样也是可以的,它们的区别在于引用的时候,是否包含顶层类来引用而已。

大部分场景下,还是建议第一种方式,可以比较清晰的展示调用的层级关系。

在这个例子中,我们定义了两个场景,分别是Success和Error,它表示我们假设的网络状态就这两种,分别在每种状态下,例如Success,都可以传入自定义的数据类型,因为它本身就是一个class,所以借助这一点,就可以自定义状态携带的场景值。在上面这个例子中,我们定义在Success中,传递data,而在Error时,传递Exception信息。

所以,使用Sealed Class的第一步,就是对场景进行封装,梳理具体的场景枚举,并定义需要传递的数据类型。

如果场景值不需要传递数据,那么可以简单的使用:object xxxx,定义一个变量即可。

使用

接下来,我们来看下如何使用Sealed Class。

fun main() {
// 模拟封装枚举的产生
val result = if (true) {
Result.Success("Success")
} else {
Result.Error(Exception("error"))
}

when (result) {
is Result.Success -> print(result.data)
is Result.Error -> print(result.exception)
}
}

大部分场景下,Sealed Class都会配合when一起使用,同时,如果when的参数是Sealed Class,在IDE中可以快速补全所有分支,而且不会需要你单独补充else 分支,因为Sealed Class已经是完备的了。

所以when和Sealed Class真是天作之合。

进一步简化

其实我们还可以进一步简化代码的调用,因为我们每次使用Sealed Class的时候,都需要when一下,有些时候,也会产生一些代码冗余,所以,借助拓展函数,我们进一步对代码进行简化。

inline fun Result<Any>.doSuccess(success: (Any) -> Unit) {
if (this is Result.Success) {
success(data)
}
}

inline fun Result<Any>.doError(error: (Exception?) -> Unit) {
if (this is Result.Error) {
error(exception)
}
}

这里我对Result进行了拓展,增加了doSuccess和doError两个拓展,同时接收两个高阶函数来接收处理行为,这样我们在调用的时候就更加简单了。

result.doSuccess { }
result.doError { }

所以when和Sealed Class和拓展函数,真是天作之合。

那么你一定好奇了,Sealed Class又是怎么实现的,其实反编译一下就一目了然了,实际上Sealed Class也是通过抽象类来实现的,编译器生成了一个只能编译器调用的构造函数,从而避免其它类进行修改,实现了Sealed Class的有限性。

封装?

Sealed Class与抽象类类似,可以对逻辑进行拓展,我们来看下面这个例子。

sealed class TTS {

abstract fun speak()

class BaiduTTS(val value: String) : TTS() {
override fun speak() = print(value)
}

class TencentTTS(val value: String) : TTS() {
override fun speak() = print(value)
}
}

这时候如果要进行拓展,就很方便了,代码如下所示。

class XunFeiTTS(val value: String) : TTS() {
override fun speak() = print(value)
}

所以,Sealed Class可以说是在抽象类的基础上,增加了对状态有限性的控制,拓展与抽象,比枚举更加灵活和方便了。

再例如前面网络的封装:

sealed class Result<out T : Any> {
data class Success<out T : Any>(val data: T) : Result<T>()
sealed class Error(val exception: Exception) : Result<Nothing>() {
class RecoverableError(exception: Exception) : Error(exception)
class NonRecoverableError(exception: Exception) : Error(exception)
}

object InProgress : Result<Nothing>()
}

通过Sealed Class可以很方便的对Error类型进行拓展,同时,增加新的状态也非常简单,更重要的是,通过IDE的自动补全功能,IDE可以自动生成各个条件分支,避免人工编码的遗漏。


收起阅读 »

2021年度28个开箱即用的MQTT开源项目合集【附源码】

随着云通讯行业持续增长,5G建设逐步推进、音视频技术快速迭代,都是为了满足人与人、设备与人、设备与设备之间的消息传输。这无疑是一个消息传输的时代。在这个时代中,所有的协议、配置都在于恰到好处。正如MQTT消息传输协议基于物联网,但又不局限于物联网,同样可以在移...
继续阅读 »

随着云通讯行业持续增长,5G建设逐步推进、音视频技术快速迭代,都是为了满足人与人、设备与人、设备与设备之间的消息传输。这无疑是一个消息传输的时代。在这个时代中,所有的协议、配置都在于恰到好处。正如MQTT消息传输协议基于物联网,但又不局限于物联网,同样可以在移动互联网中承担多种功能。

MQTT是一个极其轻量级的发布/订阅消息传输协议,它解除时间与空间耦合,可以在应用内实现推送、通知等功能;它简约、轻量,极小的SDK空间占用,适用于嵌入Android、iOS、RTOS等多端平台;它数据包小、功耗低,适用于低带宽、高延迟或不可靠的网络环境。

环信MQTT消息云的产品定位就是充分发挥MQTT协议优势,为开发者提供应用与应用之间、设备与应用之间、应用与平台之间的消息传输服务。为了让大家更深入了解MQTT协议优势,环信举办了首届MQTT创意编程挑战赛,通过编程实战实际感受MQTT协议在应用间消息传输的优势。

经过1个多月的实战开发,首届环信MQTT创意编程挑战赛最终产生28个参赛作品,覆盖多领域多平台,生动有趣的展示了开发者们天马行空的创造力。下面我们一起看看那些优秀的开源作品吧~!

 

作品1:音乐广播神器 - 一起听

一起听是一款在线音频共享收听软件,用户可以快速创建一个包含音频播放器的网页,与众多好友实时在线点播收听和赏析歌曲。


一起听主播端截图

【一起听】终端用户分类两类 a.主播端用户(默认用户) b. 听众端用户(通过广播链接进入页面的用户)。主播角色用户登入一起听页面后,将系统自动生成的广播地址分享给好友,好友即听众端,通过该链接与主播角色进入同一个频道,进行歌曲播放,收听,进度同步。

一起听通过环信MQTT实现了以下功能:
(1)实时数据同步:包括歌曲播放同步、快进同步、切歌同步;
(2)控制指令:传输“我想听XXX”格式消息,可点播歌曲,“上一首/下一首”可控制切歌;
(3)实时消息传输:实现大型聊天室即时聊天功能;
(4)在线人数:获取好友上下线动态,实时更新在线人数;


该作品作为一款共享音乐神器,通过MQTT消息实现实时消息传输,控制命令,实时在线人数等功能,充分发挥了MQTT消息体小、实时传输、一对多广播等特性,同时支持数百万级客户端同时接入,消息毫秒级到达,并且可以灵活复制到共享音视频、在线直播、在线教学(云教室)等场景。


预览地址:https://www.easemob.com/product/mqtt/demo/musicplayer


作品2:手速王——在线PK - 趣味应用小游戏

手速王是一款与在线用户拼手速,实时PK的小游戏。


该小游戏基于环信MQTT实现实时在线人数的心跳连接,实时广播当前在线用户以及用户点击屏幕次数,点击态给与爱心动画展示增加趣味性,通过图表的方式实时刷新当前点击次数,依赖MQTT广播该数据,汇总当前在线人数的点击速度,同步更新排名结果,冠军会有对应的动画以鼓励。

手速王通过环信MQTT实现了以下功能:
(1)实时数据同步:实现更新排名;
(2)控制指令:点击时触发爱心动画;
(3)实时消息传输:实时传输用户点击屏幕次数;
(4)在线人数:获取好友上下线动态,实时更新在线人数;
该作品主要实现实时图表更新功能,通过MQTT消息实现高频高并发消息传输,并通过发送控制命令触发动画特效,增强了趣味性。



作品3:在线打击乐

在线打击乐是一款基于mikutap的高颜值多人互动网页游戏,用户在开始游戏后,通过敲击键盘,拖拽鼠标等操作,实现打击乐的效果,并可与同时在线用户同步音效。


(在线打击乐demo演示)


在线打击乐通过环信MQTT实现了以下功能:
(1)实时同步:实时同步用户敲击键盘的音效;
通过MQTT消息实现了不同设备间的音效同步,达到很好的互动效果。
(2)控制指令:实时发送鼠标点击或敲击键盘等控制动作消息,从而触发不同的音效。

预览地址:https://www.easemob.com/product/mqtt/demo/onlinecombat



作品4:小说阅读室

小说阅读室是一款基于环信MQTT实现的多人在线阅读App,可与阅读同一本书的用户同步阅读进度,切换章节,同步小说的字体与背景等操作,同时基于环信MQTT实现了实时在线阅读的同时进行即时IM沟通,通过读书遇到志同道合的人,为阅读增加了趣味性和社交属性。



(小说阅读室demo演示)

小说阅读室App内置4部本地小说,同时支持从服务端下载并缓存小说,进入指定小说之后可依据个人阅读习惯,设置字体,背景,同时支持章节快速切换,退出阅读记忆上次位置等。

小说阅读室通过环信MQTT实现了以下功能:
(1)实时数据同步:同步阅读进度;
(2)控制指令:切换章节,小说的字体与背景等操作;
(3)实时消息传输:支持在线聊天功能;
(4)更新好友动态:获取好友上下线动态;
该作品基于小说阅读室,通过MQTT实现控制指令、更新好友上下线动态、在线聊天等功能,增强了阅读场景中的社交属性,提高了阅读趣味性。



作品5:互动画板

互动画板是一款多人在线实时绘画的小工具。



(互动画板demo演示)

互动画板的用户数据存储到每个客户端上,通过环信mqtt绘制实时同步,画笔每条路径通过mqtt通知到其他客户端,路径中包含当前用户数据,同时可以同步线上用户数量。除此外,清除功能也采用环信mqtt发送控制指令,对应的用户作图立即清除。

互动画板通过环信MQTT实现了以下功能:
(1)实时数据同步:同步绘画轨迹;
(2)控制指令:清空画板指令;
(3)在线人数:获取好友上下线动态,实时更新在线人数;
该作品主要实现实时互动功能,通过MQTT消息小,延迟低,高频高并发等特性,实现了多人多端绘画轨迹0延迟同步,提升了互动绘画体验。



除以上作品外,还有很多优秀作品由于篇幅原因无法一一呈现,感兴趣的小伙伴可以前往官方Github仓库进行查看哦~

区块链空气币
多人实时位置共享APP
实时图表
在线选座
im聊天室(作者林鹏 - linpeng)
实时互动
热搜话题聊天室
在线点餐
基于Electron开发的喝水提醒
基于环信mqtt服务的shell远程命令执行
喝水提醒
基于环信MQTT的Serverless任务看板


Github地址:https://github.com/easemob/Creative-Challenge-MQTT
Gitee地址:
https://gitee.com/huanxin666
环信MQTT官方地址:
https://www.easemob.com/product/mqtt


收起阅读 »

flutter 优秀日志库 ulog

ulog ulog的想法和代码风格,设计方式与 Android logger库几乎无差别,差别在于ulog第一个版本不支持文件打印,但支持动态json库配置 库源码:github.com/smartbackme… v0.0.1只有基础的console打印,后面...
继续阅读 »

ulog


ulog的想法和代码风格,设计方式与 Android logger库几乎无差别,差别在于ulog第一个版本不支持文件打印,但支持动态json库配置
库源码:github.com/smartbackme…


v0.0.1只有基础的console打印,后面将会增加文件打印


开始使用


添加库
dependencies:
flutter_ulog: ^0.0.1


//Initialization
//构建基础adapter isLoggable可以通过不同type来拦截打印,或者关闭打印
class ConsoleAdapter extends ULogConsoleAdapter{
@override
bool isLoggable(ULogType type, String? tag) => true;
}
//初始化配置json库
ULog.init((value){
return "";
});
//添加打印适配器
ULog.addLogAdapter(ConsoleAdapter());

输出基别


  verbose
debug
info
warning
error

如何输出
ULog.v("12321321\ndfafdasfdsa\ndafdasf");
ULog.d("12321321");
ULog.i("12321321");
ULog.w("12321321");
ULog.e("1321231",error: NullThrownError());
var map = [];
map.add("1232");
map.add("1232");
map.add("1232");
map.add("1232");
ULog.e(map,error: NullThrownError());
ULog.json('''
{
"a1": "value",
"a2": 42,
"bs": [
{
"b1": "any value",
"b2": 13
},
{
"b1": "another value",
"b2": 0
}
]
}
''');

ULog.e("1321231",error: NullThrownError(),tag: "12312");
ULog.e("1232132112321321x");


优点:



  1. 可打印json字符串

  2. 打印行数很多时候会自动折行

  3. 可以打印模型

  4. 颜色区分

  5. 可扩展性强


打印效果:
打印分级
在这里插入图片描述
json打印
在这里插入图片描述


折行打印
在这里插入图片描述



收起阅读 »

iOS底层-内存对齐

iOS
一、什么是内存对齐? 我们先看下以下例子: struct struct0 { int a; char c; }s; NSLog(@"s : %lu", sizeof(s)); //输出8 在32位下,int占4byte, char占1b...
继续阅读 »

一、什么是内存对齐?


我们先看下以下例子:


struct struct0 {
int a;
char c;
}s;

NSLog(@"s : %lu", sizeof(s)); //输出8

32位下int4bytechar1byte,那么放到结构体应该是4+1=5byte啊,但实际得到的结果却是8,这就是内存对齐导致的。



元素是按照定义顺序一个一个放到内存中去的,但并不是紧密排列的。 从结构体存储的 首地址开始,每个元素放置到内存中时,它都会认为内存是按照自己的大小(通常它为4或8)来划分的,因此元素放置的位置一定会在自己宽度的整数倍上开始,这就是所谓的内存对齐



二、为什么要进行内存对齐?


1.平台限制



  • 各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取,它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存。


2.性能原因



  • 例如在32位下的int型变量,如果按照对齐的方式,读一次就可以读出,如果忽略对齐,int存放在奇数位或者不在4的倍数位上,则需要读两次,再进行拼接才能得到数据。

    • 例如,没有内存对齐机制,一个int变量从地址1开始存储,如下图:




截屏2021-06-09 11.51.18.png
这种情况,处理器需要:剔除不要的,再合并到寄存器,做了些额外的操作。而在内存对齐的环境中,一次就可以读出数据,提高了效率


三、内存对齐规则


1. 概念:



  • 每个特定平台上的编译器都有自己的默认对齐系数(也叫对齐模数)。可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数,这个n就是要制定的对齐系数

  • 有效对齐值:给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位

  • Xcode中默认#pragma pack(8),也就是8字节对齐


2. 规则:



  • (1): 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节

  • (2): 结构体的总大小为有效对齐值的整数倍,如有需要编译器会在最末一个成员之后加上填充字节


3.举例:


结构体


环境(Xcode 12.3)

例一


struct Struct1 {
double a;
char b;
int c;
short d;
}s1;

分析



  • 对齐系数为:pack(8),也就是8字节,最长的数据类型是:double,也是8字节,则 有效对齐值min(8,8) = 8

  • 根据规则(1)(2),可得出:


截屏2021-06-09 14.45.19.png



每个成员的大小都要和有效对齐值对比,取比较小的数,然后这个数的整数倍作为这个成员变量的offset起始存储位置,如果中间不是整数倍,则需要填充字节。成员变量存储完后,如果整体大小不是有效对齐值的整数倍,则需要在 末尾填充字节 至整体大小为有效对齐值的整数倍为止。



分析的结果是 24字节,我们打印来验证下:


截屏2021-06-09 13.25.26.png


打印的结果和我们分析的一样~


例二


struct Struct2 {
double a;
int b;
char c;
short d;
}s2;

printf("\n Struct1 size: %lu\n", sizeof(s2)); // 输出 16


这两个结构体成员变量都是一样的,只不过位置换了下,怎么内存大小也变了?啊这...



我们继续来分析下:


截屏2021-06-09 14.45.43.png
分析得到的结果是16字节,我们再来打印验证下:

截屏2021-06-09 13.40.57.png


原来结构体成员变量的顺序对内存也是有影响的


例三


struct Struct3 {
double a; // 8 [0,7]
int b; // 4 [8,11]
char c; // 1 [12]
short d; // 2 (13, [14,15]
int e; // 4 [16, 19]
struct Struct1 {
double a; // 8 (20, 21, 22, 23, [24, 31]
char b; // 1 [32]
int c; // 4 (33, 34, 35 [36, 39]
short d; // 2 [40, 41],42, 43, 44, 45, 46, 47, 48
}s1;
}s3;

分析



  • 对齐系数为:pack(8) = 8Struct1中最大为8字节Struct3其他的成员最大为8字节, 则Struct3 有效对齐值min(pack(8),8) = 8


截屏2021-06-09 17.02.54.png
打印验证下:


截屏2021-06-09 15.30.12.png

结果是正确的~


可能存在的误区


  1. offset:计算成员的offset时,有可能会将成员大小的整数倍当做其offset,这个是错误的,offset是由有效对齐值成员大小 二者的最小值的整数倍决定的

  2. 整体大小:整体大小(如果需要补充字节,就是补充后的)是最大成员的整数倍,这是不准确的。整体大小是有效对齐值的整数倍


我们在举一个例子:


32位下


// 32 位下对齐系数 pack(4)
struct Struct4 {
int a; // 4 [0,3]
double b; // 8 [4,11]
}s4;

printf("\n\n Struct4 size: %lu\n", sizeof(s4)); // 输出 12

64位下


// 64 位下对齐系数 pack(8)
struct Struct4 {
int a; // 4 [0,3]
double b; // 8 (4, 5, 6, 7, [8, 15]
}s4;

printf("\n\n Struct4 size: %lu\n", sizeof(s4)); // 输出 16

打印如下:


image_2021-06-09_17-35-22.png




  • 32位中,Struct4有效对齐值min(pack(4), 8) = 4a从首地址开始,boffsetmin( min(pack(4), 8), 8) = 4,所以得出结果为12

  • 64位中,Struct4有效对齐值min(pack(8), 8) = 8a从首地址开始,boffsetmin( min(pack(8), 8), 8) = 8,所以得出结果为16



总结


  • 计算成员的offset是由有效对齐值成员大小二者的最小值的整数倍决定的。

  • 整体大小(如果需要补充字节,就是补充后的)是有效对齐值的整数倍。

  • 结构体成员的顺序不同会导致内存不同,而对象的本质就是一个结构体,我们可以调整对象属性的位置来达到内存优化的目的。


对象


获取对象内存大小的方式


  • sizeof

  • class_getInstanceSize

  • malloc_size


sizeof


  1. sizeof运算符,而不是一个函数

  2. sizeof传进来的是类型,用来计算这个类型占多大内存,这个在编译器编译阶段就会确定大小并直接转化成8 、16、24这样的常数,而不是在运行时计算。参数可以是数组、指针、类型、对象、结构体、函数等。

  3. 它的功能是:获得保证能容纳实现所建立的最大对象的字节大小


class_getInstanceSize


  • class_getInstanceSizeruntime提供的api,计算对象实际占用的内存大小,采用8字节对齐的方式进行运算。


malloc_size


  • malloc_size用来计算对象实际分配的内存大小,这个是由系统采用16字节对齐的方式运算的


可以通过下面代码测试:


@interface LGPerson : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *nickName;
@property (nonatomic, assign) int age;
@property (nonatomic, assign) long height;

@end
// LGPerson 中有4个属性 NSString *name,NSString *nickName,int age,long height
int main(int argc, const char * argv[]) {
LGPerson *person = [LGPerson alloc];
person.name = @"Cc";
person.nickName = @"KC";

NSLog(@"\n%lu\n - %lu\n - %lu\n", sizeof(person), class_getInstanceSize([LGPerson class]), malloc_size((__bridge const void *)(person)));
// 输出 8, 40, 48
return 0;
}

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

收起阅读 »

iOS底层-多线程之GCD(下)

iOS
前言 前面的文章讲述了同步和异步的底层分析步骤,今天来讲GCD实际的应用相关的函数及原理,主要是:栅栏函数,信号量,线程组和Dispatch_source 栅栏函数 栅栏函数有一个比较直接的效果:控制任务的执行顺序,导致同步的效果。 栅栏函数有两种: di...
继续阅读 »

前言


前面的文章讲述了同步和异步的底层分析步骤,今天来讲GCD实际的应用相关的函数及原理,主要是:栅栏函数信号量线程组Dispatch_source


栅栏函数



  • 栅栏函数有一个比较直接的效果:控制任务的执行顺序,导致同步的效果

  • 栅栏函数有两种:

    • dispatch_barrier_async

    • dispatch_barrier_sync




下面通过案例来分析他们的作用:


dispatch_barrier_async


先来看看异步栅栏的案例:


- (void)testAsync_barrier {
dispatch_queue_t concurrent = dispatch_queue_create("wushuang.concurrent", DISPATCH_QUEUE_CONCURRENT);
NSLog(@" 开始啦 ~ ");
dispatch_async(concurrent, ^{
sleep(1);
NSLog(@"1");
});
dispatch_async(concurrent, ^{
sleep(1);
NSLog(@"2");
});
dispatch_barrier_async(concurrent, ^{
NSLog(@"——————— 大栅栏 ———————");
});
dispatch_async(concurrent, ^{
NSLog(@"3");
});
dispatch_async(concurrent, ^{
NSLog(@"4");
});
NSLog(@" ~~ 你过来呀 ~~ ");
}
复制代码


  • 为了更直接的观察,在栅栏前面的异步函数里加上了sleep,打印结果如下:


截屏2021-08-18 14.07.21.png



  • 从打印结果可以看出来,栅栏函数拦住的是同一个线程中的任务,并 不会阻塞线程


dispatch_barrier_sync


再来看看同步栅栏:


- (void)testSync_barrier {
dispatch_queue_t concurrent = dispatch_queue_create("wushuang.concurrent", DISPATCH_QUEUE_CONCURRENT);
NSLog(@" 开始啦 ~ ");
dispatch_async(concurrent, ^{
sleep(1);
NSLog(@"1");
});
dispatch_async(concurrent, ^{
sleep(1);
NSLog(@"2");
});
dispatch_barrier_sync(concurrent, ^{
NSLog(@"——————— 同步大栅栏 ———————");
});
dispatch_async(concurrent, ^{
NSLog(@"3");
});
dispatch_async(concurrent, ^{
NSLog(@"4");
});
NSLog(@" ~~ 你过来呀 ~~ ");
}
复制代码

先看看打印结果:


截屏2021-08-18 14.14.17.png



  • 从结果中能够看出来,同步栅栏函数也拦住的是同一个队列中的任务,但 阻塞线程


全局队列栅栏


我们平常用的栅栏函数都是创建的并发队列,那么使用在全局队列使用呢?


- (void)testGlobalBarrier {
dispatch_queue_t global = dispatch_get_global_queue(0, 0);
NSLog(@" 开始啦 ~ ");
dispatch_async(global, ^{
sleep(1);
NSLog(@"1");
});
dispatch_async(global, ^{
sleep(1);
NSLog(@"2");
});
dispatch_barrier_async(global, ^{
NSLog(@"——————— 大栅栏 ———————");
});
dispatch_async(global, ^{
NSLog(@"3");
});

dispatch_async(global, ^{
NSLog(@"4");
});
NSLog(@" ~~ 你过来呀 ~~ ");
}
复制代码


  • 打印结果如下:


截屏2021-08-18 14.27.05.png



  • 结果什么也没有拦住,说明全局队列比较特殊。



疑问:
1. 为什么栅栏函数可以控制任务
2. 为什么栅栏函数在全局队列不起作用




底层源码



  • dispatch_barrier_sync来分析,搜索跟流程最终会进入_dispatch_barrier_sync_f_inline方法


截屏2021-08-18 16.28.41.png



  • 通过符号断点调试,发现最终会走_dispatch_sync_f_slow方法,同时设置DC_FLAG_BARRIER标记,再跟进


截屏2021-08-18 16.40.18.png



  • 再根据下符号断点,确定走_dispatch_sync_invoke_and_complete_recurse方法,并且将DC_FLAG_BARRIER参数传入


截屏2021-08-18 16.42.27.png



  • 进入_dispatch_sync_function_invoke_inline方法能够发现是_dispatch_client_callout方法,也就是栅栏函数的调用,但我们研究的核心是为什么会控制任务,通过下符号断点发现,栅栏函数执行后会走_dispatch_sync_complete_recurse方法


截屏2021-08-18 16.46.26.png



  • 于是跟进_dispatch_sync_complete_recurse方法


static void
_dispatch_sync_complete_recurse(dispatch_queue_t dq, dispatch_queue_t stop_dq,
uintptr_t dc_flags)
{
bool barrier = (dc_flags & DC_FLAG_BARRIER);
do {
if (dq == stop_dq) return;
if (barrier) {
dx_wakeup(dq, 0, DISPATCH_WAKEUP_BARRIER_COMPLETE);
} else {
_dispatch_lane_non_barrier_complete(upcast(dq)._dl, 0);
}
dq = dq->do_targetq;
barrier = (dq->dq_width == 1);
} while (unlikely(dq->do_targetq));
}
复制代码


  • 此处是一个do-while循环,首先dc_flags传入的是DC_FLAG_BARRIER,所以(dc_flags & DC_FLAG_BARRIER)一定有值的,于是在循环中会走dx_wakeup,去唤醒队列中的任务,唤醒完成后也就是栅栏结束,就会走_dispatch_lane_non_barrier_complete函数,也就是继续栅栏之后的流程。

  • 再查看此时dx_wakeup函数,此时flag传入为DISPATCH_WAKEUP_BARRIER_COMPLETE


截屏2021-08-18 17.01.29.png


_dispatch_lane_wakeup



  • 并发流程_dispatch_root_queue_wakeup中,根据flag判断会走_dispatch_lane_barrier_complete方法:


截屏2021-08-18 17.06.05.png



  • 当串行或者栅栏时,会调用_dispatch_lane_drain_barrier_waiter阻塞任务,直到确认当前队列前面任务执行完就会继续后面任务的执行


_dispatch_root_queue_wakeup


void
_dispatch_root_queue_wakeup(dispatch_queue_global_t dq,
DISPATCH_UNUSED dispatch_qos_t qos, dispatch_wakeup_flags_t flags)
{
if (!(flags & DISPATCH_WAKEUP_BLOCK_WAIT)) {
DISPATCH_INTERNAL_CRASH(dq->dq_priority,
"Don't try to wake up or override a root queue");
}
if (flags & DISPATCH_WAKEUP_CONSUME_2) {
return _dispatch_release_2_tailcall(dq);
}
}
复制代码


  • 全局并发队列源码中什么没做,此时的栅栏相当于一个普通的异步并发函数没起作用,为什么呢?全局并发是系统创建的并发队列,如果阻塞可能会导致系统任务出现问题,所以在使用栅栏函数时,不能使用全部并发队列


总结





    1. 栅栏函数可以阻塞当前线程的任务,达到控制任务的效果,但只能在创建的并发队列中使用





    1. 栅栏函数也可以在多读单写的场景中使用





    1. 栅栏函数只能在当前线程使用,如果多个线程就会起不到想要的效果




信号量



  • 程序员的自我修养中这本书的26页,有对二元信号量的讲解,它只有01两个状态,多元信号量简称信号量

  • GCD中的信号量dispatch_semaphore_t中主要有三个函数:

    • dispatch_semaphore_create:创建信号

    • dispatch_semaphore_wait:等待信号

    • dispatch_semaphore_signal:释放信号




案例分析


- (void)testDispatchSemaphore {
dispatch_queue_t global = dispatch_get_global_queue(0, 0);
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

NSLog(@"~~ 开始 ~~");
dispatch_async(global, ^{
NSLog(@"~~ 0 ~~");
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
NSLog(@"~~ 1 ~~");
});

dispatch_async(global, ^{
NSLog(@"~~ 2 ~~");
sleep(2);
dispatch_semaphore_signal(semaphore);
NSLog(@"~~ 3 ~~");
});
NSLog(@" ~~ 你过来呀 ~~ ");
}
复制代码


  • 运行结果如下:


截屏2021-08-18 19.52.23.png



  • 从打印结果中可以看出来,在先走了一个异步任务所以打印了0,但是由于没有信号,所以在dispatch_semaphore_wait就原地等待,导致1没法执行,此时第二个异步任务执行了就打印了2,然后dispatch_semaphore_signal释放信号,之后1就可以打印了


源码解读


再来看看源码分析


dispatch_semaphore_create


截屏2021-08-18 22.33.40.png



  • 首先如果信号为小于0,则返回一个DISPATCH_BAD_INPUT类型对象,也就是返回个_Nonnull

  • 如果信号大于等于0,就会对dispatch_semaphore_t对象dsema进行一些赋值,并返回dsema对象


dispatch_semaphore_wait


intptr_t
dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)
{
long value = os_atomic_dec2o(dsema, dsema_value, acquire);
if (likely(value >= 0)) {
return 0;
}
return _dispatch_semaphore_wait_slow(dsema, timeout);
}
复制代码


  • 等待信号主要是通过os_atomic_dec2o函数对信号进行自减,当值大于等于0时返回0

  • 当值小于0时,会走_dispatch_semaphore_wait_slow方法


_dispatch_semaphore_wait_slow

截屏2021-08-18 23.02.51.png




  • 当设置了timeout(超时时间),则会根据类型进行相关操作,本文使用的是DISPATCH_TIME_FOREVER,此时调用_dispatch_sema4_wait进行等待处理:


    截屏2021-08-18 23.10.06.png



    • 这里主要是一个do-while循环里队等待信号,当不满足条件后才会跳出循环,所以会出现一个等待的效果




dispatch_semaphore_signal


intptr_t
dispatch_semaphore_signal(dispatch_semaphore_t dsema)
{
long value = os_atomic_inc2o(dsema, dsema_value, release);
if (likely(value > 0)) {
return 0;
}
if (unlikely(value == LONG_MIN)) {
DISPATCH_CLIENT_CRASH(value,
"Unbalanced call to dispatch_semaphore_signal()");
}
return _dispatch_semaphore_signal_slow(dsema);
}
复制代码


  • 发送信号主要是通过os_atomic_inc2o对信号进行自增,如果自增后的结果大于0,就返回0

  • 如果自增后还是小于0,就会走到_dispatch_semaphore_signal_slow方法


_dispatch_semaphore_signal_slow

intptr_t
_dispatch_semaphore_signal_slow(dispatch_semaphore_t dsema)
{
_dispatch_sema4_create(&dsema->dsema_sema, _DSEMA4_POLICY_FIFO);
_dispatch_sema4_signal(&dsema->dsema_sema, 1);
return 1;
}
复制代码


  • 这里有个_dispatch_sema4_signal函数进行缓慢发送信号


截屏2021-08-18 23.14.05.png


调度组



  • 调度组最直接的作用就是:控制任务的执行顺序Api主要有以下几个方法:

    • dispatch_group_create:创建组

    • dispatch_group_async:进组任务

    • dispatch_group_notify:组任务执行完毕的通知

    • dispatch_group_enter:进组

    • dispatch_group_leave:出组

    • dispatch_group_wait:等待组任务时间




案例分析


dispatch_group_async


- (void)testDispatchGroup1 {
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
dispatch_group_async(group, globalQueue, ^{
sleep(1);
NSLog(@"1");
});

NSLog(@"2");
dispatch_group_async(group, globalQueue, ^{
sleep(1);
NSLog(@"3");
});

NSLog(@"4");
dispatch_group_notify(group, globalQueue, ^{
NSLog(@"5");
});

NSLog(@"~~ 6 ~~");
}
复制代码


  • 讲任务1和3放到组任务,然后将5任务放到dispatch_group_notify中执行,输出结果如下:


截屏2021-08-19 11.24.37.png



  • 输出结果可以得出结论:



      1. 调度组不会阻塞线程





      1. 组任务执行没有顺序,相当于异步并发队列





      1. 组任务执行完后才会执行dispatch_group_notify任务






dispatch_group_wait





    1. 将任务1和3分别放到两个任务组,然后在下面执行dispatch_group_wait等待10秒




- (void)testDispatchGroup1 {
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
dispatch_group_async(group, globalQueue, ^{
sleep(5);
NSLog(@"1");
});

NSLog(@"2");
dispatch_group_async(group, globalQueue, ^{
sleep(5);
NSLog(@"3");
});

dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC * 10);
dispatch_group_wait(group, time);

NSLog(@"~~ 6 ~~");
}
复制代码


  • 输出结果如下:


截屏2021-08-19 11.49.47.png

发现等待的是10秒,但5秒后任务执行完,立即执行任务6





    1. 在将等待时间改成3秒




截屏2021-08-19 11.53.34.png

这里是等3秒,发现组任务还没执行完就去执行6任务



  • 总结dispatch_group_wait的作用是阻塞调度组之外的任务



      1. 当等待时间结束时,组任务还没完成,就结束阻塞执行其他任务





      1. 当组任务完成,等待时间还未结束时,会结束阻塞执行其他任务






进组 + 出组


- (void)testDispatchGroup2 {
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);

dispatch_group_enter(group);
dispatch_async(globalQueue, ^{
sleep(1);
NSLog(@"1");
dispatch_group_leave(group);
});

NSLog(@"2");

dispatch_group_enter(group);
dispatch_async(globalQueue, ^{
sleep(1);
NSLog(@"3");
dispatch_group_leave(group);
});

dispatch_group_notify(group, globalQueue, ^{
NSLog(@"4");
});

NSLog(@"5");
}
复制代码


  • 进组+出组的案例基本和上面的一致,只是将进组任务拆分成进组+出组,执行结果如下:


截屏2021-08-19 11.29.38.png




  • 输出结果和dispatch_group_async基本一致,说明他们两个作用相同




  • 进组+出组组合有几个 注意事项,他们必须是成对出现,必须先进组后出组,否则会出现以下问题:





      1. 如果少一个dispatch_group_leave,则dispatch_group_notify不会执行




    截屏2021-08-19 11.40.04.png





      1. 如果少一个dispatch_group_enter,则任务执行完会走notify但不成对的dispatch_group_leave处会崩溃




    截屏2021-08-19 11.37.34.png





      1. 如果先leaveenter,此时会直接崩溃在leave




    截屏2021-08-19 11.43.49.png





疑问:
1. 调度组是如何达到流程控制的?
2. 为什么进组+出组要搭配使用,且效果和dispatch_group_async是一样的?
3. 为什么先dispatch_group_leave会崩溃?



带着问题我们去源码中探索究竟


原理分析


dispatch_group_creat


dispatch_group_t
dispatch_group_create(void)
{
return _dispatch_group_create_with_count(0);
}
复制代码

dispatch_group_create方法会调用_dispatch_group_create_with_count方法,并传入参数0


static inline dispatch_group_t
_dispatch_group_create_with_count(uint32_t n)
{
dispatch_group_t dg = _dispatch_object_alloc(DISPATCH_VTABLE(group),
sizeof(struct dispatch_group_s));
dg->do_next = DISPATCH_OBJECT_LISTLESS;
dg->do_targetq = _dispatch_get_default_queue(false);
if (n) {
os_atomic_store2o(dg, dg_bits,
(uint32_t)-n * DISPATCH_GROUP_VALUE_INTERVAL, relaxed);
os_atomic_store2o(dg, do_ref_cnt, 1, relaxed); // <rdar://22318411>
}
return dg;
}
复制代码


  • 方法的核心是创建dispatch_group_t对象dg,并对它的do_nextdo_targetq参数进行赋值,然后调用os_atomic_store2o进行存储


dispatch_group_enter


void
dispatch_group_enter(dispatch_group_t dg)
{
// The value is decremented on a 32bits wide atomic so that the carry
// for the 0 -> -1 transition is not propagated to the upper 32bits.
uint32_t old_bits = os_atomic_sub_orig2o(dg, dg_bits,
DISPATCH_GROUP_VALUE_INTERVAL, acquire);
uint32_t old_value = old_bits & DISPATCH_GROUP_VALUE_MASK;
if (unlikely(old_value == 0)) {
_dispatch_retain(dg); // <rdar://problem/22318411>
}
if (unlikely(old_value == DISPATCH_GROUP_VALUE_MAX)) {
DISPATCH_CLIENT_CRASH(old_bits,
"Too many nested calls to dispatch_group_enter()");
}
}
复制代码

主要调用os_atomic_sub_orig2o进行自减操作,也就是由0 -> -1,这块和信号量的操作很像,但没有wait的步骤


dispatch_group_leave


void
dispatch_group_leave(dispatch_group_t dg)
{
// The value is incremented on a 64bits wide atomic so that the carry for
// the -1 -> 0 transition increments the generation atomically.
uint64_t new_state, old_state = os_atomic_add_orig2o(dg, dg_state,
DISPATCH_GROUP_VALUE_INTERVAL, release);
uint32_t old_value = (uint32_t)(old_state & DISPATCH_GROUP_VALUE_MASK);

if (unlikely(old_value == DISPATCH_GROUP_VALUE_1)) {
old_state += DISPATCH_GROUP_VALUE_INTERVAL;
do {
new_state = old_state;
if ((old_state & DISPATCH_GROUP_VALUE_MASK) == 0) {
new_state &= ~DISPATCH_GROUP_HAS_WAITERS;
new_state &= ~DISPATCH_GROUP_HAS_NOTIFS;
} else {
// If the group was entered again since the atomic_add above,
// we can't clear the waiters bit anymore as we don't know for
// which generation the waiters are for
new_state &= ~DISPATCH_GROUP_HAS_NOTIFS;
}
if (old_state == new_state) break;
} while (unlikely(!os_atomic_cmpxchgv2o(dg, dg_state,
old_state, new_state, &old_state, relaxed)));
return _dispatch_group_wake(dg, old_state, true);
}

if (unlikely(old_value == 0)) {
DISPATCH_CLIENT_CRASH((uintptr_t)old_value,
"Unbalanced call to dispatch_group_leave()");
}
}
复制代码

通过os_atomic_add_orig2o进行自增(-1 ~ 0)得到old_state = 0


DISPATCH_GROUP_VALUE_MASK       0x00000000fffffffcULL
DISPATCH_GROUP_VALUE_1          DISPATCH_GROUP_VALUE_MASK
复制代码




    1. old_value等于old_state & DISPATCH_GROUP_VALUE_MASK等于0,此时old_value != DISPATCH_GROUP_VALUE_1,再由于判断是unlikely,所以会进入if判断





    1. 由于DISPATCH_GROUP_VALUE_INTERVAL = 4,所以此时old_state = 4,再进入do-while循环,此时会走else判断new_state &= ~DISPATCH_GROUP_HAS_NOTIFS= 4 & ~2 = 4,这时old_statenew_state相等,然后跳出循环执行_dispatch_group_wake方法,也就是唤醒dispatch_group_notify方法





    1. 假如enter两次,则old_state=-1,加上4后3,也就是在do-while循环时,new_state = old_state = 3。然后new_state &= ~DISPATCH_GROUP_HAS_NOTIFS = 3 & ~2 = 1,然后不等与old_state会再进行循环,当再次进行leave自增操作后,新的循环判断会走到_dispatch_group_wake函数进行唤醒




dispatch_group_notify


static inline void
_dispatch_group_notify(dispatch_group_t dg, dispatch_queue_t dq,
dispatch_continuation_t dsn)
{
uint64_t old_state, new_state;
dispatch_continuation_t prev;

dsn->dc_data = dq;
_dispatch_retain(dq);

prev = os_mpsc_push_update_tail(os_mpsc(dg, dg_notify), dsn, do_next);
if (os_mpsc_push_was_empty(prev)) _dispatch_retain(dg);
os_mpsc_push_update_prev(os_mpsc(dg, dg_notify), prev, dsn, do_next);
if (os_mpsc_push_was_empty(prev)) {
os_atomic_rmw_loop2o(dg, dg_state, old_state, new_state, release, {
new_state = old_state | DISPATCH_GROUP_HAS_NOTIFS;
if ((uint32_t)old_state == 0) {
os_atomic_rmw_loop_give_up({
return _dispatch_group_wake(dg, new_state, false);
});
}
});
}
}
复制代码

主要是通过os_atomic_rmw_loop2o进行do-while循环判断,知道old_state == 0时就会走_dispatch_group_wake唤醒,也就是会去走block执行



  • 此时还有一个问题没有解决,就是dispatch_group_async为什么和进组+出组效果一样,再来分析下源码


dispatch_group_async


void
dispatch_group_async(dispatch_group_t dg, dispatch_queue_t dq,
dispatch_block_t db)
{
dispatch_continuation_t dc = _dispatch_continuation_alloc();
uintptr_t dc_flags = DC_FLAG_CONSUME | DC_FLAG_GROUP_ASYNC;
dispatch_qos_t qos;

qos = _dispatch_continuation_init(dc, dq, db, 0, dc_flags);
_dispatch_continuation_group_async(dg, dq, dc, qos);
}
复制代码

这个函数内容比较熟悉,与异步的函数很像,但此时dc_flags = DC_FLAG_CONSUME | DC_FLAG_GROUP_ASYNC,然后走进_dispatch_continuation_group_async函数:


static inline void
_dispatch_continuation_group_async(dispatch_group_t dg, dispatch_queue_t dq,
dispatch_continuation_t dc, dispatch_qos_t qos)
{
dispatch_group_enter(dg);
dc->dc_data = dg;
_dispatch_continuation_async(dq, dc, qos, dc->dc_flags);
}
复制代码

这里我们看到了dispatch_group_enter,但是没有看到leave函数,我猜想肯定在block执行的地方会执行leave,不然不能确保组里任务执行完,于是根据global类型的dq_push = _dispatch_root_queue_push最终找到_dispatch_continuation_invoke_inline函数


截屏2021-08-19 15.57.50.png


如果是普通异步类型会走到_dispatch_client_callout函数,如果是DC_FLAG_GROUP_ASYNC组类型,会走_dispatch_continuation_with_group_invoke函数


static inline void
_dispatch_continuation_with_group_invoke(dispatch_continuation_t dc)
{
struct dispatch_object_s *dou = dc->dc_data;
unsigned long type = dx_type(dou);
if (type == DISPATCH_GROUP_TYPE) {
_dispatch_client_callout(dc->dc_ctxt, dc->dc_func);
_dispatch_trace_item_complete(dc);
dispatch_group_leave((dispatch_group_t)dou);
} else {
DISPATCH_INTERNAL_CRASH(dx_type(dou), "Unexpected object type");
}
}
复制代码

此时,当类型是DISPATCH_GROUP_TYPE时,就会先执行_dispatch_client_callout,然后执行dispatch_group_leave,至此前面的问题全部解决


信号源Dispatch_source



  • 信号源Dispatch_source是一个尽量不占用资源,且CPU负荷非常小的Api,它不受Runloop影响,是和Runloop平级的一套Api。主要有以下几个函数组成:



      1. dispatch_source_create:创建信号源





      1. dispatch_source_set_event_handler:设置信号源回调





      1. dispatch_source_merge_data:源时间设置数据





      1. dispatch_source_get_data:获取信号源数据





      1. dispatch_resume:继续





      1. dispatch_suspend:挂起





  • 在任一线程上调用函数dispatch_source_merge_data后,会执行Dispatch Source事先定义好的句柄(可以理解为一个block),这个过程叫Custom event用户事件,是Dispatch Source支持处理的一种事件


信号源类型



  • 创建源
    dispatch_source_t
    dispatch_source_create(dispatch_source_type_t type,
    uintptr_t handle,
    uintptr_t mask,
    dispatch_queue_t _Nullable queue);
    复制代码


    • 第一个参数是dispatch_source_type_t类型的type,然后handlemask都是uintptr_t类型的,最后要传入一个队列



  • 使用方法:
    dispatch_source_t source = dispatch_source_create(DISPATCH_SOURCE_TYPE_DATA_ADD, 0, 0, dispatch_get_main_queue());
    复制代码


  • 源的类型dispatch_source_type_t



      1. DISPATCH_SOURCE_TYPE_DATA_ADD:用于ADD合并数据





      1. DISPATCH_SOURCE_TYPE_DATA_OR:用于按位或合并数据





      1. DISPATCH_SOURCE_TYPE_DATA_REPLACE跟踪通过调用dispatch_source_merge_data获得的数据的分派源,新获得的数据值将替换 尚未交付给源处理程序 的现有数据值





      1. DISPATCH_SOURCE_TYPE_MACH_SEND:用于监视Mach端口无效名称通知的调度源,只能发送没有接收权限





      1. DISPATCH_SOURCE_TYPE_MACH_RECV:用于监视Mach端口挂起消息





      1. DISPATCH_SOURCE_TYPE_MEMORYPRESSURE:用于监控系统内存压力变化





      1. DISPATCH_SOURCE_TYPE_PROC:用于监视外部进程的事件





      1. DISPATCH_SOURCE_TYPE_READ监视文件描述符以获取可读取的挂起字节的分派源





      1. DISPATCH_SOURCE_TYPE_SIGNAL监控当前进程以获取信号的调度源





      1. DISPATCH_SOURCE_TYPE_TIMER:基于计时器提交事件处理程序块的分派源





      1. DISPATCH_SOURCE_TYPE_VNODE:用于监视文件描述符中定义的事件的分派源





      1. DISPATCH_SOURCE_TYPE_WRITE监视文件描述符以获取可写入字节的可用缓冲区空间的分派源。






定时器


下面使用Dispatch Source来封装一个定时器


- (void)testTimer {

self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(0, 0));
dispatch_time_t startTime = dispatch_time(DISPATCH_TIME_NOW, 0);
dispatch_source_set_timer(self.timer, startTime, 1 * NSEC_PER_SEC, 0);

__block int a = 0;
dispatch_source_set_event_handler(self.timer, ^{
a++;
NSLog(@"a 的 值 %d", a);
});

dispatch_resume(self.timer);
self.isRunning = YES;
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
if (self.isRunning) {
dispatch_suspend(self.timer);
// dispatch_source_cancel(self.timer);
self.isRunning = NO;
NSLog(@" 中场休息下~ ");
} else {
dispatch_resume(self.timer);
self.isRunning = YES;
NSLog(@" 继续喝~ ");
}
}
复制代码

输出结果如下:


截屏2021-08-19 18.34.34.png



  • 创建定时器dispatch_source_create时,一定要用属性或者实例变量接收,不然定时器不会执行

  • dispatch_source_set_timer的第二个参数start是从什么时候开始,第三个参数interval是时间间隔,leeway是计时器的纳秒偏差

  • 计时器停止有两种:

    • dispatch_resume:计时器暂停但一直在线,可以唤醒

    • dispatch_source_cancel:计时器释放,执行dispatch_resume唤醒,会崩溃




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

iOS底层-多线程之GCD(上)

iOS
iOS底层-多线程之GCD(上)前言说到多线程,我们肯定就不会忽视GCD,因为它用法比较简洁,Api也比较易懂,对于处理多个任务等都是比较简单的,接来下将对GCD进行总结和探究。简介GCD全称是Grand Central Dispatch,纯C语言Api,提供...
继续阅读 »

iOS底层-多线程之GCD(上)

前言

说到多线程,我们肯定就不会忽视GCD,因为它用法比较简洁,Api也比较易懂,对于处理多个任务等都是比较简单的,接来下将对GCD进行总结和探究。

简介

    1. GCD全称是Grand Central Dispatch,纯C语言Api,提供了非常多的强大函数
    1. GCD优势:
      1. GCD是苹果公司为多核的并行运算提出的解决方案
      1. GCD会自动利用更多的CPU内核(如:双核、四核)
      1. GCD自动管理线程的生命周期:创建线程调度任务销毁线程,程序员只需要告诉GCD想要执行什么任务,不需要编写任何线程管理代码

函数

  • 任务:GCD的任务使用block封装,block没有参数没有返回值

执行任务的函数:

  • 异步dispatch_async:不用等待当前语句执行完毕,就可以执行下一条语句
    • 会开启线程执行block的任务
    • 异步是多线程的代名词
  • 同步dispatch_sync:必须等待当前语句执行完毕,才会执行下一条语句
    • 不会开启线程
    • 在当前线程执行block任务

队列

队列分为串行队列并行队列,他们是一个数据结构,都遵循FIFO(先进先出)原则

串行队列

  • 串行队列在同一时间只能执行一个任务,如图所示:

截屏2021-08-08 18.03.50.png

  • 在根据FIFO原则先进先出,所以后面的任务必须等前面的任务执行完毕才能执行,就导致串行队列是顺序执行

并行队列

  • 并行队列是一次可以调度多个任务,但并不一定都能执行,线程的状态必须是runable时才能执行,所以先调度不一定先执行:

截屏2021-08-08 18.03.58.png

  • 如果可以看出,并行在同一时间能执行多个任务

案例分析

    1. 并发异步任务
- (void)textDemo1{
dispatch_queue_t queue = dispatch_queue_create("wushuang.concurrent", DISPATCH_QUEUE_CONCURRENT); //并发队列
NSLog(@"1");
dispatch_async(queue, ^{
NSLog(@"2");
dispatch_sync(queue, ^{
NSLog(@"3");
});
NSLog(@"4");
});
NSLog(@"5");
}

  • 执行顺序是什么呢,分析如下:

    • 首先1在主线程且顺序执行,所以1最新打印
    • 然后进入dispatch_async异步函数的block块,块里先执行2,同步函数会阻塞4的打印,所以快中3在2和4中间
    • 5是看异步函数执行的速度,有可能在1后面,也可能在234后面
  • 所以可能的结果是15234125341235412345

    1. 串行异步任务
- (void)textDemo1{
dispatch_queue_t queue = dispatch_queue_create("wushuang.serial", NULL); //串行队列
NSLog(@"1");
dispatch_async(queue, ^{
NSLog(@"2");
dispatch_sync(queue, ^{
NSLog(@"3");
});
NSLog(@"4");
});
NSLog(@"5");
}

  • 这个顺序回是怎样的呢,再来分析下
    • 首先1先执行
    • 然后执行dispatch_async块中的代码,块中的同步函数次时与第一种不一样,根据串行队列FIFO原则且是顺序执行,所以3执行的前提是dispatch_async函数执行完,但dispatch_async函数执行完的前提是块中dispatch_sync及之后代码能执行完,于是就出现了相互等待,造成了死锁

函数与关系

4种组合

函数与队列可以分为四种组合异步函数串行队列并发队列异步函数同步函数并发队列同步函数串行队列

    1. 异步函数串行队列开启线程,任务一个接着一个
    1. 异步函数并发队列开启线程,在当前线程执行任务,任务执行没有顺序,和cpu调度有关
    1. 同步函数并发队列不会开启线程,在当前线程执行任务,任务一个接着一个
    1. 同步函数串行队列不会开启线程,在当前线程执行任务,任务一个接着一个执行,会产生阻塞

主队列和全局队列

  • 主队列:专门在主线程上调度任务的串行队列不会开启线程,如果当前主线程正在执行任务,那么无论主队列中当前被添加了什么任务,都不会被调度dispatch_get_main_queue()
  • 全局队列:为了方便程序员的使用,苹果提供了全局队列dispatch_get_global_queue(0,0),全局队列是并发队列,在使用多线程时,如果对队列没有特殊要求,在执行异步任务时,可以直接使用全局队列
案例
- (void)viewDidLoad {
[super viewDidLoad];

dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"会来吗?");
});
}

  • ViewDidLoad中执行主线程同步任务,那么会打印吗?结果产生死锁
  • 分析:因为ViewDidLoad,在主线程上,主线程上调度任务的是主队列,主队列遵循FIFO原则,而要执行该同步任务也在主队列执行,所以必须等ViewDidLoad函数执行完才能执行,而ViewDidLoad函数执行完的前提是该同步任务执行完,所以就产生了相互等待,产生死锁

图解总结

截屏2021-08-08 22.17.19.png

队列源码分析

根据队列的介绍,我们知道有2种队列:串行并发,其中主队列是特殊的串行队列,全局队列是特殊的并发队列,那么在他们是怎么区分的呢?我们去打印堆栈看看:

截屏2021-08-09 16.16.42.png

主队列

在源码中搜索dispatch_get_main_queue

/
The main queue is meant to be used in application context to interact with the main thread and the main runloop.

Returns the main queue. This queue is created automatically on behalf of the main thread before main() is called.
*/

dispatch_queue_main_t
dispatch_get_main_queue(void)
{
return DISPATCH_GLOBAL_OBJECT(dispatch_queue_main_t, _dispatch_main_q);
}

  • 根据注释可知:

    1. 主队列与程序的主线程runloop进行交互
    2. 主队列在main()之前程序自动创建的
  • 根据代码可知它的核心是调用了DISPATCH_GLOBAL_OBJECT函数,其中的有两个参数,第一个是类型,再来看看第二个参数_dispatch_main_q,搜索得到:

struct dispatch_queue_static_s _dispatch_main_q = {
DISPATCH_GLOBAL_OBJECT_HEADER(queue_main),
#if !DISPATCH_USE_RESOLVERS
.do_targetq = _dispatch_get_default_queue(true),
#endif
.dq_state = DISPATCH_QUEUE_STATE_INIT_VALUE(1) |
DISPATCH_QUEUE_ROLE_BASE_ANON,
.dq_label = "com.apple.main-thread",
.dq_atomic_flags = DQF_THREAD_BOUND | DQF_WIDTH(1),
.dq_serialnum = 1,
};

  • 根据观察可发现队列的区分可能与dq_atomic_flagsdq_serialnum两个参数有关,值分别为DQF_THREAD_BOUND | DQF_WIDTH(1)1

全局队列

  • 再搜索dispatch_get_global_queue
dispatch_queue_global_t
dispatch_get_global_queue(intptr_t identifier, uintptr_t flags);

dispatch_queue_global_t
dispatch_get_global_queue(intptr_t priority, uintptr_t flags)
{
dispatch_assert(countof(_dispatch_root_queues) ==
DISPATCH_ROOT_QUEUE_COUNT);

if (flags & ~(unsigned long)DISPATCH_QUEUE_OVERCOMMIT) {
return DISPATCH_BAD_INPUT;
}
dispatch_qos_t qos = _dispatch_qos_from_queue_priority(priority);
#if !HAVE_PTHREAD_WORKQUEUE_QOS
if (qos == QOS_CLASS_MAINTENANCE) {
qos = DISPATCH_QOS_BACKGROUND;
} else if (qos == QOS_CLASS_USER_INTERACTIVE) {
qos = DISPATCH_QOS_USER_INITIATED;
}
#endif
if (qos == DISPATCH_QOS_UNSPECIFIED) {
return DISPATCH_BAD_INPUT;
}
return _dispatch_get_root_queue(qos, flags & DISPATCH_QUEUE_OVERCOMMIT);
}

  • identifier可以设置一些优先级,有四种优先级:

    1. DISPATCH_QUEUE_PRIORITY_HIGH
    2. DISPATCH_QUEUE_PRIORITY_DEFAULT
    3. DISPATCH_QUEUE_PRIORITY_LOW
    4. DISPATCH_QUEUE_PRIORITY_BACKGROUND
  • flags是留给将来使用,任务非0值都可能导致NULL,通常传0

  • 这里返回的是_dispatch_get_root_queue函数的调用

static inline dispatch_queue_global_t
_dispatch_get_root_queue(dispatch_qos_t qos, bool overcommit)
{
if (unlikely(qos < DISPATCH_QOS_MIN || qos > DISPATCH_QOS_MAX)) {
DISPATCH_CLIENT_CRASH(qos, "Corrupted priority");
}
return &_dispatch_root_queues[2 * (qos - 1) + overcommit];
}

  • 再继续查看_dispatch_root_queues函数:

截屏2021-08-09 16.53.42.png

此时找到了与dq_atomic_flags中相关参数DQF_WIDTH,传入的值为DISPATCH_QUEUE_WIDTH_POOL,也就是DQF_WIDTH(DISPATCH_QUEUE_WIDTH_FULL - 1),但不能确定dq_serialnum,它的值跟label有关,再来打印下_dispatch_root_queueslabel

globalQueue: <OS_dispatch_queue_global: com.apple.root.default-qos>

  • 于是根据label在源码中确定dq_serialnum10,目前还是不能确定那个队列的区分和哪个参数有关

自定义队列

  • 再来搜索队列的创建dispatch_queue_create,得到:
dispatch_queue_t
dispatch_queue_create(const char *label, dispatch_queue_attr_t attr)
{
return _dispatch_lane_create_with_target(label, attr,
DISPATCH_TARGET_QUEUE_DEFAULT, true);
}

  • 在搜索返回函数_dispatch_lane_create_with_target
static dispatch_queue_t
_dispatch_lane_create_with_target(const char *label, dispatch_queue_attr_t dqa,
dispatch_queue_t tq, bool legacy) // tq NULL, legacy true
{
dispatch_queue_attr_info_t dqai = _dispatch_queue_attr_to_info(dqa); // 面向对象封装

...

dispatch_lane_t dq = _dispatch_object_alloc(vtable,
sizeof(struct dispatch_lane_s)); // 开辟内存
_dispatch_queue_init(dq, dqf, dqai.dqai_concurrent ?
DISPATCH_QUEUE_WIDTH_MAX : 1, DISPATCH_QUEUE_ROLE_INNER |
(dqai.dqai_inactive ? DISPATCH_QUEUE_INACTIVE : 0)); // 初始化

dq->dq_label = label; //label赋值
dq->dq_priority = _dispatch_priority_make((dispatch_qos_t)dqai.dqai_qos,
dqai.dqai_relpri);
if (overcommit == _dispatch_queue_attr_overcommit_enabled) {
dq->dq_priority |= DISPATCH_PRIORITY_FLAG_OVERCOMMIT;
}
if (!dqai.dqai_inactive) {
dispatch_queue_priority_inherit_from_target(dq, tq);
dispatch_lane_inherit_wlh_from_target(dq, tq);
}
_dispatch_retain(tq);
dq->do_targetq = tq;
_dispatch_object_debug(dq, "%s", __func__);
return _dispatch_trace_queue_create(dq)._dq; // 创建痕迹标识,方便查找
}

  • 该函数第一个参数label我们比较熟悉,就是创建的线程的名字
  • _dispatch_queue_attr_to_info传入的第二参数:
dispatch_queue_attr_info_t
_dispatch_queue_attr_to_info(dispatch_queue_attr_t dqa)
{
dispatch_queue_attr_info_t dqai = { }; // 初始为NULL,
if (!dqa) return dqai;

...
}
  • 这里先初始一个dispatch_queue_attr_info_t类型对象,然后在根据dpa类型进行相关赋值,如果dqa为不存在则直接返回,这就是串行可以传NULL的原因
  • 做好相关的准备工作后,接着在调用_dispatch_object_alloc方法对线程开辟内存
  • 再调用初始化函数_dispatch_queue_init,此处第三个参数有判断是否并判断,如果是并发传入为DISPATCH_QUEUE_WIDTH_MAX,串行则传入1,继续查看方法的实现:

截屏2021-08-09 17.32.24.png

  • 此处可以看出又出现了DQF_WIDTH()函数和dq_serialnum,并发DQF_WIDTH(DISPATCH_QUEUE_WIDTH_FULL - 2),串行为DQF_WIDTH(1),但根据队列类型传入的参数只和DQF_WIDTH有关,那么dq_serialnum是什么呢?
  • 搜索_dispatch_queue_serial_numbers
unsigned long volatile _dispatch_queue_serial_numbers =
DISPATCH_QUEUE_SERIAL_NUMBER_INIT;

  • 然后再搜索DISPATCH_QUEUE_SERIAL_NUMBER_INIT
// skip zero
// 1 - main_q
// 2 - mgr_q
// 3 - mgr_root_q
// 4,5,6,7,8,9,10,11,12,13,14,15 - global queues
// 17 - workloop_fallback_q
// we use 'xadd' on Intel, so the initial value == next assigned
#define DISPATCH_QUEUE_SERIAL_NUMBER_INIT 17

  • 根据注释得知1代表 main_14~15 代码 global queues,但能区分队列吗,还得看看os_atomic_inc_orig函数的实现:
#define os_atomic_inc_orig(p, m) \
os_atomic_add_orig((p), 1, m)


#define os_atomic_add_orig(p, v, m) \
os_atomic_c11_op_orig((p), (v), m, add, +)


#define _os_atomic_c11_op_orig(p, v, m, o, op) \
atomic_fetch_##o##_explicit(_os_atomic_c11_atomic(p), v, \
memory_order_##m)


  • 最终得到C++方法atomic_fetch_add_explicit方法,网页搜索:

截屏2021-08-09 17.48.29.png

  • 原来是原子相关的操作,没啥用

总结:
1. 串行队列:DQF_WIDTH(1)
2. 全局队列:DQF_WIDTH(DISPATCH_QUEUE_WIDTH_FULL - 1)
3. 创建的并发队列:DQF_WIDTH(DISPATCH_QUEUE_WIDTH_FULL - 2)

队列的继承

  • 在队列开辟内存时调用的是_dispatch_object_alloc方法,为什么不是_dispatch_dispatch_alloc?接下来根据队列的类型进行分析下

  • 先搜索队列的类型dispatch_queue_t

DISPATCH_DECL(dispatch_queue);

  • 再查看DISPATCH_DECL的实现:
#define DISPATCH_DECL(name) \
typedef struct name##_s : public dispatch_object_s {} *name##_t


  • 根据传入的参数dispatch_queue,得到:
struct dispatch_queue_s : public dispatch_object_s {} *dispatch_queue_t

  • 于是得到队列的继承关系:dispatch_queue_t : dispatch_queue_s : dispatch_object_s
收起阅读 »

LeakCanary源码分析

LeakCanary使用 LeakCanary是一个用于Android的内存泄漏检测库.本文从如下四点分析源码 检查哪些内存泄漏 检查内存泄漏的时机 如何判定内存泄漏 如何分析内存泄漏(只有一点点,可能跟没有一样) 内存泄漏误报 1.检查哪些内存泄漏 A...
继续阅读 »

LeakCanary使用


LeakCanary是一个用于Android的内存泄漏检测库.本文从如下四点分析源码



  • 检查哪些内存泄漏

  • 检查内存泄漏的时机

  • 如何判定内存泄漏

  • 如何分析内存泄漏(只有一点点,可能跟没有一样)

  • 内存泄漏误报


1.检查哪些内存泄漏


AddWatchers.png
AppWatcherInstaller继承于ContentProvider,调用时机是介于Application的attachBaseContext(Context)和 onCreate() 之间.通过这种方式初始化.


方法2manualInstall实现了默认参数watchersToInstall,通过这个方法我们看到Activity,FragmentAndViewModel,RootView,Service四个观察者


fun appDefaultWatchers(
application: Application,
reachabilityWatcher: ReachabilityWatcher = objectWatcher
): List<InstallableWatcher> {
return listOf(
ActivityWatcher(application, reachabilityWatcher),
FragmentAndViewModelWatcher(application, reachabilityWatcher),
RootViewWatcher(reachabilityWatcher),
ServiceWatcher(reachabilityWatcher)
)
}

2.检查内存泄漏的时机


2.1 ActivityWatcher


activity触发OnDestory检查是否回收Activity实例


private val lifecycleCallbacks =
object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
override fun onActivityDestroyed(activity: Activity) {
reachabilityWatcher.expectWeaklyReachable(
activity, "${activity::class.java.name} received Activity#onDestroy() callback"
)
}
}

2.2 FragmentAndViewModelWatcher


fragment触发onFragmentDestroyed或onFragmentViewDestroyed检查是否可以回收Fragment实例

viewModel触发onClear检查是否可以回收ViewModel实例


123.png


2.2.1 检查哪些Fragment


由于Android现在有三种Fragment

androidx.fragment.app

android.app.fragment

android.support.v4.app.Fragment

leakCanary通过反射先去检查是否引入上面三种Fragment,如果有就反射创建对应的watcher加入到
fragmentDestroyWatchers中


private fun getWatcherIfAvailable(
fragmentClassName: String,
watcherClassName: String,
reachabilityWatcher: ReachabilityWatcher
): ((Activity) -> Unit)? {

return if (classAvailable(fragmentClassName) &&
classAvailable(watcherClassName)
) {
val watcherConstructor =
Class.forName(watcherClassName).getDeclaredConstructor(ReachabilityWatcher::class.java)
@Suppress("UNCHECKED_CAST")
watcherConstructor.newInstance(reachabilityWatcher) as (Activity) -> Unit
} else {
null
}
}

2.2.2 Fragment内存泄漏检查时机


(1)application注册activity生命周期回调

(2)当监听到ctivity被创建时,获取该activity的对应的fragmentManager创建fragment的生命周期观察者

(3)当onFragmentViewDestroyed/onFragmentDestroyed触发时,遍历集合然后检查是否可以回收Fragment实例


private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() {

override fun onFragmentViewDestroyed(
fm: FragmentManager,
fragment: Fragment
) {
val view = fragment.view
if (view != null) {
reachabilityWatcher.expectWeaklyReachable(
view, "${fragment::class.java.name} received Fragment#onDestroyView() callback " +
"(references to its views should be cleared to prevent leaks)"
)
}
}

override fun onFragmentDestroyed(
fm: FragmentManager,
fragment: Fragment
) {
reachabilityWatcher.expectWeaklyReachable(
fragment, "${fragment::class.java.name} received Fragment#onDestroy() callback"
)
}
}

2.2.3 检查哪些ViewModel内存泄漏


既然fragment/activity被销毁了,fragment/activity对象被回收了,那么fragment/activity绑定的所有viewmodel实例也应该销毁,所以leakCanary增加了viewmodel的内存检查

(1)监听当activity被创建时,绑定一个间谍viewmodel实例


//AndroidXFragmentDestroyWatcher
override fun invoke(activity: Activity) {
if (activity is FragmentActivity) {
val supportFragmentManager = activity.supportFragmentManager
supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
ViewModelClearedWatcher.install(activity, reachabilityWatcher)
}
}

(2)监听当fragment被创建时,绑定一个间谍viewmodel实例


//AndroidXFragmentDestroyWatcher##fragmentLifecycleCallbacks
override fun onFragmentCreated(
fm: FragmentManager,
fragment: Fragment,
savedInstanceState: Bundle?
) {
ViewModelClearedWatcher.install(fragment, reachabilityWatcher)
}

2.2.4 ViewModel内存泄漏检查时机


(1)利用反射获得fragment/activity绑定的viewModel集合

(2)当leakcanary绑定的viewmodel生命周期走到onCleared时,就去检查所有viewmodel实例是否可以回收(这边就是为啥作者取名叫spy)


//ViewModelClearedWatcher
override fun onCleared() {
viewModelMap?.values?.forEach { viewModel ->
reachabilityWatcher.expectWeaklyReachable(
viewModel, "${viewModel::class.java.name} received ViewModel#onCleared() callback"
)
}
}

2.3 RootViewWatcher


view触发onViewDetachedFromWindow检查是否回收View实例

利用Curtains获得视图变化,检查所有被添加到phoneWindow上面的,windowLayoutParams.title为Toast或者是Tooltip,或者除PopupWindow之外的所有view.


//RootViewWatcher
rootView.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {

val watchDetachedView = Runnable {
reachabilityWatcher.expectWeaklyReachable(
rootView, "${rootView::class.java.name} received View#onDetachedFromWindow() callback"
)
}

override fun onViewAttachedToWindow(v: View) {
WindowManager.LayoutParams.TYPE_PHONE
mainHandler.removeCallbacks(watchDetachedView)
}

override fun onViewDetachedFromWindow(v: View) {
mainHandler.post(watchDetachedView)
}
})

2.4 ServiceWatcher


service触发onDestroy检查是否回收Service实例


private fun onServiceDestroyed(token: IBinder) {
servicesToBeDestroyed.remove(token)?.also { serviceWeakReference ->
serviceWeakReference.get()?.let { service ->
reachabilityWatcher.expectWeaklyReachable(
service, "${service::class.java.name} received Service#onDestroy() callback"
)
}
}
}

3.如何判定内存泄漏


234.png
ReferenceQueue : 引用队列,在检测到适当的可到达性更改后,垃圾回收器将已注册的引用对象添加到该队列中


(1)将待检查对象加入到weakReference和watchedObjects中


@Synchronized override fun expectWeaklyReachable(
watchedObject: Any,
description: String
) {
if (!isEnabled()) {
return
}
removeWeaklyReachableObjects()
val key = UUID.randomUUID()
.toString()
val watchUptimeMillis = clock.uptimeMillis()
val reference =
KeyedWeakReference(watchedObject, key, description, watchUptimeMillis, queue)
SharkLog.d {
"Watching " +
(if (watchedObject is Class<*>) watchedObject.toString() else "instance of ${watchedObject.javaClass.name}") +
(if (description.isNotEmpty()) " ($description)" else "") +
" with key $key"
}

watchedObjects[key] = reference
checkRetainedExecutor.execute {
moveToRetained(key)
}
}

(6)执行GC后,遍历ReferenceQueue,删除watchedObjects集合中保存的对象


private fun removeWeaklyReachableObjects() {
// WeakReferences are enqueued as soon as the object to which they point to becomes weakly
// reachable. This is before finalization or garbage collection has actually happened.
var ref: KeyedWeakReference?
do {
ref = queue.poll() as KeyedWeakReference?
if (ref != null) {
watchedObjects.remove(ref.key)
}
} while (ref != null)
}

(3)判断watchedObjects长度是否发生改变,如果改变就认为内存泄漏


private fun checkRetainedCount(
retainedKeysCount: Int,
retainedVisibleThreshold: Int,
nopeReason: String? = null
): Boolean {
val countChanged = lastDisplayedRetainedObjectCount != retainedKeysCount
...
if (retainedKeysCount < retainedVisibleThreshold) {
if (applicationVisible || applicationInvisibleLessThanWatchPeriod) {
if (countChanged) {
onRetainInstanceListener.onEvent(BelowThreshold(retainedKeysCount))
}
showRetainedCountNotification(
objectCount = retainedKeysCount,
contentText = application.getString(
R.string.leak_canary_notification_retained_visible, retainedVisibleThreshold
)
)
scheduleRetainedObjectCheck(
delayMillis = WAIT_FOR_OBJECT_THRESHOLD_MILLIS
)
return true
}
}
return false
}

(10) 当检查到5次内存泄漏就会生成hprof文件


override fun dumpHeap(): DumpHeapResult {
...
val durationMillis = measureDurationMillis {
Debug.dumpHprofData(heapDumpFile.absolutePath)
}
...
}

4.如何分析内存泄漏


image.png
利用Shark分析工具分析hprof文件

(8)这里通过解析hprof文件生成heapAnalysis对象.SharkLog打印并存入数据库


override fun onHeapAnalyzed(heapAnalysis: HeapAnalysis) {
SharkLog.d { "\u200B\n${LeakTraceWrapper.wrap(heapAnalysis.toString(), 120)}" }

val db = LeaksDbHelper(application).writableDatabase
val id = HeapAnalysisTable.insert(db, heapAnalysis)
db.releaseReference()
...
}

5.内存泄漏误报


Java虚拟机的主流垃圾回收器采取的是可达性分析算法,
可达性算法是通过从GC root往外遍历,如果从root节点无法遍历该节点表明该节点对应的对象处于可回收状态.
反之不会回收.


public class MainActivity2 extends FragmentActivity {
Fragment mFragmentA;
Fragment mFragmentB;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main2);
mFragmentA = new FragmentA();
mFragmentB = new FragmentB();
findViewById(R.id.buttona).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
replaceFragment(mFragmentA);
}
});
findViewById(R.id.buttonb).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
replaceFragment(mFragmentB);
}
});
}
private void replaceFragment(Fragment fragment) {
getSupportFragmentManager().beginTransaction()
.replace(R.id.container, fragment).commit();
}
}

以fragment为例,leakcanary认为fragment走onDestory了,就应该释放fragment.但是这种情况真的是内存泄漏么?


    ├─ com.example.MainActivity2 instance
│ Leaking: NO (Activity#mDestroyed is false)
│ ↓ MainActivity2.mFragmentA
│ ~~~~~~~~~~
╰→ com.example.FragmentA instance
Leaking: YES (ObjectWatcher was watching this because com.example.FragmentA
received Fragment#onDestroy() callback and Fragment#mFragmentManager is null)
key = 216c8cf8-2cdb-4509-84e9-8404afefffeb
watchDurationMillis = 3804
retainedDurationMillis = -1
key = eaa41c88-bccb-47ac-8fb7-46b27dec0356
watchDurationMillis = 6113
retainedDurationMillis = 1112
key = 77d5f271-382b-42ec-904b-1e8a6d4ab097
watchDurationMillis = 7423
retainedDurationMillis = 2423
key = 8d79952f-a300-4830-b513-62e40cda8bba
watchDurationMillis = 15771
retainedDurationMillis = 10765
13858 bytes retained by leaking objects
Signature: f1d17d3f6aa4713d4de15a4f465f97003aa7

根据堆栈信息,leakcanary认为fragmentA走了onDestory应该要回收这个fragmentA对象,但是发现还被MainActivity2对象持有无法回收,然后判定是内存泄漏. 放在我们这个逻辑里面,fragment不释放是对的.
只不过这种实现不是内存最佳罢了.


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

内联函数产生原因和原理

为什么要内联函数因为: Kotlin为了书写简单,所以引入了lambda。但是: lambda会造成性能消耗。所以: 引入了内联函数来解决这个问题。如何证明lambda书写简单我们来实现个需求,diff一下有lambda和无lambda的代码便知。需求: 实现...
继续阅读 »

为什么要内联函数

  • 因为: Kotlin为了书写简单,所以引入了lambda。

  • 但是: lambda会造成性能消耗。

  • 所以: 引入了内联函数来解决这个问题。

如何证明lambda书写简单

我们来实现个需求,diff一下有lambda和无lambda的代码便知。

需求: 实现一个函数回调,回调一个String给我。

Java版本(无lambda):

// 首先需要定义一个回调
public interface Action {
void click(String fuck);
}

// 然后定义这个方法,参数就是回调的接口
public void func(Action action) {
String str = "hello";
action.click(str);
}

// 最后调用它
public static void main(String[] args) {
// 这里需要创建一个匿名类
func(new Action() {
@Override
public void click(String fuck) {
System.out.println(fuck);
}
});
}

然后我们来看kotlin版:

// 直接定义方法,参数是个表达式函数
fun func(action: (String) -> Unit) {
val str = "hello"
action.invoke(str)
}

// 调用
fun main() {
// 参数直接传入lambda就完事,it是lambda的默认参数
func { println(it) }
}

没有对比就没有伤害,java费了十牛三虎之力写了好几行,kotlin短短几行就实现了,这就是lambda的优点: 简洁省事。其实说白了就是:不用创建对象了。

虽然可读性差了点,管它呢,反正看不懂也是别人的事,别人读不懂才能凸显我的不可替代性。

事实证明,lambda确实大大简化了代码的书写过程,我们不用敲创建对象的代码了

那么,lambda有什么缺点呢?

lambda的缺点

lambda的最大缺点就是性能损耗!

让我们反编译上述kotlin代码来看:

// 这个参数已经被替换成Function1了,这个Function1是kotlin中定义的一个接口
public static final void func(@NotNull Function1 action) {
Intrinsics.checkNotNullParameter(action, "action");
String str = "hello";
action.invoke(str);
}

// main函数
public static final void main() {
// 这里其实是创建了一个匿名类
func((Function1)null.INSTANCE);
}

我们看到,kotlin中的lambda最终会在编译期变成一个匿名类,这跟java好像没什么区别啊,都是生成一个匿名类。为什么说kotlin的lambda效率低,因为:kotlin创建匿名类是在编译期

而java在1.7之后就引入了invokedynamic指令,java中的lambda在编译期会被替换为invokedynamic指令,在运行期,如果invokedynamic被调用,就会生成一个匿名类来替换这个指令,后续调用都是用这个匿名类来完成

说白了,对于java来说,如果lambda不被调用,就不会创建匿名类。而对于kotlin来说,不管lambda是否被调用,都会提前创建一个匿名类。这就等价于:java把创建匿名类的操作后置了,有需要才搞,这就变相节省了开销。因为创建匿名类会增加类个数和字节码大小。

那么,kotlin为什么不也这么干呢,为什么非要在编译时 就提前做 将来不一定用到的东西呢?因为kotlin需要兼容java6,java6是目前Android的主要开发语言,而invokedynamic又是在java7之后引入的...,mmp!

那么,kotlin怎么擦好这个屁股呢?使用内联函数!

内联函数的实现原理

还是上述代码,我们把func改成内联的,如下:

fun main() {
func { print(it) }
}

// 方法用inline修饰了
inline fun func(action: (String) -> Unit) {
val str = "hello"
action.invoke(str)
}

同样,我们反编译下看看:

// 这个函数没变化
public static final void func(@NotNull Function1 action) {
Intrinsics.checkNotNullParameter(action, "action");
String str = "hello";
action.invoke(str);
}

// 哦,调用方变了:直接把func函数体拷贝过来了,six six six
public static final void main() {
String str$iv = "hello";
System.out.print(str$iv);
}

我们看到,添加了inline后,kotlin会直接把被调用函数的函数体,复制到调用它的地方。

这样就不用创建匿名对象了!而且,还少一次调用过程。因为调用匿名对象的函数,本身还多一次调用呢。比如:

// 内联前
public void test(){
A a = new a();
a.hello(); // 这里调用一次hello()
}

// 内联后
public void test(){
// a.hello()的代码直接拷贝进来,不用调hello()了!
}

所以,内联牛逼,万岁万岁万万岁。

但是,内联也有缺点!比如,我现在有个内联函数test(),里面有1000行代码,如果有10个地方调用它,那么就会把它复制到这10个地方,这一下就是10000行。。。这就导致class文件变相增大,进而导致apk变大,用户看见就不想下了。

怎么办呢,那就不内联!也就是说:根据函数的大小,以及被调用次数的多少,来决定是否需要内联

这是个业务的决策问题,这里不再废话。

内联函数的其他规则

好,我们来看下内联函数的一些规则。

内联函数的局限性

内联函数作为参数,只能传递给另一个内联函数。比如:

// func2是非内联的
fun func2(action: (String) -> Unit) {

}

// func是内联的
inline fun func(action: (String) -> Unit) {
val str = "hello"
action.invoke(str)

// action此时是内联的,传递给非内联函数func2,就会报错
func2(action) // 报错
}

现在我们讲func2改为内联的:

// 将func2改为内联
inline fun func2(action: (String) -> Unit) {

}

// func是内联的
inline fun func(action: (String) -> Unit) {
val str = "hello"
action.invoke(str)

// 将action传递给另一个内联函数func2,正常
func2(action) // ok
}

如果,不希望修改func2()为内联的怎么办呢,此时可以使用noinline修饰action参数:

// func2是非内联的
fun func2(action: (String) -> Unit) {

}

// func是内联的,但是action被标记为非内联的
inline fun func(noinline action: (String) -> Unit) {
val str = "hello"
action.invoke(str)

// action此时是非内联的,可以传递给非内联函数func2
func2(action) // ok
}

内联函数引的非局部返回

局部返回

我们知道,一般函数调用的返回都是局部的,比如:

// 这里直接return,也就是返回到调用它的地方
fun tReturn() {
return
}

fun func() {
println("before")
// 调用了toRetrun()
tReturn()
println("after")
}

// 测试
fun main() {
func()
}

结果如下:

before
after

这是正常的,因为func()函数先打印before,然后调用tReturn(),tReturn()入栈,执行return,tReturn()出栈,回到func()函数,接着向下打印after。

但是,如果将func()声明为内联的,然后将tReturn()作为参数传入,那么func()方法体就变了,比如:

// func声明为内联的,然后传入action参数
inline fun func(action: () -> Unit) {
println("before")
action.invoke()
println("after")
}

fun main() {
// 参数跟tReturn一样
func { return }
}

结果:

before

原理也很简单,因为参数action会被复制到func()函数中,也就合并为一个方法了,等价于:

inline fun func() {
println("before")
return // 这就是参数action的函数体,直接返回了
println("after")
}

这个不难理解,那么,如果不加inline,只是修改参数为action可以吗,比如:

// 这里没有加inline 参数一样是action
fun func(action: () -> Unit) {
println("before")
action.invoke()
println("after")
}

fun main() {
func { return } // 报错
}

这会直接报错:

Kotlin: 'return' is not allowed here

这是不允许的,因为它不知道你要return到哪个地方,但是可以这样写:

fun main() {
// return 添加了标记,标记为返回到func这个地方
func { return@func }
}

结果:

before
after

综上,一句话: 普通函数参数的return都是局部返回的,而内联函数是全局返回的

那么,怎么防备这种风险呢,或者说: 怎么让一个函数既可以内联,又不让它的参数有全局返回的return呢?比如:

inline fun func(action: () -> Unit) {
println("before")
action() // 希望这里不要有return,有就直接报错
println("after")
}

使用crossinline即可!我们修改函数如下:

// 参数用crossinline修饰
inline fun func(crossinline action: () -> Unit) {
println("before")
action()
println("after")
}

// 调用
fun main() {
func { return } // 报错: Kotlin: 'return' is not allowed here
func { return@func } // 正常
}

可以看到,corssinline在保证函数是内联的情况下,限制了全局返回

总结

  • kotlin为了书写简洁,引入了lambda
  • 但是lambda有性能开销
  • 性能开销在java7优化了,但是kotlin兼容java6,无法享受这个优化
  • 所以kotlin引入内联来解决这个问题
  • 内联是在编译期将被调用的函数拷贝到调用方的函数体,从而避免创建内部类
  • 使用inline可以将函数声明为内联的,内联函数参数是全局返回的
  • 使用noinline可以修饰函数参数为不内联
  • 使用crossinline可以修饰函数参数为内联,而且不能全局返回


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

收起阅读 »

相比 XML , Compose 性能到底怎么样?

前言 最近Compose已经正式发布了1.0版本,这说明谷歌认为Compose已经可以用于正式生产环境了 那么相比传统的XML,Compose的性能到底怎么样呢? 本文主要从构建性能与运行时两个方面来分析Compose的性能,数据主要来源于:Jetpack C...
继续阅读 »

前言


最近Compose已经正式发布了1.0版本,这说明谷歌认为Compose已经可以用于正式生产环境了

那么相比传统的XML,Compose的性能到底怎么样呢?


本文主要从构建性能与运行时两个方面来分析Compose的性能,数据主要来源于:Jetpack Compose — Before and afterMeasuring Render Performance with Jetpack Compose , 想了解更多的同学可以直接点击查看


构建性能


Compose构建性能主要以 tivi 为例来进行说明

Tivi是一个开源的电影App,原本基于FragmentXML构建,同时还使用了DataBinding等使用了注解处理器的框架

后来迁移到使用Compose构建UI,迁移过程分为两步



  1. 第一步:迁移到NavigationFragment,每个FragmentUI则由Compose构建

  2. 第二步:移除Fragment,完全基于Compose实现UI


下面我们就对Pre-Compose,Fragments + Compose,Entirely Compose三个阶段的性能进行分析对比


APK体积


包体积是我们经常关注的性能指标之一,我们一起看下3个阶段的包体积对比


p1.png

p2.png

可以看出,TiviAPK 大小缩减了 46%,从 4.49MB 缩减到 2.39MB,同时方法数也减少了17%


值得注意的是,在刚开始在应用中采用Compose时,有时您会发现APK大小反而变大了

这是因为迁移没有完成,老的依赖没有完成移除,而新的依赖已经添加了,导致APK体积变大

而在项目完全迁移到Compose后,APK 大小会减少,并且优于原始指标。


代码行数


我们知道在比较软件项目时,代码行数并不是一个特别有用的统计数据,但它确实提供了对事物如何变化的一个观察指标。

我们使用cloc工具来计算代码行数


cloc . --exclude-dir=build,.idea,schemas

结果如下图所示:


p4.png
可以看出,在迁移到Compose后,毫无意外的,XML代码行减少了76%

有趣的是kotlin代码同样减少了,可能是因为我们可以减少很多模板代码,同时也可以移除之前写的一些View Helper代码


构建速度


随着项目的不断变大,构建速度是开发人员越来越关心的一个指标。

在开始重构之前,我们知道,删除大量的注解处理器会有助于提高构建速度,但我们不确定会有多少。


我们运行以下命令5次,然后取平均值


./gradlew --profile --offline --rerun-tasks --max-workers=4 assembleDebug

结果如下


p3.png

这里考虑的是调试构建时间,您在开发期间会更关注此时间。


在迁移到Compose前,Tivi 的平均构建时间为 108.71 秒。

在完全迁移到 Compose 后,平均构建时间缩短至 76.96 秒!构建时间缩短了 29%

构建时间能缩短这么多,当然不仅仅是Compose的功劳,在很大程度上受两个因素的影响:



  1. 一个是移除了使用注解处理器的DataBindingEpoxy

  2. 另一个是HiltAGP 7.0 中的运行速度更快。


运行时性能


上面我们介绍了Compose在构建时的性能,下面来看下Compose在运行时渲染的性能怎么样


分析前的准备


使用Compose时,可能有多种影响性能的指标



  • 如果我们完全在Compose中构建UI会怎样?

  • 如果我们对复杂视图使用Compose(例如用 LazyColumn 替换 RecyclerViews),但根布局仍然添加在XML

  • 如果我们使用Compose替换页面中一个个元素,而不是整个页面,会怎么样?

  • 是否可调试和R8编译器对性能的影响有多大?


为了开始回答这些问题,我们构建了一个简单的测试程序。

在第一个版本中,我们添加了一个包含50个元素的列表(其中实际绘制了大约 12 个)。该列表包括一个单选按钮和一些随机文本。


p5.jpeg

为了测试各种选项的影响,我们添加以下4种配置,以下4种都是开启了R8同时关闭了debug



  1. 纯Compose

  2. 一个XML中,只带有一个ComposeView,具体布局写在Compose

  3. XML中只包含一个RecyclerView,但是RecyclerView的每一项是一个ComposeView

  4. XML


同时为了测试build type对性能的影响,也添加了以下3种配置



  1. Compose,关闭R8并打开debug

  2. Compose,关闭R8并关闭debug

  3. XML,关闭R8并打开debug


如何定义性能?


Compose运行时性能,我们一般理解的就是页面启动到用户看到内容的时间

因此下面几个时机对我们比较重要



  1. Activity启动时间,即onCreate

  2. Activity启动完成时间,即onResume

  3. Activity渲染绘制完成时间,即用户看到内容的时间


onCreateonResume的时机很容易掌握,重写系统方法即可,但如何获得Activity完全绘制的时间呢?

我们可以给页面根View添加一个ViewTreeObserver,然后记录最后一次onDraw调用的时间


使用Profile查看上面说的过程,如下分别为使用XML渲染与使用Compose渲染的具体过程,即从OnCreate到调用最后一次onDraw的过程


使用XML

使用Compose


渲染性能分析


知道了如何定义性能,我们就可以开始测试了



  1. 每次测试都在几台设备上运行,包括最近的旗舰、没有Google Play服务的设备和一些廉价手机。

  2. 每次测试在同一台手机上都会运行10次,因此我们不仅可以获取首次渲染时间,也可以获取二次渲染时间

  3. 测试Compose版本为1.0.0


我们根据上面定义的配置,重复跑了多次,得到了一些数据,感兴趣的同学可以直接查看所有数据


p8.png

分析结果如上图所示,我们可以得出一些结论



  • R8和是否可调试对Jetpack Compose渲染时间产生了显着影响。在每次实验中,禁用R8和启用可调试性的构建所花费的时间是没有它们的构建的两倍多。在我们最慢的设备上,R8 将渲染速度加快了半秒以上,而禁用debug又使渲染速度加快了半秒。

  • XML中只包含一个ComposeView的渲染时间,跟纯Compose的耗时差不多

  • RecyclerView中包含多个ComposeView是最慢的。这并不奇怪,在XML中使用ComposeView是有成本的,所以页面中使用的ComposeView越少越好。

  • XML在呈现方面比Compose更快。没有办法解决这个问题,在每种情况下,Compose 的渲染时间比 XML 长约 33%。

  • 第一次启动总是比后续启动花费更长的时间来渲染。如果您查看完整的数据,第一个页面的渲染时间几乎是后续的两倍。


比较让我惊讶的是,尽管Compose没有了将XML转化成ViewIO操作,测量过程也因为固有特性测量提高了测量效率,但性能仍然比XML要差

不过,根据Leland Richardson说法,当从Google Play安装应用程序时,由于捆绑的AOT编译,Compose 在启动时渲染会更快,从而进一步缩小了与XML的差距


总结


经过上面对Compose性能方面的分析,总结如下



  1. 如果完全迁移到Compose,在包体积,代码行数,编译速度等方面应该会有比较大的改善

  2. 如果处于迁移中的阶段,可能因为旧的依赖没有去除,而已经引入了新的依赖,反而导致包体积等变大

  3. 尽管没有了XML转换的IO操作,测量过程也通过固有特性测量进行了优化,Compose的渲染性能比起XML仍然有一定差距

  4. 尽管目前Compose在性能方面略有欠缺(在大多数设备上仅超过一两帧),但由于其在开发人员生产力、代码重用和声明式UI的强大特性等方面的优势,Compose仍被推荐使用

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

Android知识点之Service(四)

2、service的生命周期,两种启动模式的区别 (1)、通过startService()方法来启动服务 生命周期:onCreate() -> onStart()或者onStartCommand() -> onDestroy() onStart()...
继续阅读 »

2、service的生命周期,两种启动模式的区别


(1)、通过startService()方法来启动服务

生命周期:onCreate() -> onStart()或者onStartCommand() -> onDestroy()


onStart()方法是在android 4.1以上版本废弃,采用onStartCommand()方法代替,当服务已经启动时,调用startService()方法不会重复调用onCreate()方法(只有在启动时调用一次),但会调用onStart()或者onStartCommand()方法,这在SystemServer进程的ActiveServices的bringUpServiceLocked方法中有体现:

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
boolean whileRestarting, boolean permissionsReviewRequired)
throws TransactionTooLargeException {
//如果服务已经启动,则进入此判断
if (r.app != null && r.app.thread != null) {
sendServiceArgsLocked(r, execInFg, false);
return null;
}
····
}

private final void sendServiceArgsLocked(ServiceRecord r, boolean execInFg,
boolean oomAdjusted) throws TransactionTooLargeException {
····
//通过跨进程调用应用进程的ActivityThread的ApplicationThread中的scheduleServiceArgs启动服务
//最终调用到应用服务的onStartCommand()方法
r.app.thread.scheduleServiceArgs(r, slice);
····
}

当不使用服务时,调用stopService()来关闭,此时会调用服务的onDestroy()方法


特点:



  • 服务运行与注册者无关联,注册者退出服务不会退出,除非调用stopService()方法


(2)、通过bindService()方法绑定服务

生命周期:onCreate() -> onBind() -> onUnbind() -> onDestroy()


当服务已经绑定,通过unbindService()解绑(没有最终调用spotService()退出服务情况下),此时onUnbind()返回true,再通过bindService()绑定服务,此时不会再调用onBind(),而是调用onRebind(),生命周期如下:


onCreate() -> onBind() -> 调用unbindService()解绑 -> onUnbind(true) -> 调用bindService()绑定 -> onRebind() -> onUnbind() -> onDestroy()


绑定的服务退出,绑定者退出或者调用unbindService()解绑退出


特点:



  • 绑定者可以通过服务内部自定义的Binder实现类来持有服务并且调用服务中的方法

  • 绑定者退出,那么服务也会跟着退出


3、service与activity怎么实现通信


1、通过Intent方式从Activity发送数据给Service

2、使用绑定服务的ServiceConnection通过Binder进行

3、内容提供者、存储的方式

4、广播

5、socket通信

6、全局静态变量方式

7、反射注入的方式(eventBus)


4、IntentService是什么?IntentService的原理?应用场景以及与service的区别


IntentService是一个可以执行耗时操作的服务,内部维护着HandlerThread封装的子线程消息队列来执行耗时任务,在任务执行完时调用stopSelf()方法自动退出服务


原理:

frameworks/base/core/java/android/app/IntentService.java


public abstract class IntentService extends Service {
····
//定义一个Handler对象用于接收与发送消息
private final class ServiceHandler extends Handler {
public ServiceHandler(Looper looper) {
super(looper);
}

@Override
public void handleMessage(Message msg) {
//一个抽象方法,可以被继承者重写,运行在子线程消息队列中
onHandleIntent((Intent)msg.obj);
//任务执行完,自定关闭当前服务
stopSelf(msg.arg1);
}
}
·····

@Override
public void onCreate() {
super.onCreate();
//创建一个HandlerThread对象,HandlerThread是子线程消息循环队列
HandlerThread thread = new HandlerThread("IntentService[" + mName + "]");
thread.start();
mServiceLooper = thread.getLooper();
//创建一个子线程的Handler对象,发送的消息运行在子线程消息队列中
mServiceHandler = new ServiceHandler(mServiceLooper);
}

@Override
public void onStart(@android.annotation.Nullable Intent intent, int startId) {
//使用Handler向子线程消息循环队列发送一条消息
Message msg = mServiceHandler.obtainMessage();
msg.arg1 = startId;
msg.obj = intent;
mServiceHandler.sendMessage(msg);
}

@Override
public int onStartCommand(@android.annotation.Nullable Intent intent, int flags, int startId) {
//发送消息运行在子线程消息队列中
onStart(intent, startId);
return mRedelivery ? START_REDELIVER_INTENT : START_NOT_STICKY;
}

//抽象方法,运行在子线程消息队列中
protected abstract void onHandleIntent(@android.annotation.Nullable Intent intent);
}

应用场景:



  • 后台耗时操作

  • 短暂的耗时服务,如下载资源等


IntentService与service区别:



  • service不能直接执行耗时任务否则会引起ANR,IntentService可以执行耗时任务

  • service启动后需要手动调用stopService()关闭服务,IntentService启动后在执行完任何后会自动关闭服务


5、Service的onStartCommand方法有几种返回值?各代表什么意思?


有四种返回值:




  • START_STICKY:当某个服务被系统杀死时(不是正常结束服务),如果返回值为START_STICKY,则系统会尝试重启该服务,并且调用服务的onStartCommand()方法,但是onStartCommand()方法的Intent参数为Null。




  • START_NOT_STICKY:当某个服务被系统杀死时,如果返回值为START_NOT_STICKY,则系统不会重启该服务。




  • START_REDELIVER_INTENT:当某个服务被系统杀死时,如果返回值为START_REDELIVER_INTENT,则系统会尝试重启该服务,并且调用服务的onStartCommand()方法,并且会创建之前启动服务时传入的Intent,即onStartCommand()方法的Intent参数不为Null。




  • START_STICKY_COMPATIBILITY:这是START_STICKY的兼容版本,不能保证onStartCommand()方法一定会被重新调用。




6、bindService和startService混合使用的生命周期以及怎么关闭?



  • 调用startService()


生命周期 : onCreate() -> onStart()或者onStartCommand() -> onDestroy()

通过调用stopService()方法关闭服务



  • 调用bindService()


生命周期:onCreate() -> onBind() -> onUnbind() -> onDestroy()

通过调用unbindService()方法关闭服务



  • 先调用startService()后再调用bindService()


生命周期:onCreate() -> onStart()或者onStartCommand() -> onBind() -> onUnbind() -> onDestroy()

先调用stopService()再调用unbindService()关闭服务



  • 先调用bindService()后再调用startService()


生命周期:onCreate() -> onBind() -> onStart()或者onStartCommand() -> onUnbind() -> onDestroy()

先调用unbindService()再调用stopService()关闭服务



  • 先调用bindService()后调用unbindService(),最后调用bindService()


生命周期:onCreate() -> onBind() -> 调用unbindService()解绑 -> onUnbind(true) -> 调用bindService()绑定 -> onRebind() -> onUnbind() -> onDestroy()

通过调用unbindService()方法关闭服务


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