注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

iOS RXSwift 4.9

iOS
Schedulers - 调度器Schedulers 是 Rx 实现多线程的核心模块,它主要用于控制任务在哪个线程或队列运行。如果你曾经使用过 GCD, 那你对以下代码应该不会陌生:// 后台取得数据,主线程处理结果 D...
继续阅读 »

Schedulers - 调度器

Schedulers 是 Rx 实现多线程的核心模块,它主要用于控制任务在哪个线程或队列运行。

如果你曾经使用过 GCD, 那你对以下代码应该不会陌生:

// 后台取得数据,主线程处理结果
DispatchQueue.global(qos: .userInitiated).async {
let data = try? Data(contentsOf: url)
DispatchQueue.main.async {
self.data = data
}
}

如果用 RxSwift 来实现,大致是这样的:

let rxData: Observable<Data> = ...

rxData
.subscribeOn(ConcurrentDispatchQueueScheduler(qos: .userInitiated))
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] data in
self?.data = data
})
.disposed(by: disposeBag)

使用 subscribeOn

我们用 subscribeOn 来决定数据序列的构建函数在哪个 Scheduler 上运行。以上例子中,由于获取 Data 需要花很长的时间,所以用 subscribeOn 切换到 后台 Scheduler 来获取 Data。这样可以避免主线程被阻塞。

使用 observeOn

我们用 observeOn 来决定在哪个 Scheduler 监听这个数据序列。以上例子中,通过使用 observeOn 方法切换到主线程来监听并且处理结果。

一个比较典型的例子就是,在后台发起网络请求,然后解析数据,最后在主线程刷新页面。你就可以先用 subscribeOn 切到后台去发送请求并解析数据,最后用 observeOn 切换到主线程更新页面。


MainScheduler

MainScheduler 代表主线程。如果你需要执行一些和 UI 相关的任务,就需要切换到该 Scheduler 运行。

SerialDispatchQueueScheduler

SerialDispatchQueueScheduler 抽象了串行 DispatchQueue。如果你需要执行一些串行任务,可以切换到这个 Scheduler 运行。

ConcurrentDispatchQueueScheduler

ConcurrentDispatchQueueScheduler 抽象了并行 DispatchQueue。如果你需要执行一些并发任务,可以切换到这个 Scheduler 运行。

OperationQueueScheduler

OperationQueueScheduler 抽象了 NSOperationQueue

它具备 NSOperationQueue 的一些特点,例如,你可以通过设置 maxConcurrentOperationCount,来控制同时执行并发任务的最大数量。

Error Handling - 错误处理

一旦序列里面产出了一个 error 事件,整个序列将被终止。RxSwift 主要有两种错误处理机制:

  • retry - 重试
  • catch - 恢复

retry - 重试

retry 可以让序列在发生错误后重试:

// 请求 JSON 失败时,立即重试,
// 重试 3 次后仍然失败,就将错误抛出

let rxJson: Observable<JSON> = ...

rxJson
.retry(3)
.subscribe(onNext: { json in
print("取得 JSON 成功: \(json)")
}, onError: { error in
print("取得 JSON 失败: \(error)")
})
.disposed(by: disposeBag)

以上的代码非常直接 retry(3) 就是当发生错误时,就进行重试操作,并且最多重试 3 次。

retryWhen

如果我们需要在发生错误时,经过一段延时后重试,那可以这样实现:

// 请求 JSON 失败时,等待 5 秒后重试,

let retryDelay: Double = 5 // 重试延时 5 秒

rxJson
.retryWhen { (rxError: Observable<Error>) -> Observable<Int> in
return Observable.timer(retryDelay, scheduler: MainScheduler.instance)
}
.subscribe(...)
.disposed(by: disposeBag)

这里我们需要用到 retryWhen 操作符,这个操作符主要描述应该在何时重试,并且通过闭包里面返回的 Observable 来控制重试的时机:

.retryWhen { (rxError: Observable<Error>) -> Observable<Int> in
...
}

闭包里面的参数是 Observable<Error> 也就是所产生错误的序列,然后返回值是一个 Observable。当这个返回的 Observable 发出一个元素时,就进行重试操作。当它发出一个 error 或者 completed 事件时,就不会重试,并且将这个事件传递给到后面的观察者。

如果需要加上一个最大重试次数的限制:

// 请求 JSON 失败时,等待 5 秒后重试,
// 重试 4 次后仍然失败,就将错误抛出

let maxRetryCount = 4 // 最多重试 4 次
let retryDelay: Double = 5 // 重试延时 5 秒

rxJson
.retryWhen { (rxError: Observable<Error>) -> Observable<Int> in
return rxError.flatMapWithIndex { (error, index) -> Observable<Int> in
guard index < maxRetryCount else {
return Observable.error(error)
}
return Observable<Int>.timer(retryDelay, scheduler: MainScheduler.instance)
}
}
.subscribe(...)
.disposed(by: disposeBag)

我们这里要实现的是,如果重试超过 4 次,就将错误抛出。如果错误在 4 次以内时,就等待 5 秒后重试:

...
rxError.flatMapWithIndex { (error, index) -> Observable<Int> in
guard index < maxRetryCount else {
return Observable.error(error)
}
return Observable<Int>.timer(retryDelay, scheduler: MainScheduler.instance)
}
...

我们用 flatMapWithIndex 这个操作符,因为它可以给我们提供错误的索引数 index。然后用这个索引数判断是否超过最大重试数,如果超过了,就将错误抛出。如果没有超过,就等待 5 秒后重试。


catchError - 恢复

catchError 可以在错误产生时,用一个备用元素或者一组备用元素将错误替换掉:

searchBar.rx.text.orEmpty
...
.flatMapLatest { query -> Observable<[Repository]> in
...
return searchGitHub(query)
.catchErrorJustReturn([])
}
...
.bind(to: ...)
.disposed(by: disposeBag)

我们开头的 Github 搜索就用到了catchErrorJustReturn。当错误产生时,就返回一个空数组,于是就会显示一个空列表页。

你也可以使用 catchError,当错误产生时,将错误事件替换成一个备选序列:

// 先从网络获取数据,如果获取失败了,就从本地缓存获取数据

let rxData: Observable<Data> = ... // 网络请求的数据
let cahcedData: Observable<Data> = ... // 之前本地缓存的数据

rxData
.catchError { _ in cahcedData }
.subscribe(onNext: { date in
print("获取数据成功: \(date.count)")
})
.disposed(by: disposeBag)

Result

如果我们只是想给用户错误提示,那要如何操作呢?

以下提供一个最为直接的方案,不过这个方案存在一些问题:

// 当用户点击更新按钮时,
// 就立即取出修改后的用户信息。
// 然后发起网络请求,进行更新操作,
// 一旦操作失败就提示用户失败原因

updateUserInfoButton.rx.tap
.withLatestFrom(rxUserInfo)
.flatMapLatest { userInfo -> Observable<Void> in
return update(userInfo)
}
.observeOn(MainScheduler.instance)
.subscribe(onNext: {
print("用户信息更新成功")
}, onError: { error in
print("用户信息更新失败: \(error.localizedDescription)")
})
.disposed(by: disposeBag)

这样实现是非常直接的。但是一旦网络请求操作失败了,序列就会终止。整个订阅将被取消。如果用户再次点击更新按钮,就无法再次发起网络请求进行更新操作了。

为了解决这个问题,我们需要选择合适的方案来进行错误处理。例如,使用系统自带的枚举 Result

public enum Result<Success, Failure> where Failure : Error {
case success(Success)
case failure(Failure)
}

然后之前的代码需要修改成:

updateUserInfoButton.rx.tap
.withLatestFrom(rxUserInfo)
.flatMapLatest { userInfo -> Observable<Result<Void, Error>> in
return update(userInfo)
.map(Result.success) // 转换成 Result
.catchError { error in Observable.just(Result.failure(error)) }
}
.observeOn(MainScheduler.instance)
.subscribe(onNext: { result in
switch result { // 处理 Result
case .success:
print("用户信息更新成功")
case .failure(let error):
print("用户信息更新失败: \(error.localizedDescription)")
}
})
.disposed(by: disposeBag)

这样我们的错误事件被包装成了 Result.failure(Error) 元素,就不会终止整个序列。即便网络请求失败了,整个订阅依然存在。如果用户再次点击更新按钮,也是能够发起网络请求进行更新操作的。

另外你也可以使用 materialize 操作符来进行错误处理。这里就不详细介绍了,如你想了解如何使用 materialize 可以参考这篇文章 How to handle errors in RxSwift!

收起阅读 »

iOS RXSwift 4.9

iOS
Disposable - 可被清除的资源通常来说,一个序列如果发出了 error 或者 completed 事件,那么所有内部资源都会被释放。如果你需要提前释放这些资源或取消订阅的话,那么你可以对返回的 可被清...
继续阅读 »

Disposable - 可被清除的资源

通常来说,一个序列如果发出了 error 或者 completed 事件,那么所有内部资源都会被释放。如果你需要提前释放这些资源或取消订阅的话,那么你可以对返回的 可被清除的资源(Disposable) 调用 dispose 方法:

var disposable: Disposable?

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

self.disposable = textField.rx.text.orEmpty
.subscribe(onNext: { text in print(text) })
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)

self.disposable?.dispose()
}

调用 dispose 方法后,订阅将被取消,并且内部资源都会被释放。通常情况下,你是不需要手动调用 dispose 方法的,这里只是做个演示而已。我们推荐使用 清除包(DisposeBag) 或者 takeUntil 操作符 来管理订阅的生命周期。

DisposeBag - 清除包

因为我们用的是 Swift ,所以我们更习惯于使用 ARC 来管理内存。那么我们能不能用 ARC 来管理订阅的生命周期了。答案是肯定了,你可以用 清除包(DisposeBag) 来实现这种订阅管理机制:

var disposeBag = DisposeBag()

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

textField.rx.text.orEmpty
.subscribe(onNext: { text in print(text) })
.disposed(by: self.disposeBag)
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)

self.disposeBag = DisposeBag()
}

当 清除包 被释放的时候,清除包 内部所有 可被清除的资源(Disposable) 都将被清除。在输入验证中我们也多次看到 清除包 的身影:

var disposeBag = DisposeBag() // 来自父类 ViewController

override func viewDidLoad() {
super.viewDidLoad()

...

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)
}

这个例子中 disposeBag 和 ViewController 具有相同的生命周期。当退出页面时, ViewController 就被释放,disposeBag 也跟着被释放了,那么这里的 5 次绑定(订阅)也就被取消了。这正是我们所需要的。

takeUntil

另外一种实现自动取消订阅的方法就是使用 takeUntil 操作符,上面那个输入验证的演示代码也可以通过使用 takeUntil 来实现:

override func viewDidLoad() {
super.viewDidLoad()

...

_ = usernameValid
.takeUntil(self.rx.deallocated)
.bind(to: passwordOutlet.rx.isEnabled)

_ = usernameValid
.takeUntil(self.rx.deallocated)
.bind(to: usernameValidOutlet.rx.isHidden)

_ = passwordValid
.takeUntil(self.rx.deallocated)
.bind(to: passwordValidOutlet.rx.isHidden)

_ = everythingValid
.takeUntil(self.rx.deallocated)
.bind(to: doSomethingOutlet.rx.isEnabled)

_ = doSomethingOutlet.rx.tap
.takeUntil(self.rx.deallocated)
.subscribe(onNext: { [weak self] in self?.showAlert() })
}

这将使得订阅一直持续到控制器的 dealloc 事件产生为止。

注意⚠️:这里配图中所使用的 Observable 都是“热” Observable,它可以帮助我们理解订阅的生命周期。如果你想要了解 “冷热” Observable 之间的区别,可以参考官方文档 Hot and Cold Observables

收起阅读 »

iOS RXSwift 4.8

iOS
Operator - 操作符操作符可以帮助大家创建新的序列,或者变化组合原有的序列,从而生成一个新的序列。我们之前在输入验证例子中就多次运用到操作符。例如,通过 map 方法将输入的用户名,转换为用户名是否有效。然后用这个转化后来的序列来控...
继续阅读 »

Operator - 操作符

操作符可以帮助大家创建新的序列,或者变化组合原有的序列,从而生成一个新的序列。

我们之前在输入验证例子中就多次运用到操作符。例如,通过 map 方法将输入的用户名,转换为用户名是否有效。然后用这个转化后来的序列来控制红色提示语是否隐藏。我们还通过 combineLatest 方法,将用户名是否有效密码是否有效合并成两者是否同时有效。然后用这个合成后来的序列来控制按钮是否可点击。

这里 map 和 combineLatest 都是操作符,它们可以帮助我们构建所需要的序列。现在,我们再来看几个例子:

filter - 过滤

你可以用 filter 创建一个新的序列。这个序列只发出温度大于 33 度的元素。

map - 转换

你可以用 map 创建一个新的序列。这个序列将原有的 JSON 转换成 Model 。这种转换实际上就是解析 JSON 。

zip - 配对

你可以用 zip 来合成一个新的序列。这个序列将汉堡序列的元素和薯条序列的元素配对后,生成一个新的套餐序列。

如何使用操作符

使用操作符是非常容易的。你可以直接调用实例方法,或者静态方法:

  • 温度过滤

    // 温度
    let rxTemperature: Observable<Double> = ...

    // filter 操作符
    rxTemperature.filter { temperature in temperature > 33 }
    .subscribe(onNext: { temperature in
    print("高温:\(temperature)度")
    })
    .disposed(by: disposeBag)
  • 解析 JSON

    // JSON
    let json: Observable<JSON> = ...

    // map 操作符
    json.map(Model.init)
    .subscribe(onNext: { model in
    print("取得 Model: \(model)")
    })
    .disposed(by: disposeBag)
  • 合成套餐

    // 汉堡
    let rxHamburg: Observable<Hamburg> = ...
    // 薯条
    let rxFrenchFries: Observable<FrenchFries> = ...

    // zip 操作符
    Observable.zip(rxHamburg, rxFrenchFries)
    .subscribe(onNext: { (hamburg, frenchFries) in
    print("取得汉堡: \(hamburg) 和薯条:\(frenchFries)")
    })
    .disposed(by: disposeBag)

决策树

Rx 提供了充分的操作符来帮我们创建序列。当然如果内置操作符无法满足你的需求时,你还可以创建自定义的操作符。

如果你不确定该如何选择操作符,可以参考 决策树。它会引导你找出合适的操作符。

操作符列表

26个英文字母我都认识,可是连成一个句子我就不怎么认得了...

这里提供一个操作符列表,它们就好比是26个英文字母。你如果要将它们的作用全部都发挥出来,是需要学习如何将它们连成一个句子的:

收起阅读 »

Flutter 入门与实战(八十):使用GetX构建更优雅的页面结构

