[KF-005] Flutter 中是如何比较对象是否相等的

739 阅读7分钟

对象相等的问题有点像八股文了,但是确实有必要了解,它在某些情况下会导致意外的 bug 产生,比如:

  • 状态对象修改属性后没有触发状态管理框架的刷新;
  • 列表、集合、字典,增加删除修改之后,没有触发状态管理框架的刷新​。
  • 测试意外的失败;

判断方法

主要有两种,一种是常见的 == 运算符来判断,一种是来自 dart 语言提供的 identical 函数,下面会依次介绍。

== 运算符

从源码中 external bool operator ==(Object other); 和 external int get hashCode; 的注释可以提出以下几个关键点:

  1. 默认情况下,当且仅当比较的对象是同一个类的时候,返回 true ;
  2. == 运算符和 hashCode 方法应该被同时重写,以便保持对象相等判断的一致性
  3. 允许存在 hashCode 相同的多个对象,但是哈希冲突太频繁会导致集合和字典这类基于哈希的数据结构效率降低;

给出 dart 源码中相关的部分对照参考:

DEFINE_NATIVE_ENTRY(AbstractType_getHashCode, 0, 1) {
  const AbstractType& type =
      AbstractType::CheckedHandle(zone, arguments->NativeArgAt(0));
  intptr_t hash_val = type.Hash();
  ASSERT(hash_val > 0);
  ASSERT(Smi::IsValid(hash_val));
  return Smi::New(hash_val);
}

DEFINE_NATIVE_ENTRY(AbstractType_equality, 0, 2) {
  const AbstractType& type =
      AbstractType::CheckedHandle(zone, arguments->NativeArgAt(0));
  const Instance& other =
      Instance::CheckedHandle(zone, arguments->NativeArgAt(1));
  if (type.ptr() == other.ptr()) {
    return Bool::True().ptr();
  }
  return Bool::Get(type.IsEquivalent(other, TypeEquality::kSyntactical)).ptr();
}

其实只有 15 行重要,对应了刚刚说的第一点默认情况下的返回,就是说默认情况下对象相等的底层判断是判断对象指针是否指向同一个地址(同一个类)。

而 hashCode 和 == 的一致性没有很必然的约束,也可能是我没有找到相关的代码。猜测这里的一致性是一种编码规范的约束,也就是说为了编码规范和程序设计的规范,我们需要:

  1. 使用 hashCode 代表一个对象的唯一 id,用于哈希算法中作为哈希碰撞的 id;
  2. 对象的细节是否相等,需要重写 == 实现;
  3. hashCode 和 == 都是根据对象的状态得出的,所以需要保持 “一致性”

identical 函数

这是一种特殊的对象比较方法,和深拷贝浅拷贝一样,== 是一种 “深比较” ,而这里的 identical 函数是一种 “浅比较” 。换言之,比较的是对象的引用是否相等,或者 c 语言中的比较指针是否相等。

