Flutter 中的 SOLID 原则实用指南:O - 开放/封闭原则(OCP)

9 阅读3分钟

在 SOLID 五大设计原则中,"L" 代表 里氏替换原则(Liskov Substitution Principle,LSP),它提出了一个简单却强大的要求:

子类必须能够替换父类,并保持行为的正确性。

也就是说,当一个模块依赖于某个抽象类型时,任何实现这个抽象的具体子类,都应该能在不改变程序逻辑的前提下替换使用。如果替换后出现了异常、逻辑错误或行为不一致,那么你就违反了 LSP。

🧠 一个贴合 Flutter 项目的例子

✅ 场景设定:

我们在开发一个通用的本地存储服务,用于保存用户偏好、缓存数据等。我们先定义一个抽象的接口:

abstract class LocalStorageService {
  Future<void> save(String key, String value);
  Future<String?> read(String key);
  Future<void> delete(String key);
}

这看起来没什么问题。

但某一天你需要实现一个“只读”的本地数据源(比如用于临时切换用户回放的 Mock 数据),于是你这样写:

❌ 违反 LSP 的错误实现

class ReadOnlyStorageService implements LocalStorageService {
  @override
  Future<void> save(String key, String value) {
    throw UnsupportedError('只读模式,不支持保存');
  }

  @override
  Future<String?> read(String key) async {
    return 'preset_value';
  }

  @override
  Future<void> delete(String key) {
    throw UnsupportedError('只读模式,不支持删除');
  }
}

这段代码能编译、能运行,但却存在致命设计问题。

如果有如下调用方代码:

Future<void> updateUsername(LocalStorageService storage) async {
  await storage.save('username', 'flutter_dev');
}

这段逻辑在使用正常的缓存服务时工作正常,但你一旦传入 ReadOnlyStorageService,应用就崩了 —— 这说明它不能被安全替换为父类的类型,这就是违反了里氏替换原则

✅ 正确的设计方式:使用接口拆分

为了满足 LSP,我们要将接口职责区分清楚,让只读存储和可写存储有各自的边界。

abstract class StorageReadable {
  Future<String?> read(String key);
}

abstract class StorageWritable {
  Future<void> save(String key, String value);
  Future<void> delete(String key);
}

标准可读可写缓存:

class SharedPreferencesStorage implements StorageReadable, StorageWritable {
  final SharedPreferences prefs;

  SharedPreferencesStorage(this.prefs);

  @override
  Future<String?> read(String key) async => prefs.getString(key);

  @override
  Future<void> save(String key, String value) async {
    await prefs.setString(key, value);
  }

  @override
  Future<void> delete(String key) async {
    await prefs.remove(key);
  }
}

只读存储服务:

class ReadOnlyStorage implements StorageReadable {
  final Map<String, String> preset;

  ReadOnlyStorage(this.preset);

  @override
  Future<String?> read(String key) async => preset[key];
}

使用方式明确区分依赖:

Future<void> loadUserName(StorageReadable storage) async {
  final name = await storage.read('username');
  print(name);
}

这样无论传入哪种存储实现,调用方都不会出错,接口行为始终一致。这就是 LSP 在 Flutter 项目中的完美体现。

🪄 Riverpod 搭配使用(Bonus)

将读写接口分离之后,配合 Riverpod 可以构建更灵活的依赖注入方案:

final readOnlyStorageProvider = Provider<StorageReadable>((ref) {
  return ReadOnlyStorage({'username': 'mock_user'});
});

final localStorageProvider = Provider<StorageReadable>((ref) {
  final prefs = ref.watch(sharedPreferencesProvider);
  return SharedPreferencesStorage(prefs);
});

这样你就可以在测试时注入 ReadOnlyStorage,在正式逻辑中使用完整实现,而不需要更改任何调用代码,真正实现面向抽象编程。

✅ 总结:LSP 的核心思维

做法是否符合 LSP说明
子类抛出异常替代父类方法❌ 不符合替换后出错,说明子类不具备行为兼容性
拆分接口按功能组合实现✅ 符合子类可以自由组合接口,实现职责内的行为,避免非法替换
接口依赖注入明确职责范围✅ 符合调用方按需依赖读/写接口,避免超出职责范围的误用

在项目复杂化的过程中,如果你发现某个子类“实现接口的时候很别扭”或“某些方法根本不想写”,请警惕 —— 这很可能是你在违反 LSP

记住:

能替换才是真子类,不兼容就是伪继承!

如果你觉得这篇文章对你有启发,欢迎点个 “赞”“在看”,让更多开发者写出优雅、可维护的 Flutter 代码。