前言 App 的大部分页面都会涉及到数据加载、错误、无数据和正常几个状态,在一开始的时候我们可能数据获取的状态枚举用 if...else 或者 switch 来显示不同的 Widget,这种方式会显得代码很丑陋,譬如下面这样的代码: if (PersonalC...
继续阅读 »

前言


App 的大部分页面都会涉及到数据加载、错误、无数据和正常几个状态,在一开始的时候我们可能数据获取的状态枚举用 if...else 或者 switch 来显示不同的 Widget,这种方式会显得代码很丑陋,譬如下面这样的代码:


if (PersonalController.to.loadingStatus == LoadingStatus.loading) {
return Center(
child: Text('加载中...'),
);
}
if (PersonalController.to.loadingStatus == LoadingStatus.failed) {
return Center(
child: Text('请求失败'),
);
}
// 正常状态
PersonalEntity personalProfile = PersonalController.to.personalProfile;
return Stack(
...
);

这种情况实在是不够优雅,在 GetX 中提供了一种 StateMixin 的方式来解决这个问题。


StateMixin


StateMixin 是 GetX 定义的一个 mixin,可以在状态数据中混入页面数据加载状态,包括了如下状态:



  • RxStatus.loading():加载中;

  • RxStatus.success():加载成功;

  • RxStatus.error([String? message]):加载失败,可以携带一个错误信息 message

  • RxStatus.empty():无数据。


StateMixin 的用法如下:


class XXXController extends GetxController
with StateMixin<T> {
}

其中 T 为实际的状态类,比如我们之前一篇 PersonalEntity,可以定义为:


class PersonalMixinController extends GetxController
with StateMixin<PersonalEntity> {
}

然后StateMixin 提供了一个 change 方法用于传递状态数据和状态给页面。


void change(T? newState, {RxStatus? status})

其中 newState 是新的状态数据,status 就是上面我们说的4种状态。这个方法会通知 Widget 刷新。


GetView


GetX 提供了一个快捷的 Widget 用来访问容器中的 controller,即 GetViewGetView是一个继承 StatelessWidget的抽象类,实现很简单,只是定义了一个获取 controllerget 属性。


abstract class GetView<T> extends StatelessWidget {
const GetView({Key? key}) : super(key: key);

final String? tag = null;

T get controller => GetInstance().find<T>(tag: tag)!;

@override
Widget build(BuildContext context);
}

通过继承 GetView,就可以直接使用controller.obx构建界面,而 controller.obx 最大的特点是针对 RxStatus 的4个状态分别定义了四个属性:


Widget obx(
NotifierBuilder<T?> widget, {
Widget Function(String? error)? onError,
Widget? onLoading,
Widget? onEmpty,
})


  • NotifierBuilder<T?> widget:实际就是一个携带状态变量,返回正常状态界面的函数,NotifierBuilder<T?>的定义如下。通过这个方法可以使用状态变量构建正常界面。


typedef NotifierBuilder<T> = Widget Function(T state);


  • onError:错误时对应的 Widget构建函数,可以使用错误信息 error

  • onLoading:加载时对应的 Widget

  • onEmpty:数据为空时的 Widget



通过这种方式可以自动根据 change方法指定的 RxStatus 来构建不同状态的 UI 界面,从而避免了丑陋的 if...elseswitch 语句。例如我们的个人主页,可以按下面的方式来写,是不是感觉更清晰和清爽了?


class PersonalHomePageMixin extends GetView<PersonalMixinController> {
PersonalHomePageMixin({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return controller.obx(
(personalEntity) => _PersonalHomePage(personalProfile: personalEntity!),
onLoading: Center(
child: CircularProgressIndicator(),
),
onError: (error) => Center(
child: Text(error!),
),
onEmpty: Center(
child: Text('暂无数据'),
),
);
}
}

对应的PersonalMixinController的代码如下:


class PersonalMixinController extends GetxController
with StateMixin<PersonalEntity> {
final String userId;
PersonalMixinController({required this.userId});

@override
void onReady() {
getPersonalProfile(userId);
super.onReady();
}

void getPersonalProfile(String userId) async {
change(null, status: RxStatus.loading());
var personalProfile = await JuejinService().getPersonalProfile(userId);
if (personalProfile != null) {
change(personalProfile, status: RxStatus.success());
} else {
change(null, status: RxStatus.error('获取个人信息失败'));
}
}
}

Controller 的构建


从 GetView 的源码可以看到,Controller 是从容器中获取的,这就需要使用 GetX 的容器,在使用 Controller 前注册到 GetX 容器中。


Get.lazyPut<PersonalMixinController>(
() => PersonalMixinController(userId: '70787819648695'),
);

总结


本篇介绍了使用GetXStateMixin方式构建更优雅的页面结构,通过controller.obx 的参数配置不同状态对应不同的组件。可以根据 RxStatus 状态自动切换组件,而无需写丑陋的 if...elseswitch 语句。当然,使用这种方式的前提是需要在 GetX 的容器中构建 controller 对象,本篇源码已上传至:GetX 状态管理源码。实际上使用容器能够带来其他的好处,典型的应用就是依赖注入(Dependency Injection,简称DI),接下来我们会使用两篇来介绍依赖注入的概念和具体应用。


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

落地西瓜视频埋点方案,埋点从未如此简单

前言 目前,几乎每个商用应用都有数据埋点的需求。你的 App 是怎么做埋点的呢,有遇到让你 “难顶” 的问题吗? 在这篇文章里,我将带你建立数据埋点的基本认识,还会介绍西瓜视频团队的前端埋点方案,最后为你带来我的落地实现 EasyTrack。如果能帮上忙,请...
继续阅读 »

前言



  • 目前,几乎每个商用应用都有数据埋点的需求。你的 App 是怎么做埋点的呢,有遇到让你 “难顶” 的问题吗?

  • 在这篇文章里,我将带你建立数据埋点的基本认识,还会介绍西瓜视频团队的前端埋点方案,最后为你带来我的落地实现 EasyTrack。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。




目录





1. 数据埋点概述


1.1 为什么要埋点?


“除了上帝,任何人都必须用数据说话”,在数据时代,使用数据驱动产品迭代已经称为行业共识。在分析应用数据之前,首先需要获得数据,这就需要前端或服务端进行数据埋点。


1.2 数据需求的工作流程


首先,你需要了解数据需求的工作流程,需求是如何产生,又是如何流转的,主要分为以下几个环节:



  • 1、需求产生: 产品需求引起产品形态变化,产生新的数据需求;

  • 2、事件设计: 数据产品设计埋点事件并更新数据字典文档,提出埋点评审;

  • 3、埋点开发: 开发进行数据埋点开发;

  • 4、埋点测试: 测试进行数据埋点测试,确保数据质量;

  • 5、数据消费: 数据分析师进行数据分析,推荐系统工程师进行模型训练,赋能产品运营决策。



1.3 数据消费的经典场景



























消费场景需求描述技术需求
渗透率分析统计 DAU/PV/UV/VV 等准确的上报时机
归因分析分析前因后果准确上报上下文 (如场景、会话、来源页面)
1. A / B 测试
2. 个性化推荐
分析用户特征、产品特征等准确上报事件属性

可以看到,在归因分析中,除了需要上报事件本身的属性之外,还需要上报事件产生时的上下文信息,例如当前页面、来源页面、会话等。


1.4 埋点数据采集的基本模型


数据采集是指在前端或服务端收集需要上报的事件属性的过程。为了满足复杂、高效的数据消费需求,需要科学合理地设计端侧的数据采集逻辑,基本可以总结为 “4W + 1H” 模型:





































模型描述举例
1、WHAT什么行为事件名
2、WHEN行为产生的时间时间戳
3、WHO行为产生的对象对象唯一标识 (例如用户 ID、设备 ID)
4、WHERE行为产生的环境设备所处的环境 (例如 IP、操作系统、网络)
5、HOW行为的特征上下文信息 (例如当前页面、来源页面、会话)



2. 如何实现数据埋点?


2.1 埋点方案总结


目前,业界已经存在多种埋点方案,主要分为全埋点、前端代码埋点和服务端代码埋点三种,优缺点和适用场景总结如下:































全埋点前端埋点服务端埋点
优势开发成本低完整采集上下文信息不依赖于前端版本
劣势数据量大,无法获取上下文数据,数据质量低前端开发成本较高服务端开发成本较高、获取上下文信息依赖于接口传值
适用场景通用基础事件(如启动/退出、浏览、点击)核心业务流程(如登录、注册、收藏、购买)核心业务结果事件(如支付成功)



  • 1、全埋点: 指通过编译时插桩、运行时动态代理等 AOP 手段实现自动埋点和上报,无须开发者手动进行埋点,因此也称为 “无埋点”;




  • 2、前端埋点: 指前端 (包括客户端) 开发者手动编码实现埋点,虽然可以通过埋点工具或者脚本简化埋点开发工作,但总体上还是需要手动操作;




  • 3、服务端埋点: 指服务端手动编码实现埋点,缺点是需要客户端需要侵入接口来保留上下文参数。




2.2 全埋点方案的局限性


表面上看,全埋点方案的优势很明显:客户端和服务端只需要一次开发,就能实现所有页面、所有路径的曝光和点击事件埋点,节省了研发人力,也不用担心埋点逻辑会侵入正常业务逻辑。然而,不可能存在完美的解决方案,全埋点方案还是存在一些局限性:




  • 1、资源消耗较大: 全场景上报会产生大量无用数据,网络传输、数据存储和数据计算需要消耗大量资源;




  • 2、页面稳定性要求较高: 需要保持页面视图结构相对稳定,一旦页面视图结果变化,历史录入的埋点数据就会失效;




  • 3、无法采集上下文信息: 无法采集事件产生时的上下文信息,也就无法满足复杂的数据消费需求。




2.3 埋点设计的整体方案


考虑的不同方案都存在优缺点,单纯采用一种埋点方案是不切实际的,需要根据不同业务场景和不同数据消费需要而采用不同的埋点方案:




  • 1、全埋点: 作为全局兜底方案,可以满足粗粒度的统计需求;




  • 2、前端埋点: 作为全埋点的补充方案,可以自定义埋点参数,主要处理核心业务流程事件,例如(如登录、注册、收藏、购买);




  • 3、服务端埋点: 核心业务结果事件,例如订单支付成功。






3. 前端埋点中的困难


3.1 一个简单的埋点场景


现在,我们通过一个具体的埋点场景,试着发现在做埋点需求时会遇到的困难或痛点。我直接使用西瓜视频中的一个埋点场景:



—— 图片引用自西瓜视频技术博客


这个产品场景很简单,左边是西瓜视频的推荐流列表,点击 “电影卡片” 会进入右边的 “电影详情页” 。两个页面中都有 “收藏按钮”,现在的数据需求是采集不同页面中 “收藏按钮” 的点击事件,以便分析用户收藏影片的行为,优化影片的推荐模型。



  • 1、在推荐列表页中上报点击事件:


“event_name" : "click_favorite", // 事件名
"cur_page" : "feed", // 当前页面
"video_id" : "123", // 影片 ID
"video_name" : "影片名", // 影片名
"video_type" : "1", // 影片类型
"$user_id" : "10000", // 用户 ID
"$device_id" : "abc" // 设备 ID
... // 其他预置属性


  • 2、在电影详情页中上报点击事件:


“event_name" : "click_favorite", // 事件名
"from_page" : "feed"
"cur_page" : "video_detail", // 当前页面
"video_id" : "123", // 影片 ID
"video_name" : "影片名", // 影片名
"video_type" : "1", // 影片类型
"$user_id" : "10000", // 用户 ID
"$device_id" : "abc" // 设备 ID
... // 其他预置属性

3.2 现状分析


理解了这个埋点场景之后,我们先梳理出目前遇到的困难:




  • 1、埋点参数分散: 需要上报的埋点参数位于不同 UI 容器或不同业务模块,代码跨度很大(例如:Activity、Fragment、ViewHolder、自定义 View);




  • 2、组件复用: 组件抽象复用后在多个页面使用(例如通用的 ViewHolder 或自定义 View);




  • 3、数据模型不一致: 不同场景 / 页面下描述状态的数据模型不一致,需要额外的转换适配过程(例如有的模型用 video_type 表示影片类型,另一些模型用 videoType 表示影片类型)。




3.3 评估标准


理解了问题和现状,现在我们开始尝试找到解决方案。为此,我们需要想清楚理想中的解决方案,应该满足什么标准:



  • 1、准确性: 这是核心目标,能够在保证不同场景 / 页面下准确收集埋点数据;

  • 2、简洁性: 使用方法尽可能简单,收敛模板代码;

  • 3、可用性: 尽可能高效稳定,不容易出错,性能开销小。


3.4 常规解决方案


1、逐级传递 —— 通过面向对象的关系逐级传递埋点参数:


通过 Android 框架支持的 Activity / Fragment 参数传递方式和面向对象程序设计,逐级将埋点参数传递到最深层的收藏按钮。例如:




  • 列表页: Activity -> ViewModel -> FeedFragment (推荐) -> Adapter -> ViewHolder (电影卡片) -> CollectButton (收藏按钮)




  • 详情页: Activity -> ViewModel -> DetailBottomFragment(底部功能区) -> CollectButton (收藏按钮)




缺点 (参数传递困难) :传递数据需要编写大量重复模板代码,工程代码膨胀,增大维护难度。再叠加上组件复用的情况,逐级传递会让代码复杂度非常高,很明显不是一个合理的解决方案。


2、Bean 传递 —— 在 Java Bean 中增加字段来收集埋点参数:


缺点 (违背单一职责原则):Java Bean 中侵入了与业务无关的埋点参数,同时会造成 Java Bean 数据冗余,增大维护难度。


3、全局单例 —— 通过全局单例对象来收集埋点参数:


这个方案与 “Bean 传递 ” 类似,区别在于埋点参数从 Java Bean 中移动到全局单例中,但缺点还是很明显:


缺点 (写入和清理时机):单例会被多个位置写入,一旦被覆盖就无法被恢复,容易导致上报错误;另外清理的时机也难以把握,清理过早会导致埋点参数丢失,清理过晚会污染后面的埋点事件。




4. 西瓜视频方案


理解了数据埋点开发中的困难,有没有什么方案可以简化埋点过程中的复杂度呢?我们来讨论下西瓜视频团队分享的一个思路:基于视图树收集埋点参数。




—— 图片引用自西瓜视频技术博客


通过分析数据与视图节点的关系可以发现,事件的埋点数据正好分布在视图树的不同节点中。当 “收藏按钮” 触发事件时,只需要沿着视图树逐级向上查找 (通过 View#getParent()) 就可以收集到所有数据。


并且,树的分支天然地支持为参数设置不同的值。例如 “推荐 Fragment” 需要上报 “channel : recomment”,而 “电影 Fragment” 需要上报 “channel : film”。因为 Fragment 的根布局对应有视图树中的不同节点,所以在不同 Fragment 中触发的事件最终收集到的 “channel” 参数值也就不同了。Nice~




5. EasyTrack 埋点框架


思路 Get 到了,现在我们来讨论如何应用这个思路来解决问题。贴心的我已经帮你实现为一个框架 EasyTrack。源码地址:github.com/pengxurui/E…


5.1 添加依赖



  • 1、依赖 JitPack 仓库


在项目级 build.gradle 声明远程仓库:


allprojects {
repositories {
google()
mavenCentral()
// JitPack 仓库
maven { url "https://jitpack.io" }
}
}


  • 2、依赖 EasyTrack 框架


在模块级 build.gradle 中依赖类库:


dependencies {
...
// 依赖 EasyTrack 框架
implementation 'com.github.pengxurui:EasyTrack:v1.0.1'
// 依赖 Kotlin 工具(非必须)
implementation 'com.github.pengxurui:KotlinUtil:1.0.1'
}

5.2 依附埋点参数到视图树


ITrackModel接口定义了一个数据填充能力,你可以创建它的实现类来定义一个数据节点,并在 fillTrackParams() 方法中声明参数。例如:MyGoodsViewHolder 实现了 ITrackMode 接口,在 fillTrackParams() 方法中声明参数(goods_id / goods_name)。


随后,通过 View 的扩展函数View.trackModel()将其依附到视图节点上。扩展函数 View.trackModel() 内部基于 View#setTag() 实现。


MyGoodsViewHolder.kt


class MyGoodsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), ITrackModel {

private var mItem: GoodsItem? = null

init {
// Java:EasyTrackUtilsKt.setTrackModel(itemView, this);
itemView.trackModel = this
}

override fun fillTrackParams(params: TrackParams) {
mItem?.let {
params.setIfNull("goods_id", it.id)
params.setIfNull("goods_name", it.goods_name)
}
}
}

EasyTrackUtils.kt


/**
* Attach track model on the view.
*/
var View.trackModel: ITrackModel?
get() = this.getTag(R.id.tag_id_track_model) as? ITrackModel
set(value) {
this.setTag(R.id.tag_id_track_model, value)
}

ITrackModel.kt


/**
* 定义数据填充能力
*/
interface ITrackModel : Serializable {
/**
* 数据填充
*/
fun fillTrackParams(params: TrackParams)
}

5.3 触发事件埋点


在需要埋点的地方,直接通过定义在 View 上的扩展函数 trackEvent(事件名)触发埋点事件,它会以该扩展函数的接收者对象为起点,逐级向上层视图节点收集参数。另外,它还有多个定义在 Activity、Fragment、ViewHolder 上的扩展函数,但最终都会调用到 View.trackEvent。


class MyGoodsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(item: GoodsItem) {
...
trackEvent(GOODS_EXPOSE)
}
}

EasyTrackUtils.kt


@JvmOverloads
fun Activity?.trackEvent(eventName: String, params: TrackParams? = null) =
findRootView(this)?.doTrackEvent(eventName, params)

@JvmOverloads
fun Fragment?.trackEvent(eventName: String, params: TrackParams? = null) =
this?.requireView()?.doTrackEvent(eventName, params)

@JvmOverloads
fun RecyclerView.ViewHolder?.trackEvent(eventName: String, params: TrackParams? = null) {
this?.itemView?.let {
if (null == it.parent) {
it.post { it.doTrackEvent(eventName, params) }
} else {
it.doTrackEvent(eventName, params)
}
}
}

@JvmOverloads
fun View?.trackEvent(eventName: String, params: TrackParams? = null): TrackParams? =
this?.doTrackEvent(eventName, params)

查看 logcat 日志,可以看到以下日志,显示埋点并没有生效。这是因为没有为 EasyTrack 配置埋点数据上报和统计分析的能力。


logcat 日志


EasyTrackLib: Try track event goods_expose, but the providers is Empty.

5.4 实现 ITrackProvider 接口


EasyTrack 的职责在于收集分散的埋点数据,本身没有提供埋点数据上报和统计分析的能力。因此,你需要实现 ITrackProvider 接口进行依赖注入。例如,这里模拟实现友盟数据埋点提供器,在 onInit() 方法中进行初始化,在 onEvent() 方法中调用友盟 SDK 事件上报方法。


MockUmengProvider.kt


/**
* 模拟友盟数据上报
*/
class MockUmengProvider : ITrackProvider() {

companion object {
const val TAG = "Umeng"
}

/**
* 是否启用
*/
override var enabled = true

/**
* 名称
*/
override var name = TAG

/**
* 初始化
*/
override fun onInit() {
Log.d(TAG, "Init Umeng provider.")
}

/**
* 执行事件上报
*/
override fun onEvent(eventName: String, params: TrackParams) {
Log.d(TAG, params.toString())
}
}

5.5 配置 EasyTrack


在应用初始化时,进行 EasyTrack 的初始化配置。我们可以将相关的初始化代码单独封装起来,例如:


StatisticsUtils.kt


// 模拟友盟数据统计提供器
val umengProvider by lazy {
MockUmengProvider()
}

// 模拟神策数据统计提供器
val sensorProvider by lazy {
MockSensorProvider()
}

/**
* 初始化 EasyTrack,在 Application 初始化时调用
*/
fun init(context: Context) {
configStatistics(context)
registerProviders(context)
}

/**
* 配置
*/
private fun configStatistics(context: Context) {
// 调试开关
EasyTrack.debug = BuildConfig.DEBUG
// 页面间参数映射
EasyTrack.referrerKeyMap = mapOf(
CUR_PAGE to FROM_PAGE,
CUR_TAB to FROM_TAB
)
}

/**
* 注册提供器
*/
private fun registerProviders(context: Context) {
EasyTrack.registerProvider(umengProvider)
EasyTrack.registerProvider(sensorProvider)
}

EventConstants.java


public static final String FROM_PAGE = "from_page";
public static final String CUR_PAGE = "cur_page";
public static final String FROM_TAB = "from_tab";
public static final String CUR_TAB = "cur_tab";


























配置类型描述
debugBoolean调试开关
referrerKeyMapMap<String,String>全局页面间参数映射
registerProvider()ITrackProvider底层数据埋点能力

以上步骤是 EasyTrack 的必选步骤,完成后重新执行 trackEvent() 后可以看到以下日志:


logcat 日志


/EasyTrackLib:  
onEvent:goods_expose
goods_id= 10000
goods_name = 商品名
Try track event goods_expose with provider Umeng.
Try track event goods_expose with provider Sensor.
------------------------------------------------------

5.6 页面间参数映射


上一节中有一个referrerKeyMap配置项,定义了全局的页面间参数映射。 举个例子,在分析不同入口的转化率时,不仅仅需要上报当前页面的数据,还需要上报来源页面的信息。这样我们才能分析用户经过怎样的路径来到当前页面,并最终触发了某个行为。


需要注意的是,来源页面的参数往往不能直接添加到当前页面的埋点参数中,这里一般会有一定的转换规则 / 映射关系。例如:来源页面的 cur_page 参数,在当前页面应该映射为 from_page 参数。 在这个例子里,我们配置的映射关系是:



  • 来源页面的 cur_page 映射为当前页面的 from_page;

  • 来源页面的 cur_tab 映射为当前页面的 from_tab。


因此,假设来源页面传递给当前页面的参数是 A,则当前页面在触发事件时的收集参数是 B:


A (来源页面):
{
"cur_page" : "list"
...
}

B (当前页面):
{
"cur_page" : "detail",
"from_page" : "list",
...
}

BaseTrackActivity 实现了页面间参数映射,你可以创建 BaseActivity 类并继承于 BaseTrackActivity,或者将其内部的逻辑迁移到你的 BaseActivity 中。这一步是可选的,如果你不使用页面间参数映射的特性,你那大可不必使用 BaseTrackActivity。



















操作描述
定义映射关系1、EasyTrack.referrerKeyMap 配置项
2、重写 BaseTrackActivity #referrerKeyMap() 方法
传递页面间参数Intent.referrerSnapshot(TrackParams) 扩展函数

MyGoodsDetailActivity.java


public class MyGoodsDetailActivity extends MyBaseActivity {

private static final String EXTRA_GOODS = "extra_goods";

public static void start(Context context, GoodsItem item, TrackParams params) {
Intent intent = new Intent(context, GoodsDetailActivity.class);
intent.putExtra(EXTRA_GOODS, item);
EasyTrackUtilsKt.setReferrerSnapshot(intent, params);
context.startActivity(intent);
}

@Nullable
@Override
protected String getCurPage() {
return GOODS_DETAIL_NAME;
}

@Nullable
@Override
public Map<String, String> referrerKeyMap() {
Map<String, String> map = new HashMap<>();
map.put(STORE_ID, STORE_ID);
map.put(STORE_NAME, STORE_NAME);
return map;
}
}

需要注意的是,BaseTrackActivity 不会将来源页面的全部参数都添加到当前页面的参数中,只有在全局 referrerKeyMap 配置项或 referrerKeyMap() 方法中定义了映射关系的参数,才会添加到当前页面。 例如:MyGoodsDetailActivity 继承于 BaseActivity,并重写 referrerKeyMap() 定义了感兴趣的参数(STORE_ID、STORE_NAME)。最终触发埋点时的日志如下:


logcat 日志


/EasyTrackLib:  
onEvent:goods_detail_expose
goods_id= 10000
goods_name = 商品名
store_id = 10000
store_name = 商店名
from_page = Recommend
cur_page = goods_detail
Try track event goods_expose with provider Umeng.
Try track event goods_expose with provider Sensor.
------------------------------------------------------

在一般的埋点模型中,每个 Activity (页面) 都有对应一个唯一的 page_id,因此你可以重写 fillTrackParams() 方法追加这些固定的参数。例如:MyBaseActivity 定义了 getCurPage() 方法,子类可以通过重写 getCurPage() 来设置 page_id。


MyBaseActivity.java


abstract class MyBaseActivity : BaseTrackActivity() {

@CallSuper
override fun fillTrackParams(params: TrackParams) {
super.fillTrackParams(params)
// 填充页面统一参数
getCurPage()?.also {
params.setIfNull(CUR_PAGE, it)
}
}

protected open fun getCurPage(): String? = null
}

5.7 TrackParams 参数容器


TrackParams 是 EasyTrack 收集参数的中间容器,最终会分发给 ITrackProvider 使用。



























方法描述
set(key: String, value: Any?)设置参数,无论无何都覆盖
setIfNull(key: String, value: Any?)设置参数,如果已经存在该参数则丢弃
get(key: String): String?获取参数值,参数不存在则返回 null
get(key: String, default: String?)获取参数值,参数不存在则返回默认值 default

5.8 使用 Kotlin 委托依附参数


如果你觉得每次定义 ITrackModel 数据节点后都需要调用 View.trackModel,你可以使用我定义的 Kotlin 委托 “跳过” 这个步骤,例如:


MyFragment.kt


private val trackNode by track()

EasyTrackUtils.kt


fun <F : Fragment> F.track(): TrackNodeProperty<F> = FragmentTrackNodeProperty()

fun RecyclerView.ViewHolder.track(): TrackNodeProperty<RecyclerView.ViewHolder> =
LazyTrackNodeProperty() viewFactory@{
return@viewFactory itemView
}

fun View.track(): TrackNodeProperty<View> = LazyTrackNodeProperty() viewFactory@{
return@viewFactory it
}

如果你还不了解委托属性,可以看下我之前写过的一篇文章,这里不解释其原理了:Android | ViewBinding 与 Kotlin 委托双剑合璧




6. EasyTrack 核心源码


这一节,我简单介绍下 EasyTrack 的核心源码,最核心的部分在入口类 EasyTrack 中:


6.1 doTrackEvent()


doTrackEvent() 是触发埋点的主方法,主要流程是调用 fillTrackParams() 收集埋点参数,再将参数分发给有效的 ITrackProvider。


internal fun Any.doTrackEvent(eventName: String, otherParams: TrackParams? = null): TrackParams? {
1. 检查是否有有效的 ITrackProvider
2. 基于视图树递归收集埋点参数(fillTrackParams)
3. 日志
4. 将收集到的埋点参数分发给有效的 ITrackProvider
}

6.2 fillTrackParams()


-> 基于视图树递归收集埋点参数
internal fun fillTrackParams(node: Any?, params: TrackParams? = null): TrackParams {
val result = params ?: TrackParams()
var curNode = node
while (null != curNode) {
when (curNode) {
is View -> {
// 1. 视图节点
if (android.R.id.content == curNode.id) {
// 1.1 Activity 节点
val activity = getActivityFromView(curNode)
if (activity is IPageTrackNode) {
// 1.1.1 IPageTrackNode节点(处理页面间参数映射)
activity.fillTrackParams(result)
curNode = activity.referrerSnapshot()
} else {
// 1.1.2 终止
curNode = null
}
} else {
// 1.2 Activity 视图子节点
curNode.trackModel?.fillTrackParams(result)
curNode = curNode.parent
}
}
is ITrackNode -> {
// 2. 非视图节点
curNode.fillTrackParams(result)
curNode = curNode.parent
}
else -> {
// 3. 终止
curNode = null
}
}
}
return result
}

主要逻辑:从入参 node 为起点,循环获取依附在视图节点上的 ITrackModel 数据节点并调用 fillTrackParams() 方法收集参数,并将循环指针指向 parent。




7. 总结


EasyTrack 框架的源码我已经放在 Github 上了,源码地址:github.com/pengxurui/E… 我也写了一个简单的 Sample Demo,你可以直接运行体验下。欢迎批评,欢迎 Issue~


说说目前遇到的问题,在处理页面间参数传递时,我们需要依赖 Intent extras 参数。这就导致我们需要在大量创建 Intent 的地方都加入来源页面的埋点参数(注意:即使你不使用 EasyTrack,你也要这么做)。目前我还没有想到比较好的方法,你觉得呢?说说你的看法吧。


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

【Flutter 状态管理】第一论: 对状态管理的看法与理解

前言 由 编程技术交流圣地[-Flutter群-] 发起的 状态管理研究小组,将就 状态管理 相关相关话题进行为期 两个月 的讨论。小组将于两个月后解散,并发布相关讨论成果。 目前只有内定的 5 个人参与讨论,如果你对状态管理有什么独特的见解,或想参与其中。...
继续阅读 »
前言

编程技术交流圣地[-Flutter群-] 发起的 状态管理研究小组,将就 状态管理 相关相关话题进行为期 两个月 的讨论。小组将于两个月后解散,并发布相关讨论成果。



目前只有内定的 5 个人参与讨论,如果你对状态管理有什么独特的见解,或想参与其中。可以发表一篇自己对状态管理的认知文章,作为入群的“门票”,欢迎和我们共同交流。




前两周进行第一个话题的探讨 :


你对状态管理的看法与理解



状态管理,状态管理。顾名思义是状态+管理,那问题来了,到底什么是状态?为什么要管理呢?


一、何谓状态


1. 对状态概念的思考

其实要说明一个东西是什么,是非常困难的。这并不像数学中能给出具体的定义,比如


平行四边形: 是在同一个二维平面内,由两组平行线段组成的闭合图形
三角形: 是由同一平面内不在同一直线上的三条线段首尾顺次连接所组成的封闭图形

如果具有明确定义的概念,我们可以很容易理解它的特性和作用。但对于 状态 这种含义比较笼统的词汇,那就仁者见仁,智者见智 了。我查了一下,对于状态而言有如下解释:


状态是人或事物表现出来的形态。是指现实(或虚拟)事物处于生成、生存、发展、消亡时期
或各转化临界点时的形态或事物态势。

如果影射到编程上,状态就是界面各个时期的表现,状态的改变,通过刷新后会导致界面的变化。那 界面状态 有什么区别和联系呢?


比如说一颗种子发芽、长大、开花、结果、枯萎,这是外在的表征,是外界所看到的形态变化。但从根本上来说,这些变化是种子与外界的资源交换,导致的内部数据变化,而产生的结果。也就是一个是 面子 ,一个是 里子


看花人并不会在意种子的内部的变化逻辑,他们只需满足看花的需求就行了。 也就是说 界面是表现 ,是用来给用户看的;状态是本质 ,是需要编程者去维护的。如果一个开发者只能看到 面子 ,而忽略我们本身就是那颗种子,还谈什么状态,想什么管理?。




2.状态、交互与界面

对一个应用而言,最根本的目的在于: 用户 通过操作界面, 可以进行正确的逻辑处理,并得到一定的响应反馈





从用户的角度来看,应用内部运作机制是个 黑盒,用户不需要、也没必要了解细节。但这个黑盒内部逻辑处理需要编程者进行实现,我们是无法逃避的。



拿我们最熟悉的计数器而言,点击按钮,修改状态信息,重新构建后,实现界面上数字变化的效果。





二、为什么需要管理


说到 管理 一词,你觉得什么情况下需要管理?是 复杂,只有 复杂 才有管理的必要。那管理有什么好处?


比如张三开了一家餐馆,雇了四个人,他们各干各的,都要同时进行招乎食客、烧菜、送快递、清洁等任务,那效率将非常低下。如果菜里吃出了不明生物 (bug),也不容易定位问题根源。这很像什么东西都塞在一个 XXXState 里去完成,其中不仅需要处理组件构建逻辑,还掺杂着大量的业务逻辑


如果将复杂的事务,分层次地交由不同人进行处理,各司其职,要比四个人各干各的要高效。而管理的目的就是分层级提高地 处理任务。




1.状态的作用范围

首先来思考一个问题:是不是所有的状态都需要管理?比如说下面的 FloatingActionButton ,在点击时会有水波纹的效果,界面的变化就意味着存在着状态的变化



FloatingActionButton 组件继承自 StatelessWidget,也就是说它并没有改变自身状态的能力。那点击时,为什么状态会发生变化呢?因为它在 build 中使用了 RawMaterialButton 组件,RawMaterialButton 中使用了 InkWell ,而 InkWell 继承自 InkResponseInkResponsebuild 中使用了_InkResponseStateWidget ,这个组件中维护了水波纹在手势中的状态变化逻辑。


class FloatingActionButton extends StatelessWidget{

---->[FloatingActionButton#build]----
Widget result = RawMaterialButton(
onPressed: onPressed,
mouseCursor: mouseCursor,
elevation: elevation,
focusElevation: focusElevation,
hoverElevation: hoverElevation,
highlightElevation: highlightElevation,
disabledElevation: disabledElevation,
constraints: sizeConstraints,
materialTapTargetSize: materialTapTargetSize,
fillColor: backgroundColor,
focusColor: focusColor,
hoverColor: hoverColor,
splashColor: splashColor,
textStyle: extendedTextStyle,
shape: shape,
clipBehavior: clipBehavior,
focusNode: focusNode,
autofocus: autofocus,
enableFeedback: enableFeedback,
child: resolvedChild,
);



也就是说:点击时,水波纹的变化效果,被封装在 _InkResponseStateWidget 组件状态中。像这种私有的状态,我们并不需要进行管理,因为它能够独立完成自己任务,而且外界并不需要了解这些状态。比如水波纹的圆心半径等会变化的状态信息,在外界是不关心的。

Flutter 中的 State 本身就是一种状态管理的手段。因为:


1. State 具有根据状态信息,构建组件的能力
2. State 具有重新构建组件的能力

所有的 StatefulWidget 都是这样,变化逻辑及状态量都会被封装在对应的 XXXState 类中。是局部的,私有的,外界无需了解内部状态的信息变化,也没有可以直接访问的途径。这一般用于对组件的封装,将复杂且相对独立的状态变化,封装起来,简化用户使用。




2.状态的共享及修改同步

上面说的 State 管理状态虽然非常小巧,方便。但同时也会存在不足之处,因为状态量被维护在 XXXState 内部,外界很难访问修改。比如下面 page1 中,C 是数字信息,跳转到 page2 时,也要显示这个数值,且按下 R 按钮能要让 page1page2 的数字都重置为 0。这就存在着状态存在共享及修改同步更新,该如何实现呢?





我们先来写个如下的设置界面:



class SettingPage extends StatelessWidget {
const SettingPage({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('设置界面'),),
body: Container(
height: 54,
color: Colors.white,
child: Row(
children: [
const SizedBox(width: 10,),
Text('当前计数为:'),
Spacer(),
ElevatedButton(child: Text('重置'),onPressed: (){} ),
const SizedBox(width: 10,)
],
),
),
);
}
}

那如何知道当前的数值,以及如何将 重置 操作点击时,影响 page1 的数字状态呢?其实 构造入参回调函数 可以解决一切的数据共享和修改同步问题。




3.代码实现 - setState 版:源码位置

在点击重置时 ,由于 page2 的计数也要清空,这就说明其状态量需要变化,要用 StatefulWidget 维护状态。在构造时,通过构造方法传入 initialCounter ,让 page2 的数字可以与 page1 一致。通过 onReset 回调函数来监听重置按钮的触发,以此来重置 page1 的数字状态,让 page1 的数字可以与 page2 一致。这就是让两个界面的同一状态量保持一致。如下图:


class SettingPage extends StatefulWidget {
final int initialCounter;
final VoidCallback onReset;

const SettingPage({
Key? key,
required this.initialCounter,
required this.onReset,
}) : super(key: key);

@override
State<SettingPage> createState() => _SettingPageState();
}














跳转到设置页设置页重置

class _SettingPageState extends State<SettingPage> {
int _counter = 0;

@override
void initState() {
super.initState();
_counter = widget.initialCounter;
}

//构建同上, 略...

void _onReset() {
widget.onReset();
setState(() {
_counter = 0;
});
}

_SettingPageState 中维护 _counter 状态量,在点击 重置 时执行 _onReset 方法,触发 onReset 回调。在 界面1 中监听 onReset ,来重置 界面1 的数字状态。这样通过 构造入参回调函数 ,就能保证两个界面 数字状态信息 的同步。


---->[界面1 跳转代码]-----
Navigator.push(context,
MaterialPageRoute(builder: (context) => SettingPage(
initialCounter: _counter,
onReset: (){
setState(() {
_counter=0;
});
},
)));

但这样,确定也很明显,数据传来传去,调来调去,非常麻烦,乱就容易出错。如果再多几个需要共享的信息,或者在其他界面里还需要共享这个状态,那代码里将会更加混乱。




4.代码实现 - ValueListenableBuilder 版:源码位置

上面的 setState 版实现 数据共享和修改同步,除了代码混乱之外,还有一些其他的缺点。首先,在 SettingPage 中我们又维护了一个状态信息,两个界面的信息虽然相同,却是两份一样的。如果状态信息是比较大的对象,这未免会造成不必要的内存浪费。





其次,就是深为大家诟病的 setState 重构范围。State#setState 执行后,会触发 build 方法重新构建组件。比如在 page1 中,_MyHomePageState#build 构建的是 Scaffold ,当状态变化时触发 setState ,其下的所有组件都会被构建一遍,重新构建的范围过大。

大家可以想一下,这里为什么不把 Scaffold 提到外面去?原因是:FloatingActionButton 组件需要修改状态量 _counter 并执行重新构建,所以不得不扩大构建的范围,来包含住 FloatingActionButton





其实 Flutter 中有个组件可以解决上面两个问题,那就是 ValueListenableBuilder 。使用方式很简单,先创建一个 ValueNotifier 的可监听对象 _counter


class _MyHomePageState extends State<MyHomePage> {

final ValueNotifier<int> _counter = ValueNotifier(0);

@override
void dispose() {
super.dispose();
_counter.dispose();
}

void _incrementCounter() {
_counter.value++;
}

如下使用 ValueListenableBuilder 组件,监听 _counter 对象,当该可监听对象的数值变化时,会可以通知监听者,重新构建 builder 方法里的组件。这样最大的好处在于:不需要 通过 _MyHomePageState#setState 对内部整体进行构建,仅对需要改变的局部 进行重新构建。


ValueListenableBuilder(
valueListenable: _counter,
builder: (ctx, int value, __) => Text(
'$value',
style: Theme.of(context).textTheme.headline4,
),
),



可以将 对于_counter 可见听对象传入 page2 中,同样通过 ValueListenableBuilder 监听 counter。这就相当于观察者模式中,两个订阅者 同时监听一个发布者 。在 page2 中让发布者信息变化,也会通知两个订阅者,比如执行 counter.value =0 ,两处的 ValueListenableBuilder 都会触发局部重建。



这样就能达到和 setState 版 一样的效果,通过 ValueListenableBuilder 简化了入参和回调通知,并具有局部重构组件的能力。可以说 State状态的共享及修改同步 方面是被 ValueListenableBuilder 完胜的。但话说回来, State 本来就不是做这种事的,它更注重于私有状态的处理。比如ValueListenableBuilder 的本质,就是一个通过 State 实现的私有状态封装 ,所以没有什么好不好,只有适合或不适合。





三、使用状态管理工具


1. 状态管理工具的必要性

其实前面的 ValueListenableBuilder 的效果以及不错了,但是在某些场合仍存在不足。因为 _counter 需要通过构造方法进行传递,如果状态量过多,或共享场合变多、传递层级过深,也会使代码处理比较复杂。最致命的一点是:业务逻辑处理界面组件都耦合在 _MyHomePageState 中,这对于拓展维护而言并不是件好事。所以 管理 对于 复杂逻辑性下的状态的共享及修改同步 是有必要的。





2.通过 flutter_bloc 实现状态管理: 源码位置

我们前面说过,状态管理的目的在于:让状态可以共享及在更新状态时可以同步更新相关组件显示,且将状态变化逻辑界面构建进行分离。flutter_bloc 是实现状态管理的工具之一,它的核心是:通过 BlocEvent 操作转化成 State;同时通过 BlocBuilder 监听状态的变化,进行局部组件构建。


通过这种方式,编程者可以将 状态变化逻辑 集中在 Bloc 中处理。当事件触发时,通过发送 Event 指令,让 Bloc 驱动 State 进行变化。就这个小案例而言,主要有两个事件: 自加重置 。像这样不需要参数的 Event , 通过枚举进行区分即可,比如定义事件:


enum CountEvent {
add, // 自加
reset, // 重置
}



状态,就是界面构建需要依赖的信息。这里定义 CountState ,持有 value 数值。


class CountState {
final int value;
const CountState({this.value = 0});
}



最后是 Bloc ,新版的 flutter_bloc 通过 on 监听事件,通过 emit 产出新状态。如下在构造中通过 on 来监听 CountEvent 事件,通过 _onCountEvent 方法进行处理,进行 CountState 的变化。当 event == CountEvent.add 时,会产出一个原状态 +1 的新 CountState 对象。


class CountBloc extends Bloc<CountEvent, CountState> {
CountBloc() : super(const CountState()){
on<CountEvent>(_onCountEvent);
}

void _onCountEvent(CountEvent event, Emitter<CountState> emit) {
if (event == CountEvent.add) {
emit(CountState(value: state.value + 1));
}

if (event == CountEvent.reset) {
emit (const CountState(value: 0));
}
}
}

画一个简单的示意图,如下:点击 _incrementCounter 时,只需要触发 CountEvent.add 指令即可。核心的状态处理逻辑会在 CountBloc 中进行,并生成新的状态,且通过 BlocBuilder 组件 触发局部更新 。这样,状态变化的逻辑界面构建的逻辑就能够很好地分离。



// 发送自加事件指定
void _incrementCounter() {
BlocProvider.of<CountBloc>(context).add(CountEvent.add);
}

//构建数字 Text 处使用 BlocBuilder 局部更新:
BlocBuilder<CountBloc, CountState>(
builder: _buildCounterByState,
),

Widget _buildCounterByState(BuildContext context, CountState state) {
return Text(
'${state.value}',
style: Theme.of(context).textTheme.headline4,
);
}



这样,设置界面的 重置 按钮也是类似,只需要发出 CountEvent.reset 指令即可,核心的状态处理逻辑会在 CountBloc 中进行,并生成新的状态,且通过 BlocBuilder 组件 触发局部更新





由于 BlocProvider.of<CountBloc>(context) 获取 Bloc 对象,需要上级的上下文存在该 BlocProvider ,可以在最顶层进行提供。这样在任何界面中都可以获取该 Bloc 及对其状态进行共享。



这是个比较小的案例,可能无法体现 Bloc 的精髓,但作为一个入门级的体验还是挺不错的。你需要自己体会一下:


[1]. 状态的 [共享] 及 [修改状态] 时同步更新。
[2]. [状态变化逻辑] 和 [界面构建逻辑] 的分离。

个人认为,这两点是状态管理的核心。也许每个人都会有各自的认识,但至少你不能在不知道自己要管理什么的情况下,做着表面上认为是状态管理的事。最后总结一下我的观点:状态就是界面构建需要依赖的信息;而管理,就是通过分工,让这些状态信息可以更容易维护更便于共享更好同步变化更'高效'地运转flutter_bloc 只是 状态管理 的工具之一,而其他的工具,也不会脱离这个核心。




四、官方案例 - github_search 解读


1. 案例介绍:源码位置

为了让大家对 flutter_bloc 在逻辑分层上有更深的认识,这里选取了 flutter_bloc 官方的一个案例进行解读。下面先简单看一下界面效果:


[1] 输入字符进行搜索,界面显示 github 项目
[2] 在不同的状态下显示不同的界面,如未输入、搜索中、搜索成功、无数据。
[3] 输入时防抖 debounce。避免每输入一个字符都请求接口。

注: debounce : 当调用动作 n 毫秒后,才会执行该动作,若在这 n 毫秒内又调用此动作则将重新计算执行时间。















搜索状态变化无数据时状态显示

项目结构


├── bloc         # 处理状态变化逻辑
├── view # 处理视图构建
├── repository # 处理数据获取逻辑
└── main.dart # 程序入口



2.仓储层 repository

我们先来看一下仓储层 repository ,这是将数据获取逻辑单独抽离出来,其中包含model 包下相关数据实体类 ,和 api 包下数据获取操作。



有人可能会问,业务逻辑都放在 Bloc 里处理不就行了吗,为什么非要搞个 repository 层。其实很任意理解,Bloc 核心是处理状态的变化,如果接口请求代码都放在 Bloc 里就显得非常臃肿。更重要的有点是: repository 层是相对独立的,你完全可以单独对进行测试,保证数据获取逻辑的正确性。


这样能带来另一个好处,当数据模型确定后。repository 层和界面层完全可以同步进行开发,最后通过 Bloc 层将 repository界面 进行整合。分层是进行管理的一种手段,就像不同部门来处理不同的事务,一旦出错,就很容易定位是哪个环节出了问题。当一个部门的进行拓展升级,也能尽可能不波及其他部门。



repository 层也是通用的,不管是 Bloc 也好、Provider 也好,都只是管理的一种手段。repository 层作为数据的获取方式是完全独立的,比如 todo 的案例,Bloc 版和 Provider 可以共用一个 repository 层,因为即使框架的使用方式有差异,但数据的获取方式是不变的。




下面来简单看一下repository 层的逻辑,GithubRepository 依赖两个对象,只有一个 search 方法。其中 GithubCache 类型 cache 对象用于记录缓存,在查询时首先从缓存中查看,如果已存在,则返回缓存数据。否则使用 GithubClient 类型的 client 对象进行搜索。





GithubClient 主要通过 http 获取网络数据。





GithubClient 就是通过一个 Map 维护搜索字符搜索结果的映射。这了处理的比较简单,完全可以基于此进行拓展:比如设置一个缓存数量上限,不然随着搜索缓存会一直加入;或将缓存加入数据库,支持离线缓存。将 repository 层独立出来后,这些功能的拓展就能和界面层解耦。因为界面只关心数据本身,并不关心数据如何缓存、如何获取。





3. bloc 层

首先来看事件,整个搜索功能只有一个事件:文字输入时的TextChanged,事件触发时需要附带搜索的信息字符串。


abstract class GithubSearchEvent extends Equatable {
const GithubSearchEvent();
}

class TextChanged extends GithubSearchEvent {
const TextChanged({required this.text});

final String text;

@override
List<Object> get props => [text];

@override
String toString() => 'TextChanged { text: $text }';
}



至于状态,整个过程中有四类状态:



  • [1]. SearchStateEmpty : 输入字符为空时的状态,无维护数据。

  • [2]. SearchStateLoading : 从请求开始到响应中的等待状态,无维护数据。

  • [3]. SearchStateSuccess: 请求成功的状态,维护 SearchResultItem 条目列表。

  • [4]. SearchStateError:失败状态,维护错误信息字符串。





最后是 Bloc,用于整合状态变化的逻辑。在 构造方法 中通过 onTextChanged 事件进行监听,触发 _onTextChanged 产出状态。比如 searchTerm.isEmpty 说明无字符输入,产出 SearchStateEmpty 状态。在 githubRepository.search 获取数据前,产出 SearchStateLoading 表示等待状态。请求成功则产出 SearchStateSuccess 状态,且内含结果数据,失败则产出 SearchStateError 状态。


class GithubSearchBloc extends Bloc<GithubSearchEvent, GithubSearchState> {
GithubSearchBloc({required this.githubRepository})
: super(SearchStateEmpty()) {
on<TextChanged>(_onTextChanged);
}

final GithubRepository githubRepository;

void _onTextChanged(
TextChanged event,
Emitter<GithubSearchState> emit,
) async {
final searchTerm = event.text;

if (searchTerm.isEmpty) return emit(SearchStateEmpty());

emit(SearchStateLoading());

try {
final results = await githubRepository.search(searchTerm);
emit(SearchStateSuccess(results.items));
} catch (error) {
emit(error is SearchResultError
? SearchStateError(error.message)
: const SearchStateError('something went wrong'));
}
}
}

到这里,整个业务逻辑就完成了,不同时刻的状态变化也已经完成,接下来只需要通过 BlocBuilder 监听状态变化,构建组件即可。另外说明一下 debounce 的作用:如果不进行防抖处理,每次输入字符都会触发请求获取数据,这样会造成请求非常频繁,而且过程中的输入大多数是无用的。这种情况,就可以使用 debounce 进行处理,比如,输入 300 ms 后才进行请求操作,如果在此期间有新的输入,就重新计时。
其本质是对流的转换操作,在 stream_transform 插件中有相关处理,在 pubspec.yaml 中添加依赖


stream_transform: ^2.0.0



on<TextChanged>transformer 参数中可以指定事件流转换器,这样就能完成防抖效果:


const Duration _duration = Duration(milliseconds: 300);

EventTransformer<Event> debounce<Event>(Duration duration) {
return (events, mapper) => events.debounce(duration).switchMap(mapper);
}

class GithubSearchBloc extends Bloc<GithubSearchEvent, GithubSearchState> {
GithubSearchBloc({required this.githubRepository})
: super(SearchStateEmpty()) {
// 使用 debounce 进行转换
on<TextChanged>(_onTextChanged, transformer: debounce(_duration));
}



4.界面层

界面层的处理非常简单,通过 BlocBuilder 监听状态变化,根据不同的状态构建不同的界面元素即可。





事件的触发,是在文字输入时。输入框被单独封装成 SearchBar 组件,在 TextFieldonChanged 方法中,触发 _githubSearchBlocTextChanged 方法,这样驱动点,让整个状态变化的“齿轮组”运转了起来。


---->[search_bar.dart]----
@override
void initState() {
super.initState();
_githubSearchBloc = context.read<GithubSearchBloc>();
}

return TextField(
//....
onChanged: (text) {
_githubSearchBloc.add(TextChanged(text: text));
},

这样一个简单的搜索需求就完成了,flutter_bloc 还通过了非常多的实例、文档,有兴趣的可以自己多研究研究。




五、小结


这里小结一下我对状态管理的理解:


[1]. [状态] 是界面构建需要依赖的信息。
[2]. [管理] 是对复杂场景的分层处理,使[状态变化逻辑]独立于[视图构建逻辑]。

再回到那个最初的问题,是所有的状态都需要管理吗?如何区分哪些状态需要管理?就像前端 redux 状态管理,在 You Might Not Need Redux (可自行百度译文) 中说到:人们常常在正真需要 Redux 之前,就选择使用它 。对于状态管理,其实都是这样,往往初学者 "趋之若鹜" ,不明白为什么要状态管理,为什么一个很简单的功能,非要弯弯绕绕一大圈来实现。就是看到别用了,使用我也要用,这是不理智的。


我们在使用前应该明白:


[1]. 状态是否需要被共享和修改同步。如果否,也许通过 [State] 封装为内部状态是更好的选择。
[2]. [业务逻辑] 和[界面状态变化] 是否复杂到有分层的必要。如果不是非常复杂,
FutureBuilder、ValueListenableBuilder 这种小巧的局部构建组件也许是更好的选择。

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

[JS基础回顾] 闭包 又双叒叕来~~~

闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包 MDN的解释闭包是函数和声明该函数的词法环境的组合。 Tips: 词法作用域和词法环境 1,此时函数还没被执行,所以使用的是词法作用域即静态作用域.2, 此时函...
继续阅读 »

闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包




MDN的解释闭包是函数声明该函数的词法环境的组合。




Tips: 词法作用域词法环境 1,此时函数还没被执行,所以使用的是词法作用域即静态作用域.2, 此时函数被执行,此时词法作用域就会变成词法环境(包含静态作用域与动态作用域)



以上的解释 个人感觉还是不够清晰
我这样理解

  1. 闭包就是突破了函数作用域
  2. 闭包就是函数嵌套函数子函数可以访问父函数的变量(也就是所谓的自由变量), 所以,此变量不会被回收.


闭包暴露``函数作用域3种方式:


1) 通过外部函数的参数进行暴露



闭包内 调用外部函数 通过外部函数的参数 暴露 闭包内 自由变量.



function fn() { 
var a = 2;
function innerFn() {
outerFn(a) //通过外部函数的参数进行暴露
}
innerFn();
};
function outerFn(val) {
console.log(val); // 2
}
fn(); // 2

2) 通过外部作用域的变量进行暴露



其中val为全局变量



function fn() { 
var a = 1;
function innerFn() {
val = a; //通过外部作用域的变量进行暴露
}
innerFn();
};

fn();
console.log(val); // 1


3) 通过return直接将整个函数进行暴露


function fn() { 
var a = 1;
function innerFn() {
console.log(a);
}
return innerFn; //通过return直接将整个函数进行暴露
};

let a = fn();
a(); // 1

关于闭包的内存泄露



首先必须声明一点:使用闭包并不一定会造成内存泄露,只有使用闭包不当才可能会造成内存泄露.




为什么闭包可能会造成内存泄露呢?原因就是上面提到的,因为它一般会暴露自身的作用域给外部使用.如果使用不当,就可能导致该内存一直被占用,无法被JS的垃圾回收机制回收.就造成了内存泄露.




注意: 即使闭包里面什么都没有,闭包仍然会隐式地引用它所在作用域里的所用变量. 正因为这个隐藏的特点,闭包经常会发生不易发现的内存泄漏问题.



常见哪些情况使用闭包会造成内存泄露:





    1. 使用定时器未及时清除.因为计时器只有先停止才会被回收.所以决办法很简单,将定时器及时清除,并将造成内存的变量赋值为null(变成空指针)





    1. 相互循环引用.这是经常容易犯的错误,并且也不容易发现.





    1. 闭包引用到全局变量上.因为全局变量是只有当页面被关闭的时候才会被回收.




四 循环和闭包


1) 同步循环打印 正确的值


for (var i=1; i<5; i++) { 
console.log( i );
}
// 1 2 3 4

2) 同步中嵌套异步任务(中的宏任务)循环打印 错误的值



当执行 console 时, 循环已经完成, 同步任务执行完成后,执行宏任务,此时 i 已经是 5.所以打印5个5.



for (var i=1; i<5; i++) { 
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
// 打印出 5 个 5

3) 创造5个独立的函数作用域,但是 i 也全都是对外部作用域的引用 错误的值



它的最终值仍然是5个5.为什么?我们来分析下,它用了一个匿名函数包裹了定时器,并立即执行.在进行for循环时,会创造5个独立的函数作用域(由匿名函数创建的,因为它是闭包函数).但是这5个独立的函数作用域里的i也全都是对外部作用域的引用.即它们访问的都是i的最终值5.这并不是我们想要的,我们要的是5个独立的作用域,并且每个作用域都保存一个"当时"i的值.



for (var i=1; i<5; i++) { 
(function() {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
})();
}
// 打印出 5 个 5

4) 通过匿名函数创建独立的函数作用域,并且通过 变量 保存独立的 i 值


