2024年中,聊聊我的项目中十大代码优化技巧

2,763 阅读13分钟

前言

最近以来,由于工作的变动,接触了太多前辈写的优质代码,越发觉得“干净简洁的代码”已成为良好编程实践的标志。那么如何写出优秀的代码呢?这当然需要我们在工作中不断地实践,不是一蹴而就的;网上也有许多关于这方面的指导和设计原则,但恕笔者直言,过度遵循它们有时也会相互矛盾。比如说,遵循SOLID原则是可以产生高度可测试的代码,但是过度使用它们也许就会把我们的代码被分成太多的小类,从而难以维护和扩展。

笔者平常开发以用于移动应用程序的Kotlin为主,有时也需要使用Python或者Flutter,所以在这里笔者简单分享下在日常开发过程中总结的十大代码技巧,有些是非常小的点,但都是平常容易被自己忽视的,希望可以帮助大家,简单提高下代码质量。

当然我们一定要记住,在所有这些原则和技巧之上,一定要记住一点的是: 在某些情况下,如果你的项目非常着急要上线,代码的效率比其简洁性更重要,等到项目上线后再不断审查、重构和改进你的代码

好了话不多说,Let's go

1.什么时候需要重构

这个怎么说呢,就笔者个人而言,总结了黄金三法则

  • 当我们第一次编码这部分代码的时候,尽管写,按照自己的想法来

  • 如果第二次又写了这部分重复的代码,这时候需要开始警惕了

  • 第三次代码又重复了,事不过三,停下来重构吧

    这个规则可以有助于在过早抽象和过多代码重复之间取得平衡,过早抽象可能导致架构过于复杂,而过多的代码重复则使项目难以维护。这种方法有助于开发可维护的代码,同时避免解决方案设计过度或设计不足的陷阱。

2.简单整理下项目结构

目前来说,最普遍认可的架构之一是Clean Architecture。但是,这并不意味着其他架构或者自己的架构不好。只是现有架构解决了大多数开发人员遇到的常见问题,但架构的选择取决于哪种架构适合我们的开发需求以及我们的团队可以有效支持什么。

一般来说,就笔者做过的项目,无论是团队还是个人的,首先需要注意的是命名的一致性,不要害怕文件名太长。采用一致的命名模式至关重要。名称能让小伙伴们立即了解文件的内容和用途,举些例子:

  • NetworkManagerNetworkHelper进行网络相关操作。

  • MainScreen,OtherScreen表示一些页面屏幕,这是最近Compose项目的命名,或者说之前项目MainActivity,MainFragment这些页面的命名

  • MainViewModelMenuViewModel针对ViewModel

    这样的命名规范让我们能够轻松追踪不同组件之间的关系,例如MainActivityMainScreenMainViewModel 是如何相互关联的。即使它们被放在不同的包下面。

3.代码注释的合理性

曾几何时,听到别人说过这样一句话:"优秀的程序员往往不需要写注释,一旦你的代码需要加上注释的时候,它肯定是糟糕的代码" 。但是,如果公司的项目已经维护了很多年了,代码量越来越多,那肯定是需要对一些重要的代码块进行注释,比如说某些复杂的算法,复杂的逻辑等。当然,并不是每一行都需要进行注释

什么时候进行注释?
  • 如果方法和一些属性字段的名称不够直观

  • 一些复杂的算法逻辑

  • 与一些第三方库的集成,比如说使用了Retrofit进行网络请求,但并不意味者需要整个记录下,但是如果在使用的时候编写了另外的拦截器,最好给它起一个解释性的名称,并添加一个简短的注释,说明它的作用以及何时需要添加

  • 还有的时候,我们的代码可能包含一些针对特殊bug的解决方案,再查阅一些资料解决后,这时候可以添加上解决的错误报告链接;例如,下面是笔者在自己项目中解决了弹窗出现的宽高问题并且贴出了相关查阅资料链接

    //https://stackoverflow.com/questions/4668939/viewgrouptextview-getmeasuredheight-gives-wrong-value-is-smaller-than-real
    val widthMeasureSpec = getWidthMeasureSpec()
    val heightMeasureSpec = getHeightMeasureSpec()
    rootView.measure(widthMeasureSpec, heightMeasureSpec)
    

