在 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 代码。