for (var i=1; i<5; i++) { 
(function () {
var x=i;
console.log(x*1000); // 1000 2000 3000 4000
setTimeout( function timer() {
console.log( x );
}, x*1000 );
})();
}

// 1 2 3 4

5) 通过匿名函数创建独立的函数作用域,并且通过 参数 保存独立的 i 值


for (var i=1; i<5; i++) { 
(function (x) {
console.log(x*1000); // 1000 2000 3000 4000
setTimeout( function timer() {
console.log( x );
}, x*1000 );
})(i);
}

// 1 2 3 4

注意

  • 使用定时器未及时清除.因为计时器只有先停止才会被回收.所以决办法很简单,将定时器及时清除,并将造成内存的变量赋值为null(变成空指针)
  • 闭包引用到全局变量上.因为全局变量是只有当页面被关闭的时候才会被回收.
  • 闭包就是函数嵌套函数子函数可以访问父函数的变量(也就是所谓的自由变量), 所以,此变量不会被回收.


  • 作者:无限循环无限
    链接:https://juejin.cn/post/7011805931201642533

    收起阅读 »

    JS箭头函数 什么时候用 ,什么时候不能用,我总结出了4点

    箭头函数的定义 箭头函数定义包括一个参数列表(零个或多个参数,如果参数个数不是一个的话要用 ( .. ) 包围起来),然后是标识 =>,函数体放在最后。 箭头函数与普通函数的区别 箭头函数 let arrowSum = (a, b) => { ...
    继续阅读 »

    箭头函数的定义



    箭头函数定义包括一个参数列表(零个或多个参数,如果参数个数不是一个的话要用 ( .. ) 包围起来),然后是标识 =>,函数体放在最后。



    箭头函数与普通函数的区别


    箭头函数


    let arrowSum = (a, b) => { 
    return a + b
    }

    普通函数


    let zz = function(a, b){
    return a + b
    }

    箭头函数的用法


    我们打印fn函数的原型,我们会发现箭头函数本身没有this;


    var fn = (a, b) => {
    console.log(this, fn.prototype);
    //window, undefined
    var fn2 = () => {
    console.log(this, '测试');
    // window
    };
    fn2();
    }
    fn()

    箭头函数的arguments
    我们会发现这样写会报语法错误


    var fn = (a) => {
    console.log(a.arguments)
    }
    fn();
    // TypeError:Cannot read property 'arguments' of undefined

    我们换一种情况,我们看代码会发现箭头函数argemnets指向了上一个函数



    箭头函数不会创建自己的this,它只会从自己的作用域链的上一层继承this。




    var z = function(a){
    console.log(arguments);
    bb();
    function bb() {
    console.log(arguments);
    let ac = () => {
    console.log(arguments);
    //arguments 指向第二层函数
    };
    ac();
    }
    }
    z()

    什么时候不能用箭头函数


    1. 通过构造函数调用


    let Foo = () =>  {

    }
    let result = new Foo();
    //TypeError: Foo is not a constructor

    2. 需要使用prototype


    let foo = () =>  {

    }
    console.log(foo.prototype)
    //underfind

    3. 没有super



    连原型都没有,自然也不能通过 super 来访问原型的属性,所以箭头函数也是没有 super 的,不过跟 this、arguments、new.target 一样,这些值由外围最近一层非箭头函数决定



    总结




    • 如果你有一个简单语句的在线函数表达式,其中唯一的语句是return某个计算出的值,而且这个函数内部没有this引用,且没有自身引用(比如递归,事件绑定/解绑定),且不会要求函数执行这些,那么我们可以安全的把它重构为=>箭头函数




    • 如果你的内层函数表达式依赖于它的函数中调用 let self= this 或者.bind(this)来确保适当的this绑定,那么内层函数表达式可以转换为=>箭头函数




    • 如果你的内函数表达式依赖于封装函数像 let args = Array.prototype.slice.call
      (arguments)的词法复制,那么这个内层函数表达式应该可以安全的转换=>箭头函数




    • 所有的其他情况——函数声明,较长的多函数表达式,需要词法名称标识符(比如递归 , 构造函数)的函数,以及任何不符合以上几点特征的函数一般都应该避免=>箭头函数





    关于this arguments 和 super 的词法绑定。这是利用es6的特性来修正一些常见的问题,而不是bug或者错误。


    作者:zz
    链接:https://juejin.cn/post/7011270097721360421
    收起阅读 »

    ?Map和Set巧解力扣算法问题

    问题一:什么是Map和Set? ES6以前,在JavaScript中实现“键/值”式存储可以使用Object来方便高效的完成,也就是使用对象属性作为键,再使用属性来引用值,像下面这样 let student = { name: '啊呜', se...
    继续阅读 »

    问题一:什么是Map和Set?


    ES6以前,在JavaScript中实现“键/值”式存储可以使用Object来方便高效的完成,也就是使用对象属性作为键,再使用属性来引用值,像下面这样


    let student = {
    name: '啊呜',
    sex: 'male',
    age: 18
    }

    但是这种实现并非没有问题,这里的键只能是对象的属性,于是就出现了Map这一新的集合类型,为JavaScript带来了真正的键/值存储机制,我们可以这样初始化映射:


    const map = new Map([
    ['key1','value1'],
    ['key2','value2'],
    ['key3','value3'],
    ])

    ES6还新增了Set这一种新的集合类型,Set在很多方面都像是加强的Map,这是因为它们的大多数API和行为都是共有的。Set集合类型的特点是不能存储重复元素,成员值都是唯一且没有重复的值


    问题二:Map和Set的基本API怎么用?


    Map的API:




    • get() :返回键值对




    • set() :添加键值对,返回实例




    • delete() :删除键值对,返回布尔




    • has() :检查键值对,返回布尔




    • clear() :清除所有成员




    • keys() :返回以键为遍历器的对象




    • values() :返回以值为遍历器的对象




    • entries() :返回以键和值为遍历器的对象




    • forEach() :使用回调函数遍历每个成员




    Set的API:




    • add() :添加值,返回实例




    • delete() :删除值,返回布尔




    • has() :检查值,返回布尔




    • clear() :清除所有成员




    • keys() :返回以属性值为遍历器的对象




    • values() :返回以属性值为遍历器的对象




    • entries() :返回以属性值和属性值为遍历器的对象




    • forEach() :使用回调函数遍历每个成员





    好啦,到这,相信你对JS中的Map和Set有了一定的了解,我们现在尝试使用这两种集合类型,在LeetCode中大显身手~



    LeetCode20:有效的括号



    给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。



    示例1:



    输入: s = "()"

    输出: true



    示例2:



    输入: s = "()[]{}"

    输出: true



    示例 3:



    输入: s = "(]"

    输出: false



    这题的思路是使用栈+Map来解决,直接上代码:


    carbon (2).png


    LeetCode141:环形链表



    给定一个链表,判断链表中是否有环。



    示例:


    circularlinkedlist.png



    输入: head = [3,2,0,-4], pos = 1

    输出: true



    这题我的思路是使用Set来解决,当然还有一种方法,用快慢指针来解决,但是比较难想到,而且比较反人类,我们这里只介绍Set,清晰易懂~


    carbon (3).png



    作者:_啊呜
    链接:https://juejin.cn/post/7011710641807294477
    收起阅读 »

    深入理解 redux 数据流和异步过程管理

    前端框架的数据流 前端框架实现了数据驱动视图变化的功能,我们用 template 或者 jsx 描述好了数据和视图的绑定关系,然后就只需要关心数据的管理了。 数据在组件和组件之间、组件和全局 store 之间传递,叫做前端框架的数据流。 一般来说,除了某部分状...
    继续阅读 »

    前端框架的数据流


    前端框架实现了数据驱动视图变化的功能,我们用 template 或者 jsx 描述好了数据和视图的绑定关系,然后就只需要关心数据的管理了。


    数据在组件和组件之间、组件和全局 store 之间传递,叫做前端框架的数据流。


    一般来说,除了某部分状态数据是只有某个组件关心的,我们会把状态数据放在组件内以外,业务数据、多个组件关心的状态数据都会放在 store 里面。组件从 store 中取数据,当交互的时候去通知 store 改变对应的数据。


    这个 store 不一定是 redux、mobox 这些第三方库,其实 react 内置的 context 也可以作为 store。但是 context 做为 store 有一个问题,任何组件都能从 context 中取出数据来修改,那么当排查问题的时候就特别困难,因为并不知道是哪个组件把数据改坏的,也就是数据流不清晰。


    正是因为这个原因,我们几乎见不到用 context 作为 store,基本都是搭配一个 redux。


    所以为什么 redux 好呢?第一个原因就是数据流清晰,改变数据有统一的入口。



    组件里都是通过 dispatch 一个 action 来触发 store 的修改,而且修改的逻辑都是在 reducer 里面,组件再监听 store 的数据变化,从中取出最新的数据。


    这样数据流动是单向的,清晰的,很容易管理。


    这就像为什么我们在公司里想要什么权限都要走审批流,而不是直接找某人,一样的道理。集中管理流程比较清晰,而且还可以追溯。


    异步过程的管理


    很多情况下改变 store 数据都是一个异步的过程,比如等待网络请求返回数据、定时改变数据、等待某个事件来改变数据等,那这些异步过程的代码放在哪里呢?


    组件?


    放在组件里是可以,但是异步过程怎么跨组件复用?多个异步过程之间怎么做串行、并行等控制?


    所以当异步过程比较多,而且异步过程与异步过程之间也不独立,有串行、并行、甚至更复杂的关系的时候,直接把异步逻辑放组件内不行。


    不放组件内,那放哪呢?


    redux 提供的中间件机制是不是可以用来放这些异步过程呢?


    redux 中间件


    先看下什么是 redux 中间件:


    redux 的流程很简单,就是 dispatch 一个 action 到 store, reducer 来处理 action。那么如果想在到达 store 之前多做一些处理呢?在哪里加?


    改造 dispatch!中间件的原理就是层层包装 dispatch。


    下面是 applyMiddleware 的源码,可以看到 applyMiddleware 就是对 store.dispatch 做了层层包装,最后返回修改了 dispatch 之后的 store。


    function applyMiddleware(middlewares) {
    let dispatch = store.dispatch
    middlewares.forEach(middleware =>
    dispatch = middleware(store)(dispatch)
    )
    return { ...store, dispatch}
    }

    所以说中间件最终返回的函数就是处理 action 的 dispatch:


    function middlewareXxx(store) {
    return function (next) {
    return function (action) {
    // xx
    };
    };
    };
    }

    中间件会包装 dispatch,而 dispatch 就是把 action 传给 store 的,所以中间件自然可以拿到 action、拿到 store,还有被包装的 dispatch,也就是 next。


    比如 redux-thunk 中间件的实现:


    function createThunkMiddleware(extraArgument) {
    return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
    return action(dispatch, getState, extraArgument);
    }

    return next(action);
    };
    }

    const thunk = createThunkMiddleware();

    它判断了如果 action 是一个函数,就执行该函数,并且把 store.dispath 和 store.getState 传进去,否则传给内层的 dispatch。


    通过 redux-thunk 中间件,我们可以把异步过程通过函数的形式放在 dispatch 的参数里:


    const login = (userName) => (dispatch) => {
    dispatch({ type: 'loginStart' })
    request.post('/api/login', { data: userName }, () => {
    dispatch({ type: 'loginSuccess', payload: userName })
    })
    }
    store.dispatch(login('guang'))

    但是这样解决了组件里的异步过程不好复用、多个异步过程之间不好做并行、串行等控制的问题了么?


    没有,这段逻辑依然是在组件里写,只不过移到了 dispatch 里,也没有提供多个异步过程的管理机制。


    解决这个问题,需要用 redux-saga 或 redux-observable 中间件。


    redux-saga


    redux-saga 并没有改变 action,它会把 action 透传给 store,只是多加了一条异步过程的处理。



    redux-saga 中间件是这样启用的:


    import { createStore, applyMiddleware } from 'redux'
    import createSagaMiddleware from 'redux-saga'
    import rootReducer from './reducer'
    import rootSaga from './sagas'

    const sagaMiddleware = createSagaMiddleware()
    const store = createStore(rootReducer, {}, applyMiddleware(sagaMiddleware))
    sagaMiddleware.run(rootSaga)

    要调用 run 把 saga 的 watcher saga 跑起来:


    watcher saga 里面监听了一些 action,然后调用 worker saga 来处理:


    import { all, takeLatest } from 'redux-saga/effects'

    function* rootSaga() {
    yield all([
    takeLatest('login', login),
    takeLatest('logout', logout)
    ])
    }
    export default rootSaga

    redux-saga 会先把 action 透传给 store,然后判断下该 action 是否是被 taker 监听的:


    function sagaMiddleware({ getState, dispatch }) {
    return function (next) {
    return function (action) {
    const result = next(action);// 把 action 透传给 store

    channel.put(action); //触发 saga 的 action 监听流程

    return result;
    }
    }
    }

    当发现该 action 是被监听的,那么就执行相应的 taker,调用 worker saga 来处理:


    function* login(action) {
    try {
    const loginInfo = yield call(loginService, action.account)
    yield put({ type: 'loginSuccess', loginInfo })
    } catch (error) {
    yield put({ type: 'loginError', error })
    }
    }

    function* logout() {
    yield put({ type: 'logoutSuccess'})
    }

    比如 login 和 logout 会有不同的 worker saga。


    login 会请求 login 接口,然后触发 loginSuccess 或者 loginError 的 action。


    logout 会触发 logoutSuccess 的 action。


    redux saga 的异步过程管理就是这样的:先把 action 透传给 store,然后判断 action 是否是被 taker 监听的,如果是,则调用对应的 worker saga 进行处理。


    redux saga 在 redux 的 action 流程之外,加了一条监听 action 的异步处理的流程。


    其实整个流程还是比较容易理解的。理解成本高一点的就是 generator 的写法了:


    比如下面这段代码:


    function* xxxSaga() {
    while(true) {
    yield take('xxx_action');
    //...
    }
    }

    它就是对每一个监听到的 xxx_action 做同样的处理的意思,相当于 takeEvery:


    function* xxxSaga() {
    yield takeEvery('xxx_action');
    //...
    }

    但是因为有一个 while(true),很多同学就不理解了,这不是死循环了么?


    不是的。generator 执行后返回的是一个 iterator,需要另外一个程序调用 next 方法才会继续执行。所以怎么执行、是否继续执行都是由另一个程序控制的。


    在 redux-saga 里面,控制 worker saga 执行的程序叫做 task。worker saga 只是告诉了 task 应该做什么处理,通过 call、fork、put 这些命令(这些命令叫做 effect)。


    然后 task 会调用不同的实现函数来执行该 worker saga。


    为什么要这样设计呢?直接执行不就行了,为啥要拆成 worker saga 和 task 两部分,这样理解成本不就高了么?


    确实,设计成 generator 的形式会增加理解成本,但是换来的是可测试性。因为各种副作用,比如网络请求、dispatch action 到 store 等等,都变成了 call、put 等 effect,由 task 部分控制执行。那么具体怎么执行的就可以随意的切换了,这样测试的时候只需要模拟传入对应的数据,就可以测试 worker saga 了。


    redux saga 设计成 generator 的形式是一种学习成本和可测试性的权衡。


    还记得 redux-thunk 有啥问题么?多个异步过程之间的并行、串行的复杂关系没法处理。那 redux-saga 是怎么解决的呢?


    redux-saga 提供了 all、race、takeEvery、takeLatest 等 effect 来指定多个异步过程的关系:


    比如 takeEvery 会对多个 action 的每一个做同样的处理,takeLatest 会对多个 action 的最后一个做处理,race 会只返回最快的那个异步过程的结果,等等。


    这些控制多个异步过程之间关系的 effect 正是 redux-thunk 所没有的,也是复杂异步过程的管理必不可少的部分。


    所以 redux-saga 可以做复杂异步过程的管理,而且具有很好的可测试性。


    其实异步过程的管理,最出名的是 rxjs,而 redux-observable 就是基于 rxjs 实现的,它也是一种复杂异步过程管理的方案。


    redux-observable


    redux-observable 用起来和 redux-saga 特别像,比如启用插件的部分:


    const epicMiddleware = createEpicMiddleware();

    const store = createStore(
    rootReducer,
    applyMiddleware(epicMiddleware)
    );

    epicMiddleware.run(rootEpic);

    和 redux saga 的启动流程是一样的,只是不叫 saga 而叫 epic。


    但是对异步过程的处理,redux saga 是自己提供了一些 effect,而 redux-observable 是利用了 rxjs 的 operator:


    import { ajax } from 'rxjs/ajax';

    const fetchUserEpic = (action$, state$) => action$.pipe(
    ofType('FETCH_USER'),
    mergeMap(({ payload }) => ajax.getJSON(`/api/users/${payload}`).pipe(
    map(response => ({
    type: 'FETCH_USER_FULFILLED',
    payload: response
    }))
    )
    );

    通过 ofType 来指定监听的 action,处理结束返回 action 传递给 store。


    相比 redux-saga 来说,redux-observable 支持的异步过程的处理更丰富,直接对接了 operator 的生态,是开放的,而 redux-saga 则只是提供了内置的几个 effect 来处理。


    所以做特别复杂的异步流程处理的时候,redux-observable 能够利用 rxjs 的操作符的优势会更明显。


    但是 redux-saga 的优点还有基于 generator 的良好的可测试性,而且大多数场景下,redux-saga 提供的异步过程的处理能力就足够了,所以相对来说,redux-saga 用的更多一些。


    总结


    前端框架实现了数据到视图的绑定,我们只需要关心数据流就可以了。


    相比 context 的混乱的数据流,redux 的 view -> action -> store -> view 的单向数据流更清晰且容易管理。


    前端代码中有很多异步过程,这些异步过程之间可能有串行、并行甚至更复杂的关系,放在组件里并不好管理,可以放在 redux 的中间件里。


    redux 的中间件就是对 dispatch 的层层包装,比如 redux-thunk 就是判断了下 action 是 function 就执行下,否则就是继续 dispatch。


    redux-thunk 并没有提供多个异步过程管理的机制,复杂异步过程的管理还是得用 redux-saga 或者 redux-observable。


    redux-saga 透传了 action 到 store,并且监听 action 执行相应的异步过程。异步过程的描述使用 generator 的形式,好处是可测试性。比如通过 take、takeEvery、takeLatest 来监听 action,然后执行 worker saga。worker saga 可以用 put、call、fork 等 effect 来描述不同的副作用,由 task 负责执行。


    redux-observable 同样监听了 action 执行相应的异步过程,但是是基于 rxjs 的 operator,相比 saga 来说,异步过程的管理功能更强大。


    不管是 redux-saga 通过 generator 来组织异步过程,通过内置 effect 来处理多个异步过程之间的关系,还是 redux-observable 通过 rxjs 的 operator 来组织异步过程和多个异步过程之间的关系。它们都解决了复杂异步过程的处理的问题,可以根据场景的复杂度灵活选用。


    作者:zxg_神说要有光
    链接:https://juejin.cn/post/7011835078594527263

    收起阅读 »

    【JavaScript】async await 更优雅的错误处理

    背景 团队来了新的小伙伴,发现我们的团队代码规范中,要给 async await 添加 try...catch。他感觉很疑惑,假如有很多个(不集中),那不是要加很多个地方?那不是很不优雅? 为什么要错误处理 JavaScript 是一个单线程的语言,假如不加...
    继续阅读 »

    背景


    团队来了新的小伙伴,发现我们的团队代码规范中,要给 async await 添加 try...catch。他感觉很疑惑,假如有很多个(不集中),那不是要加很多个地方?那不是很不优雅?


    为什么要错误处理


    JavaScript 是一个单线程的语言,假如不加 try ...catch ,会导致直接报错无法继续执行。当然不意味着你代码中一定要用 try...catch 包住,使用 try...catch 意味着你知道这个位置代码很可能出现报错,所以你使用了 try...catch 进行捕获处理,并让程序继续执行。


    我理解我们一般在执行 async await 的时候,一般运行在异步的场景下,这种场景一般不应该阻塞流程的进行,所以推荐使用了 try...catch 的处理。


    async await 更优雅的错误处理


    但确实如那位同事所说,加 try...catch 并不是一个很优雅的行为。所以我 Google 了一下,发现 How to write async await without try-catch blocks in Javascript 这篇文章中提到了一种更优雅的方法处理,并封装成了一个库——await-to-js。这个库只有一个 function,我们完全可以将这个函数运用到我们的业务中,如下所示:


    /**
    * @param { Promise } promise
    * @param { Object= } errorExt - Additional Information you can pass to the err object
    * @return { Promise }
    */
    export function to<T, U = Error> (
    promise: Promise<T>,
    errorExt?: object
    ): Promise<[U, undefined] | [null, T]> {
    return promise
    .then<[null, T]>((data: T) => [null, data]) // 执行成功,返回数组第一项为 null。第二个是结果。
    .catch<[U, undefined]>((err: U) => {
    if (errorExt) {
    Object.assign(err, errorExt);
    }

    return [err, undefined]; // 执行失败,返回数组第一项为错误信息,第二项为 undefined
    });
    }

    export default to;

    这里需要有一个前置的知识点:await 是在等待一个 Promise 的返回值


    正常情况下,await 命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。


    所以我们只需要利用 Promise 的特性,分别在 promise.thenpromise.catch 中返回不同的数组,其中 fulfilled 的时候返回数组第一项为 null,第二个是结果。rejected 的时候,返回数组第一项为错误信息,第二项为 undefined。使用的时候,判断第一项是否为空,即可知道是否有错误,具体使用如下:


    import to from 'await-to-js';
    // If you use CommonJS (i.e NodeJS environment), it should be:
    // const to = require('await-to-js').default;

    async function asyncTaskWithCb(cb) {
    let err, user, savedTask, notification;

    [ err, user ] = await to(UserModel.findById(1));
    if(!user) return cb('No user found');

    [ err, savedTask ] = await to(TaskModel({userId: user.id, name: 'Demo Task'}));
    if(err) return cb('Error occurred while saving task');

    if(user.notificationsEnabled) {
    [ err ] = await to(NotificationService.sendNotification(user.id, 'Task Created'));
    if(err) return cb('Error while sending notification');
    }

    if(savedTask.assignedUser.id !== user.id) {
    [ err, notification ] = await to(NotificationService.sendNotification(savedTask.assignedUser.id, 'Task was created for you'));
    if(err) return cb('Error while sending notification');
    }

    cb(null, savedTask);
    }

    小结


    async await 中添加错误处理个人认为是有必要的,但方案不仅仅只有 try...catch。利用 async awaitPromise 的特性,我们可以更加优雅的处理 async await 的错误。


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

    收起阅读 »

    国内知名Wchat团队荣誉出品顶级IM通讯聊天系统

    iOS
    国内知名Wchat团队荣誉出品顶级IM通讯聊天系统团队言语在先:想低价购买者勿扰(团队是在国内首屈一指的通信公司离职后组建,低价购买者/代码代码贩子者/同行勿扰/)。想购买劣质低等产品者勿扰(行业鱼龙混杂,想购买类似低能协议xmpp者勿扰)。想购买由类似ope...
    继续阅读 »



    国内知名Wchat团队荣誉出品顶级IM通讯聊天系统



    团队言语在先:

    想低价购买者勿扰(团队是在国内首屈一指的通信公司离职后组建,低价购买者/代码代码贩子者/同行勿扰/)

    。想购买劣质低等产品者勿扰(行业鱼龙混杂,想购买类似低能协议xmpp者勿扰)

    。想购买由类似openfire第三方开源改造而来的所谓第三方通信server者勿扰

    。想购买没有做任何安全加密场景者勿扰(随便一句api 一个接口就构成了红包收发/转账/密码设置等没有任何安全系数可言的低质产品)

    。想购买非运营级别通信系统勿扰(到处呼喊:最稳定/真正可靠/大并发/真正安全!所有一切都需要实际架构支撑以及理论数值测验)

    。想购买无保障/无支撑者勿扰(1W/4W/10W低质产品不可谓没有,必须做到:大并发支持合同保障/合作支持运维保障/在线人数支持架构保障)

    。想购买消息丢包者勿扰(满天飞的所谓消息确认机制,最简单的测验既是前端支持消息收发demo测试环境,低质产品一秒收发百条消息必丢必崩,

    别提秒发千条/万条,更低质产品可测验:同时发九张图片/根据数字12345678910发送出去,必丢!android vs ios)

    。想购买大容量群uer者勿扰(随便宣传既是万人大群/几千大群/群组无限,小团队产品群组上线用户超过4000群消息体量不用很大手机前端必卡)

    。最重要一点:口口声声说要运营很大的系统 却想出十几个money的人群勿扰,买产品做系统一要稳定二要长久用三要抛开运维烦恼,预算有限那就干脆

    别买,买了几万的系统你一样后面用不起来会烂掉!

    。产品体系包括:android ios server adminweb maintenance httpapi h5 webpc (支持server压测/前端消息收发压测/httpapi压测)

    。。支持源码,但需要您拿去做一个伟大的系统出来!

    。。团队产品目前国内没有同质化,客户集中在国外,有求高质量产品的个人或团队可通过以下方式联系到我们(低价者勿扰!)

    。。。球球:383189941 q 513275129

    。。。。产品不多介绍直接加我 测试产品更直接

    。。。。。创新从未停止 更新不会终止 大陆唯一一家支持大并发保障/支持合同费用包含运维支撑的团队 

    收起阅读 »

    Android 高级UI5 画笔Paint的基本用法

    1.setStyle(Paint.Style style)设置画笔样式,取值有Paint.Style.FILL :填充内部Paint.Style.FILL_AND_STROKE :填充内部和描边Paint.Style.STROKE :仅描边代码实例:publi...
    继续阅读 »

    1.setStyle(Paint.Style style)

    设置画笔样式,取值有
    Paint.Style.FILL :填充内部
    Paint.Style.FILL_AND_STROKE :填充内部和描边
    Paint.Style.STROKE :仅描边

    代码实例:


    public class PaintViewBasic extends View {
    private Paint mPaint;

    public PaintViewBasic(Context context) {
    super(context);
    mPaint = new Paint();
    }

    public PaintViewBasic(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    mPaint = new Paint();
    }

    @Override
    protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    drawStyle(canvas);
    }

    private void drawStyle( Canvas canvas ) {

    mPaint.setColor(Color.RED);//设置画笔的颜色
    mPaint.setTextSize(60);//设置文字大小
    mPaint.setStrokeWidth(5);//设置画笔的宽度
    mPaint.setAntiAlias(true);//设置抗锯齿功能 true表示抗锯齿 false则表示不需要这功能

    mPaint.setStyle(Paint.Style.STROKE);
    canvas.drawCircle(200,200,160,mPaint);

    mPaint.setStyle(Paint.Style.FILL);
    canvas.drawCircle(200,600,160,mPaint);

    mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
    canvas.drawCircle(200,1000,160,mPaint);

    }

    }

    2.setStrokeCap(Paint.Cap cap)

    设置线冒样式,取值有
    Paint.Cap.BUTT(无线冒)
    Paint.Cap.ROUND(圆形线冒)
    Paint.Cap.SQUARE(方形线冒)
    注意:冒多出来的那块区域就是线帽!就相当于给原来的直线加上一个帽子一样,所以叫线帽


        private void drawStrokeCap(Canvas canvas) {
    Paint paint = new Paint();

    paint.setAntiAlias(true);
    paint.setStrokeWidth(200);
    paint.setColor(Color.parseColor("#00ff00"));
    paint.setStrokeCap(Paint.Cap.BUTT); // 线帽,即画的线条两端是否带有圆角,butt,无圆角
    canvas.drawLine(200, 200, 500, 200, paint);

    paint.setColor(Color.parseColor("#ff0000"));
    paint.setStrokeCap(Paint.Cap.ROUND); // 线帽,即画的线条两端是否带有圆角,ROUND,圆角
    canvas.drawLine(200, 500, 500, 500, paint);

    paint.setColor(Color.parseColor("#0000ff"));
    paint.setStrokeCap(Paint.Cap.SQUARE); // 线帽,即画的线条两端是否带有圆角,SQUARE,矩形
    canvas.drawLine(200, 800, 500, 800, paint);
    }

    3.setStrokeJoin(Paint.Join join)

    设置线段连接处样式,取值有:
    Paint.Join.MITER(结合处为锐角)
    Paint.Join.Round (结合处为圆弧)
    Paint.Join.BEVEL (结合处为直线)


      private void drawStrokeJoin( Canvas canvas ) {
    Paint paint = new Paint();

    paint.setAntiAlias( true );
    paint.setStrokeWidth( 80 );
    paint.setStyle(Paint.Style.STROKE ); // 默认是填充 Paint.Style.FILL
    paint.setColor( Color.parseColor("#0000ff") );

    Path path = new Path();
    path.moveTo(100, 100);
    path.lineTo(400, 100);
    path.lineTo(100, 300);
    paint.setStrokeJoin(Paint.Join.MITER);
    canvas.drawPath(path, paint);

    path.moveTo(100, 500);
    path.lineTo(400, 500);
    path.lineTo(100, 700);
    paint.setStrokeJoin(Paint.Join.ROUND);
    canvas.drawPath(path, paint);

    path.moveTo(100, 900);
    path.lineTo(400, 900);
    path.lineTo(100, 1100);
    paint.setStrokeJoin(Paint.Join.BEVEL);
    canvas.drawPath(path, paint);
    }

    }

    4.setPathEffect(PathEffect effect)

    设置绘制路径的效果,如点画线等

    CornerPathEffect:

    这个类的作用就是将Path的各个连接线段之间的夹角用一种更平滑的方式连接,类似于圆弧与切线的效果。
    一般的,通过CornerPathEffect(float radius)指定一个具体的圆弧半径来实例化一个CornerPathEffect。

    DashPathEffect:

    这个类的作用就是将Path的线段虚线化。
    构造函数为DashPathEffect(float[] intervals, float offset),其中intervals为虚线的ON和OFF数组,该数组的length必须大于等于2,phase为绘制时的偏移量。

    DiscretePathEffect:

    这个类的作用是打散Path的线段,使得在原来路径的基础上发生打散效果。
    一般的,通过构造DiscretePathEffect(float segmentLength,float deviation)来构造一个实例,其中,segmentLength指定最大的段长,deviation指定偏离量。

    PathDashPathEffect:

    这个类的作用是使用Path图形来填充当前的路径,其构造函数为PathDashPathEffect (Path shape, float advance, float phase,PathDashPathEffect.Stylestyle)。
    shape则是指填充图形,advance指每个图形间的间距,phase为绘制时的偏移量,style为该类自由的枚举值,有三种情况:Style.ROTATE、Style.MORPH和
    Style.TRANSLATE。其中ROTATE的情况下,线段连接处的图形转换以旋转到与下一段移动方向相一致的角度进行转转,MORPH时图形会以发生拉伸或压缩等变形的情况与下一段相连接,TRANSLATE时,图形会以位置平移的方式与下一段相连接。

    ComposePathEffect:

    组合效果,这个类需要两个PathEffect参数来构造一个实例,ComposePathEffect (PathEffect outerpe,PathEffect innerpe),表现时,会首先将innerpe表现出来,然后再在innerpe的基础上去增加outerpe的效果。

    SumPathEffect:

    叠加效果,这个类也需要两个PathEffect作为参数SumPathEffect(PathEffect first,PathEffect second),但与ComposePathEffect不同的是,在表现时,会分别对两个参数的效果各自独立进行表现,然后将两个效果简单的重叠在一起显示出来。

    关于参数phase

    在存在phase参数的两个类里,如果phase参数的值不停发生改变,那么所绘制的图形也会随着偏移量而不断的发生变动,这个时候,看起来这条线就像动起来了一样。


    private float phase;
    private PathEffect[] effects;
    private int[] colors;

    private void drawPathEffect(Canvas canvas) {
    mPaint.setStyle(Paint.Style.STROKE);
    mPaint.setStrokeWidth(4);
    // 创建,并初始化Path
    Path path = new Path();
    path.moveTo(0, 0);
    for (int i = 1; i <= 35; i++) {
    // 生成15个点,随机生成它们的坐标,并将它们连成一条Path
    path.lineTo(i * 20, (float) Math.random() * 60);
    }
    // 初始化七个颜色
    colors = new int[]{Color.BLACK, Color.BLUE, Color.CYAN, Color.GREEN, Color.MAGENTA, Color.RED,
    Color.GRAY};


    // 将背景填充成白色
    canvas.drawColor(Color.WHITE);
    effects = new PathEffect[7];
    // -------下面开始初始化7中路径的效果
    // 使用路径效果
    effects[0] = null;
    // 使用CornerPathEffect路径效果
    effects[1] = new CornerPathEffect(10);
    // 初始化DiscretePathEffect
    effects[2] = new DiscretePathEffect(3.0f, 5.0f);
    // 初始化DashPathEffect
    effects[3] = new DashPathEffect(new float[]{20, 10, 5, 10}, phase);
    // 初始化PathDashPathEffect
    Path p = new Path();
    p.addRect(0, 0, 8, 8, Path.Direction.CCW);
    effects[4] = new PathDashPathEffect(p, 12, phase, PathDashPathEffect.Style.ROTATE);
    // 初始化PathDashPathEffect
    effects[5] = new ComposePathEffect(effects[2], effects[4]);
    effects[6] = new SumPathEffect(effects[4], effects[3]);
    // 将画布移到8,8处开始绘制
    canvas.translate(8, 8);
    // 依次使用7中不同路径效果,7种不同的颜色来绘制路径
    for (int i = 0; i < effects.length; i++) {
    mPaint.setPathEffect(effects[i]);
    mPaint.setColor(colors[i]);
    canvas.drawPath(path, mPaint);
    canvas.translate(0, 200);
    }
    // 改变phase值,形成动画效果
    phase += 1;
    invalidate();
    }

    5.setShadowLayer(float radius, float dx, float dy, int shadowColor)

    阴影制作:包括各种形状(矩形,圆形等等),以及文字等等都能设置阴影。


        private void drawShadowLayer(Canvas canvas) {
    // 建立Paint 物件
    Paint paint1 = new Paint();
    paint1.setTextSize(100);
    // 设定颜色
    paint1.setColor(Color.BLACK);
    // 设定阴影(柔边, X 轴位移, Y 轴位移, 阴影颜色)
    paint1.setShadowLayer(10, 5, 5, Color.GRAY);
    // 实心矩形& 其阴影
    canvas.drawText("我爱你", 20,100,paint1);
    Paint paint2 = new Paint();
    paint2.setTextSize(100);
    paint2.setColor(Color.GREEN);
    paint2.setShadowLayer(10, 6, 6, Color.GRAY);
    canvas.drawText("你真傻", 20,200,paint2);

    //cx和cy为圆点的坐标
    int radius = 80;
    int offest = 40;
    int startX = radius + offest;
    int startY = radius + offest + 200;

    Paint paint3 = new Paint();
    //如果不关闭硬件加速,setShadowLayer无效
    setLayerType(LAYER_TYPE_SOFTWARE, null);
    paint3.setShadowLayer(20, -20, 10, Color.DKGRAY);
    canvas.drawCircle(startX, startY, radius, paint3);
    paint3.setStyle(Paint.Style.STROKE);
    paint3.setStrokeWidth(5);
    canvas.drawCircle(startX + radius * 2 + offest, startY, radius, paint3);
    }

    6.setXfermode(Xfermode xfermode)

    Xfermode国外有大神称之为过渡模式,这种翻译比较贴切但恐怕不易理解,大家也可以直接称之为图像混合模式,因为所谓的“过渡”其实就是图像混合的一种,这个方法跟我们上面讲到的setColorFilter蛮相似的。查看API文档发现其果然有三个子类:AvoidXfermode, PixelXorXfermode和PorterDuffXfermode,这三个子类实现的功能要比setColorFilter的三个子类复杂得多。

    由于AvoidXfermode, PixelXorXfermode都已经被标注为过时了,所以这次主要研究的是仍然在使用的PorterDuffXfermode:

    PorterDuffXfermode

    该类同样有且只有一个含参的构造方法PorterDuffXfermode(PorterDuff.Mode mode),虽说构造方法的签名列表里只有一个PorterDuff.Mode的参数,但是它可以实现很多酷毙的图形效果!!而PorterDuffXfermode就是图形混合模式的意思,其概念最早来自于SIGGRAPH的Tomas Proter和Tom Duff,混合图形的概念极大地推动了图形图像学的发展,延伸到计算机图形图像学像Adobe和AutoDesk公司著名的多款设计软件都可以说一定程度上受到影响,而我们PorterDuffXfermode的名字也来源于这俩人的人名组合PorterDuff,那PorterDuffXfermode能做些什么呢?我们先来看一张API DEMO里的图片:

    这张图片从一定程度上形象地说明了图形混合的作用,两个图形一圆一方通过一定的计算产生不同的组合效果,在API中Android为我们提供了18种(比上图多了两种ADD和OVERLAY)模式:

    ADD:饱和相加,对图像饱和度进行相加,不常用

    CLEAR:清除图像

    DARKEN:变暗,较深的颜色覆盖较浅的颜色,若两者深浅程度相同则混合

    DST:只显示目标图像

    DST_ATOP:在源图像和目标图像相交的地方绘制【目标图像】,在不相交的地方绘制【源图像】,相交处的效果受到源图像和目标图像alpha的影响

    DST_IN:只在源图像和目标图像相交的地方绘制【目标图像】,绘制效果受到源图像对应地方透明度影响

    DST_OUT:只在源图像和目标图像不相交的地方绘制【目标图像】,在相交的地方根据源图像的alpha进行过滤,源图像完全不透明则完全过滤,完全透明则不过滤

    DST_OVER:将目标图像放在源图像上方

    LIGHTEN:变亮,与DARKEN相反,DARKEN和LIGHTEN生成的图像结果与Android对颜色值深浅的定义有关

    MULTIPLY:正片叠底,源图像素颜色值乘以目标图像素颜色值除以255得到混合后图像像素颜色值

    OVERLAY:叠加

    SCREEN:滤色,色调均和,保留两个图层中较白的部分,较暗的部分被遮盖

    SRC:只显示源图像

    SRC_ATOP:在源图像和目标图像相交的地方绘制【源图像】,在不相交的地方绘制【目标图像】,相交处的效果受到源图像和目标图像alpha的影响

    SRC_IN:只在源图像和目标图像相交的地方绘制【源图像】

    SRC_OUT:只在源图像和目标图像不相交的地方绘制【源图像】,相交的地方根据目标图像的对应地方的alpha进行过滤,目标图像完全不透明则完全过滤,完全透明则不过滤

    SRC_OVER:将源图像放在目标图像上方

    XOR:在源图像和目标图像相交的地方之外绘制它们,在相交的地方受到对应alpha和色值影响,如果完全不透明则相交处完全不绘制


    public class PorterDuffView extends View {

    Paint mPaint;
    Context mContext;
    int BlueColor;
    int PinkColor;
    int mWith;
    int mHeight;
    public PorterDuffView(Context context) {
    super(context);
    init(context);
    }
    public PorterDuffView(Context context, AttributeSet attrs) {
    super(context, attrs);
    init(context);
    }

    public PorterDuffView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init(context);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    mHeight = getMeasuredHeight();
    mWith = getMeasuredWidth();
    }

    private void init(Context context) {
    mContext = context;
    BlueColor = ContextCompat.getColor(mContext, R.color.colorPrimary);
    PinkColor = ContextCompat.getColor(mContext, R.color.colorAccent);
    mPaint = new Paint();
    mPaint.setStyle(Paint.Style.FILL);
    mPaint.setAntiAlias(true);
    }
    private Bitmap drawRectBm(){
    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(BlueColor);
    paint.setStyle(Paint.Style.FILL);
    paint.setAntiAlias(true);
    Bitmap bm = Bitmap.createBitmap(200,200, Bitmap.Config.ARGB_8888);
    Canvas cavas = new Canvas(bm);
    cavas.drawRect(new RectF(0,0,70,70),paint);
    return bm;
    }
    private Bitmap drawCircleBm(){
    Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    paint.setColor(PinkColor);
    paint.setStyle(Paint.Style.FILL);
    paint.setAntiAlias(true);
    Bitmap bm = Bitmap.createBitmap(200,200, Bitmap.Config.ARGB_8888);
    Canvas cavas = new Canvas(bm);
    cavas.drawCircle(70,70,35,paint);
    return bm;
    }
    @Override
    protected void onDraw(Canvas canvas) {
    mPaint.setFilterBitmap(false);
    mPaint.setStyle(Paint.Style.FILL);
    mPaint.setTextSize(20);
    RectF recf = new RectF(20,20,60,60);
    mPaint.setColor(BlueColor);
    canvas.drawRect(recf,mPaint);
    mPaint.setColor(PinkColor);
    canvas.drawCircle(100,40,20,mPaint);
    @SuppressLint("WrongConstant") int sc = canvas.saveLayer(0, 0,mWith,mHeight, null, Canvas.MATRIX_SAVE_FLAG |
    Canvas.CLIP_SAVE_FLAG |
    Canvas.HAS_ALPHA_LAYER_SAVE_FLAG |
    Canvas.FULL_COLOR_LAYER_SAVE_FLAG |
    Canvas.CLIP_TO_LAYER_SAVE_FLAG);
    int y = 180;
    int x = 50;
    for(PorterDuff.Mode mode : PorterDuff.Mode.values()){
    if(y >= 900){
    y = 180;
    x += 200;
    }
    mPaint.setXfermode(null);
    canvas.drawText(mode.name(),x + 100,y,mPaint);
    canvas.drawBitmap(drawRectBm(),x,y,mPaint);
    mPaint.setXfermode(new PorterDuffXfermode(mode));
    canvas.drawBitmap(drawCircleBm(),x,y,mPaint);
    y += 120;
    }
    mPaint.setXfermode(null);
    // 还原画布
    canvas.restoreToCount(sc);
    }
    }

    收起阅读 »

    android音视频基础

    一、编码目的编码的目的:压缩,各种音视频的编码方式就是为了让视频体积更小,有利于存储和传输。编码的核心四想就是去除冗余信息。二、编码思路1.空间冗余图像内部相邻元素之间存在较强的相关性,造成信息的冗余。(一块区域颜色一样)2.时间冗余相邻视频帧具有较大的相关性...
    继续阅读 »

    一、编码目的

    编码的目的:压缩,各种音视频的编码方式就是为了让视频体积更小,有利于存储和传输。编码的核心四想就是去除冗余信息。

    二、编码思路

    1.空间冗余

    图像内部相邻元素之间存在较强的相关性,造成信息的冗余。(一块区域颜色一样)

    2.时间冗余

    相邻视频帧具有较大的相关性,造成信息的冗余。(第一帧和第二帧绝大多数数据一样)

    3. 视觉冗余

    人类不敏感的信息可以去除。(红色偏点橘色)

    4.信息熵冗余 == 熵编码-哈夫曼算法

    也称编码冗余,人们用于表达某一信息所需要的比特数总比理论上表示该信息所需要的最少比特数要大,它们之间的差距就是信息熵冗余,或称编码冗余。

    5.知识冗余 == 人类(头 身体 腿),汽车,房子 不需要记录

    是指在有些图像中还包含与某些验证知识有关的信息。

    6.I帧、P帧、B帧压缩思路

    I帧:帧内编码帧,关键帧,I帧可以看作一个图像经过压缩之后的产物,可以单独解码出一个完整的图像;(压缩率最低)

    P帧:前向预测/参考 编码帧,记录了本帧跟之前的一个关键帧(或P帧)的差别,解码时需要用之前缓存的画面叠加上本帧定义的差别,生成最终画面。 (压缩率比I帧高,比B帧低 属于 适中情况)

    B帧:双向预测/参考 编码帧,记录了本帧与前后帧的差别,解码需要参考前面一个I帧或者P帧,同时也需要后面的P帧才能解码一张完整的图像。 (参考前后的预测得到的,压缩率是最高,但是耗时)

    image.png

    三、编码标准

    1.组织

    • 国际电信联盟:H.264、H.265
    • MPEG系列标准:MPEG1、MPEG2、MPEG4、AVC

    AVC == H.264

    HEVC == H.265

    2.视频编码概念

    通过指定的压缩技术,把某一种视频格式文件,转换成另一种视频文件格式文件的方式。

    3. H.264分层结构(VCL和NAL)

    • VCL

      VCL(viedo coding layer,视频编码层):负责高效的视频内容展示。

      VCL数据:编码处理的输出,被压缩编码后的视频数据序列。

    • NAL

      NAL(Network Abstraction Layer,网络提取层):以网络所要求的恰当方式对数据进行打包传送,是传输层。不管是网络还是本地都需要通过这一层来传输。

    NAL = 一个字节的片头 + 若干的片数据

    image.png

    传输的是NAL

    4. H.264的输出结构

    H.264编码器默认的输出为:起始码+NALU。

    起始码:0x00000001和0x000001

    0x00000001:NALU里有狠多片

    0x000001:NALU里只有一片。

    5.举例分析H.264文件格式。

    image.png

    SPS 序列参数集(记录有多少I帧,多少B帧,多少P帧,帧是如何排列) == 7
    00 00 00 01 670x67 ---> 2进制01100111 ---> 取低五位 00000111 ---> 十六进制 0x07


    PPS 图像参数集(图像宽高信息等) == 8
    00 00 00 01 68, 0x68 ---> 2进制01101000---> 取低五位 00001000 ---> 十六进制 0x08


    SEI补充信息单元(可以记录坐标信息,人员信息, 后面解码的时候,可以通过代码获取此信息)https://blog.csdn.net/y601500359/article/details/80943990
    00 00 01 06 , 0x06 ---> 2进制00000110---> 取低五位00000110 ---> 十六进制 0x06

    I帧
    00 00 00 65, 0x65 ---> 2进制01100101---> 取低五位00000101 ---> 十六进制 0x05
    最终是 5 I帧完整画面出来

    P帧
    61 -->0x01 重要P帧
    41 -->0x01 非重要P帧

    B帧
    01 -->0x01 B帧

    image.png

    6.PTS和DTS

    DTS:解码时间戳,在什么时候解码这一帧的数据。

    PTS:显示时间戳,在什么时候显示这一帧数据。

    在没有B帧的时候,DTS和PTS是一样的顺序。

    因为B帧的解码需要靠前一帧和后一帧,只要有B帧DTS和PTS就一定会乱。

    image.png

    GOP:I帧+ 下一个I帧之前的所有B帧和P帧。

    i帧=GOP是什么理解的?
    SPS PPS I P B P B P B P B I 一组   SPS PPS I P B P B P B P B I 二组
    收起阅读 »

    Android 是怎么捕捉 java 异常的

    val default = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { t, e ->    //...
    继续阅读 »
     val default = Thread.getDefaultUncaughtExceptionHandler()

    Thread.setDefaultUncaughtExceptionHandler { t, e ->
       // 处理异常
       Log.e("Uncaught", "exception message : "+ e.message)
       // 将异常回执给原注册的 handler
       default.uncaughtException(t, e)
    }

    以上是很简单的一段代码,经常被用于 java 异常全局捕捉,但我的疑问是,他是怎么实现全局捕捉的,带着这样的疑问,我们来扒一下代码看看。


    顺藤摸瓜,我们看看静态方法 getDefaultUncaughtExceptionHandler 是被谁调用的,看了下所有的类调用的类,唯有 ThreadGroup 最靠谱:


    image.png


    在 parent 为空的情况下,就会调用 getDefaultUncaughtExceptionHandler 来回调异常,然后继续顺藤摸瓜,看看 ThreadGroup 的 uncaughtException 是被谁触发的,搜了一个圈,没有一个靠谱的。在我踌躇时,顺带瞄了一眼注释,奇迹发现:


    -   Called by the Java Virtual Machine when a thread in this
    - thread group stops because of an uncaught exception, and the thread
    - does not have a specific {[@link ](/link%20)Thread.UncaughtExceptionHandler}
    - installed.

    意思是:当一个未捕获的异常导致线程组中的线程停止时,JVM 会调用该方法。那我们就去搜搜 jvm 的源码,看看是怎么触发这个方法的。


    在 Hotspot 虚拟机源码的 thread.cpp 中的 JavaThread::exit 方法发现了这样的一段代码,并且还给出了注释:


    image.png


    在线程调用 exit 退出时,如果有未捕获的异常,则会调用 Thread.dispatchUncaughtException 方法,然后我们继续跟踪该方法:


    image.png


    然后调用当前线程的 uncaughtException 分发异常:


    image.png


    有意思的来了,如果我们没有给当前线程设置 UncaughtExceptionHandler ,则会将这个异常交给当前线程的 ThreadGroup 处理。如果我们给当前线程设置了 UncaughtExceptionHandler,则当前线程发生了异常,永远也不会抛给 getDefaultUncaughtExceptionHandler,该功能适合捕捉当前线程异常来用。


    终于回到了我们起初看到的 ThreadGroup.UncaughtExceptionHandler 方法,贴回原来的图继续分析:


    image.png


    这个地方会继续判断 parent 是否为空,parent 是个 ThreadGroup,ThreadGroup 实现了 Thread.UncaughtExceptionHandler 接口。这里我就直接说答案了,后面再说 ThreadGroup 和 Thread 的关系,最终会走到 system 的 ThreadGroup,system 的 parent 是个空,这时候走 else 分支,获取 Thread 中的 getDefaultUncaughtExceptionHandler 静态变量,触发 uncaughtException 方法,由于我们在 Activity 中设置了这个静态变量,所以,我们收到了这个异常通知。


    小知识


    1、如何捕获异常不退出


    val default = Thread.getDefaultUncaughtExceptionHandler()

    Log.e("Uncaught", "Uncaught handler: "+ default)
    // Uncaught handler: com.android.internal.os.RuntimeInit$KillApplicationHandler@21f02a3

    Thread.setDefaultUncaughtExceptionHandler { t, e ->
       // 将异常回执给原注册的 handler
       // default.uncaughtException(t, e)
    }

    捕获异常后,什么都不处理。但这样做显得非常不地道,这样会导致其他框架无法通过之前设置的静态变量捕获到异常上报。我打印了一下 default 是 RuntimeInit,该类在捕获到异常后,会做 killProcess。


    2、如何捕获指定线程异常:


    val thread = Thread {
         val a = 1/0
    }
    thread.setUncaughtExceptionHandler { t, e ->
           Log.e("Uncaught", "Uncaught trace: "+ e.message)
    }
    thread.start()

    3、ThreadGroup 和 Thread 的关系结构


    image.png



    • Thread 的 parent 是在 new Thread 的时候指定的,构造可传自定义的 ThreadGroup,默认是使用创建当前线程的 ThreadGroup

    • Thread 添加进 ThreadGroup 的 Thread[] 数组时机是在调用 start 启动线程的时候做的

    • ThreadGroup 的 parent 是在 new ThreadGroup 的时候指定的,构造可传自定义的 ThreadGroup,默认是使用当前线程的 ThreadGroup

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

    iOS RXSwift 4.7

    iOS
    ReplaySubjectReplaySubject 将对观察者发送全部的元素,无论观察者是何时进行订阅的。这里存在多个版本的 ReplaySubject,有的只会将最新的 n 个元素发送给观察者,有的只会将限制时间段内最新的元素发送给观察...
    继续阅读 »

    ReplaySubject

    ReplaySubject 将对观察者发送全部的元素,无论观察者是何时进行订阅的。

    这里存在多个版本的 ReplaySubject,有的只会将最新的 n 个元素发送给观察者,有的只会将限制时间段内最新的元素发送给观察者。

    如果把 ReplaySubject 当作观察者来使用,注意不要在多个线程调用 onNextonError 或 onCompleted。这样会导致无序调用,将造成意想不到的结果。


    演示

    let disposeBag = DisposeBag()
    let subject = ReplaySubject<String>.create(bufferSize: 1)

    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: 2 Event: next(🐱)
    Subscription: 1 Event: next(🅰️)
    Subscription: 2 Event: next(🅰️)
    Subscription: 1 Event: next(🅱️)
    Subscription: 2 Event: next(🅱️)

    BehaviorSubject

    当观察者对 BehaviorSubject 进行订阅时,它会将源 Observable 中最新的元素发送出来(如果不存在最新的元素,就发出默认元素)。然后将随后产生的元素发送出来。

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


    演示

    let disposeBag = DisposeBag()
    let subject = BehaviorSubject(value: "🔴")

    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("🅱️")

    subject
    .subscribe { print("Subscription: 3 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(🅰️)
    Subscription: 1 Event: next(🅱️)
    Subscription: 2 Event: next(🅱️)
    Subscription: 3 Event: next(🅱️)
    Subscription: 1 Event: next(🍐)
    Subscription: 2 Event: next(🍐)
    Subscription: 3 Event: next(🍐)
    Subscription: 1 Event: next(🍊)
    Subscription: 2 Event: next(🍊)
    Subscription: 3 Event: next(🍊)

    Variable (已弃用)

    Variable 是早期添加到 RxSwift 的概念,通过 “setting” 和 “getting”, 他可以帮助我们从原先命令式的思维方式,过渡到响应式的思维方式

    但这只是我们一厢情愿的想法。许多开发者滥用 Variable,来构建 重度命令式 系统,而不是 Rx 的 声明式 系统。这对于新手很常见,并且他们无法意识到,这是代码的坏味道。所以在 RxSwift 4.x 中 Variable 被轻度弃用,仅仅给出一个运行时警告。

    在 RxSwift 5.x 中,他被官方的正式的弃用了,并且在需要时,推荐使用 BehaviorRelay 或者 BehaviorSubject


    ControlProperty

    ControlProperty 专门用于描述 UI 控件属性的,它具有以下特征:

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

    Kotlin协程实现原理概述

    协程的顶层实现-CPS 现有如下代码: fun test(a: Int, b: Int) { // 求和 var result = a + b // 乘以2 result = result shl 1 // 加2 ...
    继续阅读 »

    协程的顶层实现-CPS


    现有如下代码:


    fun test(a: Int, b: Int) {
    // 求和
    var result = a + b
    // 乘以2
    result = result shl 1
    // 加2
    result += 2
    // 打印结果
    println(result)
    }

    我们来将代码SRP一下(单一职责):


    // 加法
    fun sum(a: Int,b: Int) = a + b
    // x乘以2
    fun double(x: Int) = x shl 1
    // x加2
    fun add2(x: Int) = x + 2

    // 最终的test
    fun test(a: Int, b: Int) {
    // 从内层依次调用,最终打印
    println(add2(double(sum(a,b))))
    }

    可以看到,我们将原来一坨的方法,抽离成了好几个方法,每个方法干一件事,虽然提高了可读性和可维护性,但是代码复杂了,我们来让它更复杂一点。


    上述代码是 让内层方法的返回值 作为参数 传递给外层方法,现在我们 把外层方法作为接口回调 传递给 内层方法:


    // 加法,next是加法做完的回调,会传入相加的结果
    fun sum(a: Int, b: Int, next: (Int) -> Unit) = a + b
    // x乘以2
    fun double(x: Int, next: (Int) -> Unit) = x shl 1
    // x加2
    fun add2(x: Int, next: (Int) -> Unit) = x + 2

    // 最终的test
    fun test2(a: Int, b: Int) {
    // 执行加法
    sum(a, b) { sum ->
    // 加完执行乘法
    double(sum) { double ->
    // 乘完就加2
    add2(double) { result ->
    // 最后打印
    println(result)
    }
    }
    }
    }

    这就是CPS的代码风格:通过接口回调的方式来实现的


    假设: 我们上述的几个方法: sum()/double()/add2()都是挂起函数,那么最终也会编译为CPS风格的回调函数方式,也就是:原来看起来同步的代码,经过编译器的"修改",变成了异步的方法,也就是:CPS化了,这就是kotlin协程的顶层实现逻辑。


    现在,让我们来验证一下,我们定义一个suspend函数,反编译看下是否真的CPS化了。


    // 定义挂起函数
    suspend fun test(id: String): String = "hello"

    反编译结果如下:


    // 参数添加了一个Continuation参数
    public final Object test(@NotNull String id, @NotNull Continuation $completion) {
    return "hello";
    }

    可以看到,多了个Continuation参数,这是个接口,是在本次函数执行完毕后执行的回调,内容如下:


    public interface Continuation<in T> {
    // 保存上下文(比如变量状态)
    public val context: CoroutineContext

    // 方法执行结束的回调,参数是个范型,用来传递方法执行的结果
    public fun resumeWith(result: Result<T>)
    }

    好,现在我们知道了suspend函数 是通过添加Continuation来实现的,我们来看个具体的业务:


    // 根据id获取token
    suspend fun getToken(id: String): String = "token"

    // 根据token获取info
    suspend fun getInfo(token: String): String = "info"

    // 测试
    suspend fun test() {
    // 先获取token,这是耗时请求
    val token = getToken("123")
    // 再根据token获取info,这也是个耗时请求
    val info = getInfo(token)
    // 打印
    println(info)
    }

    上述的业务代码很简单,但是前两步都是耗时操作,线程会卡在那里wait吗?显然不会,既然是suspend函数,那么就可以CPS化,等价的CPS代码如下:


    // 跟上述相同,传递了Continuation回调
    fun getToken(id: String, callback: Continuation<String>): String = "token"

    // 跟上述相同,传递了Continuation回调
    fun getInfo(token: String, callback: Continuation<String>): String = "info"

    // 测试(只写了主线代码)
    fun test() {
    // 先获取token,传入回调
    getToken("123", object : Continuation<String> {
    override fun resumeWith(result: Result<String>) {
    // 用token获取info,传入回调
    val token = result.getOrNull()
    getInfo(token!!, object : Continuation<String> {
    override fun resumeWith(result: Result<String>) {
    // 打印结果
    val info = result.getOrNull()
    println(info)
    }
    })
    }
    })
    }

    上述就是无suspend的CPS风格代码,通过传入接口回调来实现协程的同步代码风格。


    接下来我们来反编译suspend风格代码,看下它里面是怎么调度的。


    协程的底层实现-状态机


    我们先来简单修改下suspend test函数:


    // 没变化
    suspend fun getToken(id: String): String = "token"
    // 没变化
    suspend fun getInfo(token: String): String = "info"

    // 添加了局部变量a,看下suspend怎么保存a这个变量
    suspend fun test() {
    val token = getToken("123") // 挂起点1
    var a = 10 // 这里是10
    val info = getInfo(token) // 挂起点2,需要将前面的数据保存(比如a),在挂起点之后恢复
    println(info)
    println(a
    }

    每个suspend函数调用点,都会生成一个挂起点,在挂起点我们要保存当前的运行状态,比如局部变量等。


    反编译后的代码大致如下:


    public final Object getToken(String id, Continuation completion) {
    return "token";
    }

    public final Object getInfo(String token, Continuation completion) {
    return "info";
    }

    // 重点函数(伪代码)
    public final Object test(Continuation<String>: continuation) {
    Continuation cont = new ContinuationImpl(continuation) {
    int label; // 保存状态
    Object result; // 保存中间结果,还记得那个Result<T>吗,是个泛型,因为泛型擦除,所以为Object,用到就强转
    int tempA; // 保存上下文a的值,这个是根据具体代码产生的
    };
    switch(cont.label) {
    case 0 : {
    cont.label = 1; //更新label

    getToken("123",cont) // 执行对应的操作,注意cont,就是传入的回调
    break;
    }

    case 1 : {
    cont.label = 2; // 更新label

    // 这是一个挂起点,我们要保存上下文数据,这里就保存a的值
    int a = 10;
    cont.tempA = a; // 保存a的值

    // 获取上一步的结果,因为泛型擦除,需要强转
    String token = (Object)cont.result;
    getInfo(token, cont); // 执行对应的操作
    break;
    }

    case 2 : {
    String info = (Object)cont.result; // 获取上一步的结果
    println(info); // 执行对应的操作

    // 在挂起点之后,恢复a的值
    int a = cont.tempA;
    println(a);

    return;
    }
    }
    }

    我们可以将每个case理解为一个状态,每个case分支对应的语句,理解为一个Continuation实现。


    上述伪代码大致描述了协程的调度流程:



    • 1 调用test函数时,需要传入一个Continuation接口,我们会对它进行二次装饰。

    • 2 装饰就是根据函数具体逻辑,在内部添加额外的上下文数据和状态信息(也就是label)。

    • 3 每个状态对应一个Continuation接口,里面会执行对应的业务逻辑。

    • 4 每个状态都会: 保存上下文信息 -> 获取上一个状态的结果 -> 执行本状态业务逻辑 -> 恢复上下文信息。

    • 5 直到最后一个状态对应的逻辑执行完毕。


    总结


    综上,我们可以归纳以下几点:



    • 1 Kotlin协程没有很"频繁"的切换线程,它是在顶层通过调度方式实现的,所以效率是比较高的。

    • 2 Kotlin中,每个suspend方法,都需要一个Continuation接口实现,用来执行下一个状态的操作;并且,每个suspend方法的调用点都会产生一个挂起点。

    • 3 每个挂起点,都会产生一个label,对应于状态机的一个状态,不同的状态之间,通过Continuation来切换。

    • 4 Kotlin协程会在每个挂起点保存当前的上下文数据,并且在挂起点之后进行恢复。这样,每个状态之间就是相互独立的,可以独立调度。

    • 5 协程的切换,只不过是从一种状态切换到另一种状态,因为不同状态是相互独立的,所以在合适的时机,再切换回来也不会对结果造成影响。

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

    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 章节详细介绍。

    收起阅读 »