这时有同学就会说了,我们编写代码的时候不是应该整洁点,这样才能保证代码的清晰,方便理解。但是这肯定不是绝对的,有时候在数据加密或者自定义硬件集成等等复杂系统里,即使编写良好的代码也可能非常复杂。这个时候使用注释,在代码本身不够清晰难以理解的地方提供一些见解,增加可读性。例如这样一段代码

data class HardwareConnection (val ip: String, val port: Int) { 
    /** 
     * 发送命令关闭连接。如果连接未关闭,
     * 它将会在最后一个命令发送后阻塞设备 60 秒
     。此阻塞发生在设备端。
     */ 
    fun sendCommand (command: String ) { 
        val connection = HardwareSDK.connect(ip, port) 
        connection.sendCommand(command) 
        connection.close() 
    } 
}

但是,过多的注释(尤其是那些显而易见的语句)会使代码变得杂乱,降低代码的可读性,没有必要对标准库调用或非常基本的操作进行注释。总而言之,言而总之,关键是要找到一个最佳点,让每条注释都有明确的目的,而不会让代码变得过于繁琐。

最好不要像下面这么做

// 定义一个变量来存储用户的年龄
var userAge: Int = 25class UserManager(private val database: Database) { 
    fun createUser (user: User) { 
        // 将用户保存到数据库
        database.save(user) 
    } 
​
    fun deleteUser (userId: String) { 
        // 从数据库中删除用户
        database.delete(userId) 
    } 
}

4.适当的时候,限制全局对象和单例的使用

在开发应用的时候,我们经常需要管理在整个应用程序生命周期中存在的值或实例对象,当然使用单例模式来管理这些数据无疑是非常简单的,但是盲目的使用单例或全局对象可能会导致如下这些问题

  • 无法保证最新状态值:如果你存储了一个上下文对象或运行时的权限,它们的值可能会随着时间改变,此时,如果是全局变量的话,无法保证它始终是最新值
  • 调试困难: 在全局变量中模拟复杂状态是比较困难的,测试的时候可能会互相干扰,这可能导致不正确的测试结果
  • 并发访问问题:有可能会导致应用崩溃
  • 初始化问题: 如果App退出重新启动的时候,无法保证全局状态中的所有变量和对象都会被正确初始化,可能会导致空指针异常,即便在Kotlin中使用不可空的字段
替代方案