可能会有小伙伴好奇,为什么还需要一个这样的方法,这不是很无用吗,实则不然,riverpod 库在文档中给出了理由。下面是省流:

  1. 实际业务的类往往很重,使用重写后的 == 会比较每一个细节,导致效率低;
  2. 状态改变前后,如果没有重写 == ,会导致状态总是被判定为没有刷新过(前面说到的默认情况下相同类型的比较总是返回 true

下面也给出 dart 源码的实现:

DEFINE_NATIVE_ENTRY(Identical_comparison, 0, 2) {
  GET_NATIVE_ARGUMENT(Instance, a, arguments->NativeArgAt(0));
  GET_NATIVE_ARGUMENT(Instance, b, arguments->NativeArgAt(1));
  const bool is_identical = a.IsIdenticalTo(b);
  return Bool::Get(is_identical).ptr();
}
bool Instance::IsIdenticalTo(const Instance& other) const {
  if (ptr() == other.ptr()) return true;
  if (IsInteger() && other.IsInteger()) {
    return Integer::Cast(*this).Equals(other);
  }
  if (IsDouble() && other.IsDouble()) {
    double other_value = Double::Cast(other).value();
    return Double::Cast(*this).BitwiseEqualsToDouble(other_value);
  }
  return false;
}

应该是很容易看懂的,除了 int 和 double 是字面量对比,其他类型都是使用内存的引用来对比,所以直接比较指针的值,除此之外的,判定为 false。

如何使用 == 方法

我们知道似乎我们很需要去重写这两个方法,来使得我的对象在比较的时候可以更加方便,而且可以保证程序运行的可预测性,但是这两个方法,很蠢:每个类都需要去重写,而且这些东西的编写是很无脑的机械的,如果不小心写错了,反而埋下隐患。

这里介绍两种修改对象的比较方法:

freezed

freezed 是和本文很贴切的 dart 库,可以 “冻住” 一个类,即修改为不可变的类,属性不可变,如果要使用 copyWith 方法,就会生成新的类,从根本上解决了对象改变但状态没有改变的情况,本质上是通过注解生成 == 和 hashCode 方法,避免重复且无聊的手写代码。它有一些好处:

  1. 编写代码最少,得到收益最多;
  2. 引入不可变对象的思想,确保对象不会被以外改变;
  3. 还可以和 json_serializable 库配合使用,实现 json 序列化。
// This file is "main.dart"
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'main.freezed.dart';

@freezed
class Person with _$Person {
  const factory Person({
    required String firstName,
    required String lastName,
    required int age,
  }) = _Person;
}

equatable

equatable 是和本文更贴切的 dart 库,专注于解决对象相等的判断问题的,只需要继承并重写 List<Object> get props 方法,注意,是一个 get 方法,在返回的列表里,把所有的属性传入即可。他有一些好处:

  1. 无需代码生成;
  2. 可以颗粒度判断属性相等,避免多余的逻辑判断;
import 'package:equatable/equatable.dart';

class Person extends Equatable {
  const Person(this.name);

  final String name;

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

如何使用 identical 函数

显然的“浅比较”在状态管理框架中有很大的益处,但是并不是所有的状态管理框架都使用它来做状态的比较。这里查找了所有常用状态管理框架中,状态修改导致 UI 刷新的相关代码片段:

常见的状态管理框架的状态相等判断

provider

  void setState(R value) {
    if (_hasValue) {
      final shouldNotify = delegate.updateShouldNotify != null
          ? delegate.updateShouldNotify!(_value as R, value)
          : _value != value;
      if (shouldNotify) {
        element!.markNeedsNotifyDependents();
      }
    }
    _hasValue = true;
    _value = value;
  }

provider 使用第 5 行的 == 来判断,虽然是不等于,但是本质一样。第 4 行的函数判断来自开发者自己传入的更新规则,这个不多讨论。

bloc

  @protected
  @visibleForTesting
  @override
  void emit(State state) {
    try {
      if (isClosed) {
        throw StateError('Cannot emit new states after calling close');
      }
      if (state == _state && _emitted) return;
      onChange(Change<State>(currentState: this.state, nextState: state));
      _state = state;
      _stateController.add(_state);
      _emitted = true;
    } catch (error, stackTrace) {
      onError(error, stackTrace);
      rethrow;
    }
  }

关键函数如上。核心的状态判断是第 9 行,使用的 == 判断。

riverpod

  @protected
  bool updateShouldNotify(State previous, State next) {
    return !identical(previous, next);
  }

关键函数如上。核心的状态判断是使用 identical 函数。

get

简单的状态管理使用直接标脏刷新(难怪说速度快,直接不比较了),注意这里的 filter 的作用,类似于其他状态管理框架的 select 原语,并没有真正的去比较状态。

💡但是这里也声明,getx 这个框架屏蔽了太多的细节,如果出现类似通过 extension 或者抽象泛化的方式,间接的判断了状态的改变,解释权归 getx ,我不对上述的判断负责。

💡当然如果能找到 getx 的简单状态管理中,是否需要刷新 UI 时对状态的判断代码,欢迎在评论区讨论。

  void _filterUpdate() {
    var newFilter = widget.filter!(_controller as T); 
    if (newFilter != _filter) {
      _filter = newFilter;
      getUpdate();
    }
  }

  void getUpdate() {
    _dirty = true;
    markNeedsBuild();
  }

响应式状态管理使用 == 判断状态,最后使用标脏更新 UI。

  set value(T newValue) {
    if (_value == newValue) return;
    _value = newValue;
    _notify();
  }

总结一下各大框架的刷新机制

  1. provider 和 getx 都和 UI 耦合,不提供纯 dart 的状态管理方式,所以刷新代码的方式可以是直接标脏刷新;
  2. 大多数情况下,默认使用 == 来实现对新旧状态改变的判断;
  3. 只有 riverpod 在通知者程序中使用了 identical 来判断状态的修改。

可以看出,各大框架的设计和判断机制是有差异的,但是重写 == 方法可以解决绝大部分问题(因为浅比较的 riverpod 并不受影响,只要你有新的对象作为状态就可以)。

总结

你可能需要根据需求考虑是否需要使用这些库,但是显然的,与其去排查为什么数据修改了对象不刷新,不如让不同的数据对应的状态不再相同,从而使得不同的状态去刷新 UI。