当然完全避免使用单例和全局对象是不现实的,在这之前分析一下当前场景,确保我们真的需要全局状态或单例

  • 如果数据只在特定的Activity,Fragment中,应该绑定到当前界面的生命周期中

    class MyActivity : AppCompatActivity() {
        private lateinit var viewModel: MyViewModel
    ​
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            viewModel = ViewModelProvider(this).get(MyViewModel::class.java)
            // 数据绑定到 ViewModel 的生命周期
        }
    }
    ​
    
  • 如果需要在重启应用的时候保持当前状态值,可以考虑使用SP或者数据库

    class PreferencesHelper(context: Context) {
        private val sharedPreferences = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
    ​
        fun saveData(key: String, value: String) {
            sharedPreferences.edit().putString(key, value).apply()
        }
    ​
        fun getData(key: String): String? {
            return sharedPreferences.getString(key, null)
        }
    }
    ​
    
  • 如果有后台进程定期从后端获取新数据,并且需要传递到 UI,这些数据可以存储在数据库中 ,并使用冷流或热流,随着数据的变化来发出更新

    class DataRepository(private val database: AppDatabase) {
        val dataFlow: Flow<List<Data>> = database.dataDao().getAllDataFlow()
    ​
        suspend fun fetchDataFromBackend() {
            // 从后端获取数据并存储到数据库
        }
    }
    

    通过这些方法,可以减少全局状态带来的问题,增强代码的可维护性和测试性。当然有同学就又会说了,说了这么多场景避免使用全局对象或单例,那么,什么时候适合使用呢?

    适合使用单例的情况
    • 1.应用程序级别的配置: 一些配置信息需要在整个应用程序中共享,例如网络配置、日志配置等。这些配置通常在应用程序启动时初始化,并且在应用程序运行期间保持不变。

      object AppConfig {
          const val BASE_URL = "https://api.example.com"
          const val TIMEOUT = 30L
      }
      
    • 2.全局服务或管理器: 某些服务或管理器在整个应用程序中都需要使用,例如网络请求管理器、数据库管理器或共享的资源管理器。

      object NetworkManager {
          private val retrofit = Retrofit.Builder()
              .baseUrl(AppConfig.BASE_URL)
              .build()
      ​
          fun getApiService(): ApiService {
              return retrofit.create(ApiService::class.java)
          }
      }
      
    • 3.缓存: 需要在应用程序中全局共享的缓存数据,如图像缓存、数据缓存等。

      object ImageCache {
          private val cache = mutableMapOf<String, Bitmap>()
      ​
          fun getImage(url: String): Bitmap? {
              return cache[url]
          }
      ​
          fun putImage(url: String, bitmap: Bitmap) {
              cache[url] = bitmap
          }
      }
      
    • 4.跨模块共享的对象: 某些对象需要在多个模块之间共享,如分析工具、跟踪工具等。

      object Analytics {
          fun trackEvent(event: String) {
              // 发送事件到分析服务器
          }
      }
      
    适合使用全局对象的情况
    • 全局应用程序状态: 有些状态需要在整个应用程序中保持,例如用户登录状态、当前主题设置等。

      class UserSession {
          var isLoggedIn: Boolean = false
          var userName: String? = null
      ​
          companion object {
              val instance = UserSession()
          }
      }
      
    • 事件总线: 当需要在不同组件之间传递事件时,可以使用事件总线(EventBus)模式。事件总线通常是一个全局对象。

      object EventBus {
          private val listeners = mutableListOf<(Event) -> Unit>()
      ​
          fun register(listener: (Event) -> Unit) {
              listeners.add(listener)
          }
      ​
          fun unregister(listener: (Event) -> Unit) {
              listeners.remove(listener)
          }
      ​
          fun post(event: Event) {
              listeners.forEach { it(event) }
          }
      }
      
    • 应用程序生命周期事件: 需要在应用程序生命周期内管理的对象,如应用程序级别的广播接收器。

      class MyApplication : Application() {
          override fun onCreate() {
              super.onCreate()
              // 注册全局广播接收器
          }
      }
      

在使用单例或全局对象时,应仔细评估其必要性和潜在影响。虽然它们在某些情况下是有用的,但过度使用可能导致代码难以维护和测试。建议在确实需要全局访问或共享状态时,才使用单例或全局对象。并结合依赖注入等技术,尽量减少全局状态对应用程序带来的不良影响。

5.尽量避免深层嵌套

有时候因为某些需求条件非常多,导致我们代码进行了深度嵌套,循环和条件判断多层控制结构相互嵌套,这样看起来会显著降低代码得可读性和清晰度,可以看下面这段代码

fun getUserRole(userInput: UserInput): String? {
    if (userInput.login.isNotEmpty()) {
        if (userInput.password.isNotEmpty()) {
            if (isUserExist(userInput.login)) {
                if (isPasswordValid(userInput.password)) {
                    return userRole(userInput.login)
                } else {
                    return UserRole.Unknown
                }
            }
            return UserRole.Unknown
        }
        return UserRole.Unknown
    }
    return UserRole.Unknown
}

是不是非常多条件判断,我们修改起来需要知道每一个条件判断的逻辑,会有点小麻烦,所以这里笔者稍微修改下,把条件判断结构互相独立开来,避免进行深层嵌套,增加其可读性,如下所示:

fun getUserRole(userInput: UserInput): String? {
    if (userInput.login.isEmpty()) return UserRole.Unknown
    if (userInput.password.isEmpty()) return UserRole.Unknown
    if (!isUserExist(userInput.login)) return UserRole.NotExists
    if (!isPasswordValid(userInput.password)) return UserRole.Unauthorized
    return userRole(userInput.login)
}

当然,这还有一定优化空间,我们可以使用Kotlinwhen语句,这样以来代码会变得更加清晰,更易于理解

fun getUserRole(userInput: UserInput): String? {
    return when {
        userInput.login.isEmpty() -> UserRole.Unknown
        userInput.password.isEmpty() -> UserRole.Unknown
        !isUserExist(userInput.login) -> UserRole.NotExists
        !isPasswordValid(userInput.password) -> UserRole.Unauthorized
        else -> userRole(userInput.login)
    }
}

6. 单一职责,避免函数功能逻辑过多

单一职责是开发中的非常重要的原则,简单来说,就是我们每个函数或类只负责完成一个独立的功能,避免在一个函数中包含过多的功能逻辑,这样可以使代码更简洁、易读,便于测试和维护。

这里有同学就会问了,我如何确定这个类或方法是否职责单一呢?笔者总结了以下几点依据,可以参考下

  • 这个类或方法是否可以被简洁地描述为只做一件事?
  • 如果这个类或方法需要修改,修改的原因是否唯一?
  • 这个类或方法是否包含了多个不相关且独立的逻辑?
  • 这个类或方法是否可以被自然地拆分成多个独立的类或方法?

7. 一些时候,尽量使用值的名称而不是'it'

这个在写kt代码的时候经常会用到,很多情况你可以直接用it跳过名称,当时如果有多层内部结构的时候,最好给闭包参数命名

data class User(
    val name: String, 
    val address: Address? = null, 
    val age: Int? = null
)
​
data class Address(val city: String)
​
val user = User("Rainy", Address("ShenZhen"))
​
user.let {
    it.address?.let {
        println("City: ${it.city}")
    }
}

改动之后,此时加上名字,让代码阅读起来更加方便

user.let { user ->
    user.address?.let { address ->
        println("City: ${address.city}")
    }
}

8. 代码缩进,避免复杂代码单行

我们或许会在项目里遇到有些代码比较长,但没有做缩进,都写到了单行,这也加大了我们的理解难度。比如下面这块代码

list.filter { gb-> gb.isHorizontal }.map { it.gradientColors }.takeIf { it.size > 2 } ?: listOf(0)

这个过滤不止一个条件,又没有代码缩进,不但不节省空间,也会加大理解难度;比较好的方式是将这些条件都分解为单独的步骤,使得代码更具可读性,简单来说就是进行缩进下。

 list.filter { gb -> gb.isHorizontal }
            .map { it.gradientColors }
            .takeIf { it.size > 2 }

9.避免一些”天才“代码,有时候链式调用反而成为“累赘"

比如之前我在一个项目里写了这段代码,对我而言,这很好理解啊,就是用于在列表中找到特定ID的过滤器并切换其isActive状态

filters
    .indexOfFirst { it.id == id }
    .takeIf { it != -1 }
    ?.let { index ->
        filters[index] = filters[index].copy(
            isActive = !filters[index].isActive
        )
    }

但是同事会觉得难以理解,这段代码在列表过滤过程中发送了什么,因为修改操作写在了链式调用里面,是否有可能被并发修改?

后来我稍微修改了下,这个时候用更少的链式调用会更容易理解:

val index = filters.indexOfFirst { it.id == id }
if (index != -1) {
    filters[index] = filters[index].copy(isActive = !filters[index].isActive)
}

同事此时一看,恍然大悟,之前的疑惑就迎刃而解了。

10.睡个好觉

这是最简单的技巧,同时也是最难的技巧。

足够的休息时间可以让我们编写更好,更清晰的代码。当然休息不足,可能会忘记一些复杂的代码解决方案,从而影响到开发效率。注意工作与生活的平衡,记得不止一人和我说过,生活是生活,工作是工作,作为打工人,我们尽量争取每天 7-8 小时的睡眠,并从事让大脑休息的活动,例如读一本好书(而不是看手机屏幕)。请记住,我们的心理健康与我们的技术能力同样重要。一个休息良好且健康的开发人员更有可能编写出高效且可维护的代码


就简单谈到这里吧,guys,祝大家2024前程似锦