Flutter 自定义日志模块设计

前言

村里的老人常说:"工程未动,日志先行。"

有效的利用日志,能够显著提高开发/debug效率,否则程序运行出现问题时可能需要花费大量的时间去定位错误位置和出错原因。

然而一个复杂的项目往往需要打印日志的地方比较多,除了控制日志数量之外,

如何做到有效区分重要信息,以及帮助快速定位代码位置,也是衡量一个工程日志质量的重要标准。

效果图

废话不多说,先看看我们的日志长啥样儿:

(图1)

通常日志信息中,除了包含需要显示的文本,同时应该包含执行时间、代码调用位置信息等。

在我这套系统中,还允许通过颜色区分显示不同日志等级的信息,这个在日志过多时可以让你迅速找到重要信息。

由上面的图1可以看到,4种级别的日志分别采用了不同的颜色显示,并且调用位置显示为程序路径文件名,可以直接点击蓝色的文件名跳转 到相应的代码行。

是不是十分方便? :D

而下面的 HomePage 则展示了该日志模块的另一种用法:

(图2)

接口设计

我们先来看一下接口代码:

Dart 复制代码
/// Simple Log
class Log {

  static const int kDebugFlag   = 1 << 0;
  static const int kInfoFlag    = 1 << 1;
  static const int kWarningFlag = 1 << 2;
  static const int kErrorFlag   = 1 << 3;

  static const int kDebug   = kDebugFlag|kInfoFlag|kWarningFlag|kErrorFlag;
  static const int kDevelop =            kInfoFlag|kWarningFlag|kErrorFlag;
  static const int kRelease =                      kWarningFlag|kErrorFlag;

  static int level = kRelease;

  static bool colorful = false;  // colored printer
  static bool showTime = true;
  static bool showCaller = false;

  static Logger logger = DefaultLogger();

  static void   debug(String msg) => logger.debug(msg);
  static void    info(String msg) => logger.info(msg);
  static void warning(String msg) => logger.warning(msg);
  static void   error(String msg) => logger.error(msg);

}

根据多年的项目经验,一般的项目需求中日志可以分为4个等级:

  1. 调试信息 (仅 debug 模式下显示)
  2. 普通信息
  3. 警告信息
  4. 错误信息 (严重错误,应收集后定时上报)

其中"调试信息"通常是当我们需要仔细观察每一个关键变量的值时才会打印的信息,这种信息由于太过冗余,通常情况下应该关闭;

而"告警信息"和"错误信息"则是程序出现超出预期范围或错误时需要打印的信息,这两类信息一般不应该关闭,在正式发布的版本中,错误信息甚至可能需要打包上传到日志服务器,以便程序员远程定位问题。

考虑到项目环境等因素,这里将具体的打印功能代理给 Log 类对象 logger 执行(后面会介绍)。

另外,根据 Dart 语言特性,这里还提供了 MixIn(混入)方式调用日志的接口,

通过 MixIn,还可以在打印日志的时候额外输出当前类信息:

Dart 复制代码
/// Log with class name
mixin Logging {
  
  void logDebug(String msg) {
    Type clazz = runtimeType;
    Log.debug('$clazz >\t$msg');
  }

  void logInfo(String msg) {
    Type clazz = runtimeType;
    Log.info('$clazz >\t$msg');
  }

  void logWarning(String msg) {
    Type clazz = runtimeType;
    Log.warning('$clazz >\t$msg');
  }

  void logError(String msg) {
    Type clazz = runtimeType;
    Log.error('$clazz >\t$msg');
  }

}

使用方法也很简单(如上图2所示),先在需要打印日志的类定义中增加 ```with Logging```,

然后使用上面定义的 4 个接口 logXxxx() 打印即可:

Dart 复制代码
import 'package:lnc/log.dart';


// Logging Demo
class MyClass with Logging {

  int _counter = 0;

  void _incrementCounter() {
    logInfo('counter = $_counter');
  }

  //...

}

开发应用

首先以你项目需求所期望的方式实现 ```Logger``` 接口:

Dart 复制代码
import 'package:lnc/log.dart';


class MyLogger implements Logger {

  @override
  void debug(String msg) {
    // 打印调试信息
  }

  @override
  void info(String msg) {
    // 打印普通日志信息
  }

  @override
  void warning(String msg) {
    // 打印告警信息
  }

  @override
  void error(String msg) {
    // 打印 or 收集需要上报的错误信息
  }

}

然后在 app 启动之前初始化替换 ```Log.logger```:

Dart 复制代码
void main() {

  Log.logger = MyLogger();  // 替换 logger

  Log.level = Log.kDebug;
  Log.colorful = true;
  Log.showTime = true;
  Log.showCaller = true;

  Log.debug('starting MyApp');
  // ...

}

代码引用

由于我已提交了一个完整的模块代码到 pub.dev,所以在实际应用中,你只需要在项目工程文件 ```pubspec.yaml``` 中添加:

复制代码
dependencies:

  lnc: ^0.1.2

然后在需要使用的 dart 文件头引入即可:

Dart 复制代码
import 'package:lnc/log.dart';

只有当你需要修改日志行为(例如上报数据)的时候,才需要编写你的 MyLogger。

全部源码

Dart 复制代码
/* license: https://mit-license.org
 *
 *  LNC : Log & Notification Center
 *
 *                               Written in 2023 by Moky <albert.moky@gmail.com>
 *
 * =============================================================================
 * The MIT License (MIT)
 *
 * Copyright (c) 2023 Albert Moky
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 * =============================================================================
 */


/// Simple Log
class Log {

  static const int kDebugFlag   = 1 << 0;
  static const int kInfoFlag    = 1 << 1;
  static const int kWarningFlag = 1 << 2;
  static const int kErrorFlag   = 1 << 3;

  static const int kDebug   = kDebugFlag|kInfoFlag|kWarningFlag|kErrorFlag;
  static const int kDevelop =            kInfoFlag|kWarningFlag|kErrorFlag;
  static const int kRelease =                      kWarningFlag|kErrorFlag;

  static int level = kRelease;

  static bool colorful = false;  // colored printer
  static bool showTime = true;
  static bool showCaller = false;

  static Logger logger = DefaultLogger();

  static void   debug(String msg) => logger.debug(msg);
  static void    info(String msg) => logger.info(msg);
  static void warning(String msg) => logger.warning(msg);
  static void   error(String msg) => logger.error(msg);

}


/// Log with class name
mixin Logging {
  
  void logDebug(String msg) {
    Type clazz = runtimeType;
    Log.debug('$clazz >\t$msg');
  }

  void logInfo(String msg) {
    Type clazz = runtimeType;
    Log.info('$clazz >\t$msg');
  }

  void logWarning(String msg) {
    Type clazz = runtimeType;
    Log.warning('$clazz >\t$msg');
  }

  void logError(String msg) {
    Type clazz = runtimeType;
    Log.error('$clazz >\t$msg');
  }

}


class DefaultLogger with LogMixin {
  // override for customized logger

  final LogPrinter _printer = LogPrinter();

  @override
  LogPrinter get printer => _printer;

}

abstract class Logger {

  LogPrinter get printer;

  void   debug(String msg);
  void    info(String msg);
  void warning(String msg);
  void   error(String msg);

}

mixin LogMixin implements Logger {

  static String colorRed    = '\x1B[95m';  // error
  static String colorYellow = '\x1B[93m';  // warning
  static String colorGreen  = '\x1B[92m';  // debug
  static String colorClear  = '\x1B[0m';

  String? get now =>
      Log.showTime ? LogTimer().now : null;

  LogCaller? get caller =>
      Log.showCaller ? LogCaller.parse(StackTrace.current) : null;

  int output(String msg, {LogCaller? caller, String? tag, String color = ''}) {
    String body;
    // insert caller
    if (caller == null) {
      body = msg;
    } else {
      body = '$caller >\t$msg';
    }
    // insert tag
    if (tag != null) {
      body = '$tag | $body';
    }
    // insert time
    String? time = now;
    if (time != null) {
      body = '[$time] $body';
    }
    // colored print
    if (Log.colorful && color.isNotEmpty) {
      printer.output(body, head: color, tail: colorClear);
    } else {
      printer.output(body);
    }
    return body.length;
  }

  @override
  void debug(String msg) => (Log.level & Log.kDebugFlag) > 0 &&
      output(msg, caller: caller, tag: ' DEBUG ', color: colorGreen) > 0;

  @override
  void info(String msg) => (Log.level & Log.kInfoFlag) > 0 &&
      output(msg, caller: caller, tag: '       ', color: '') > 0;

  @override
  void warning(String msg) => (Log.level & Log.kWarningFlag) > 0 &&
      output(msg, caller: caller, tag: 'WARNING', color: colorYellow) > 0;

  @override
  void error(String msg) => (Log.level & Log.kErrorFlag) > 0 &&
      output(msg, caller: caller, tag: ' ERROR ', color: colorRed) > 0;

}

class LogPrinter {

  int chunkLength = 1000;  // split output when it's too long
  int limitLength = -1;    // max output length, -1 means unlimited

  String carriageReturn = '↩️';

  /// colorful print
  void output(String body, {String head = '', String tail = ''}) {
    int size = body.length;
    if (0 < limitLength && limitLength < size) {
      body = '${body.substring(0, limitLength - 3)}...';
      size = limitLength;
    }
    int start = 0, end = chunkLength;
    for (; end < size; start = end, end += chunkLength) {
      _print(head + body.substring(start, end) + tail + carriageReturn);
    }
    if (start >= size) {
      // all chunks printed
      assert(start == size, 'should not happen');
    } else if (start == 0) {
      // body too short
      _print(head + body + tail);
    } else {
      // print last chunk
      _print(head + body.substring(start) + tail);
    }
  }

  /// override for redirecting outputs
  void _print(Object? object) => print(object);

}

class LogTimer {

  /// full string for current time: 'yyyy-mm-dd HH:MM:SS'
  String get now {
    DateTime time = DateTime.now();
    String m = _twoDigits(time.month);
    String d = _twoDigits(time.day);
    String h = _twoDigits(time.hour);
    String min = _twoDigits(time.minute);
    String sec = _twoDigits(time.second);
    return '${time.year}-$m-$d $h:$min:$sec';
  }

  static String _twoDigits(int n) {
    if (n >= 10) return "$n";
    return "0$n";
  }

}

// #0      LogMixin.caller (package:lnc/src/log.dart:85:55)
// #1      LogMixin.debug (package:lnc/src/log.dart:105:41)
// #2      Log.debug (package:lnc/src/log.dart:50:45)
// #3      main.<anonymous closure>.<anonymous closure> (file:///Users/moky/client/test/client_test.dart:14:11)
// #4      Declarer.test.<anonymous closure>.<anonymous closure> (package:test_api/src/backend/declarer.dart:215:19)
// <asynchronous suspension>
// #5      Declarer.test.<anonymous closure> (package:test_api/src/backend/declarer.dart:213:7)
// <asynchronous suspension>
// #6      Invoker._waitForOutstandingCallbacks.<anonymous closure> (package:test_api/src/backend/invoker.dart:258:9)
// <asynchronous suspension>

// #?      function (path:1:2)
// #?      function (path:1)
class LogCaller {
  LogCaller(this.name, this.path, this.line);

  final String name;
  final String path;
  final int line;

  @override
  String toString() => '$path:$line';

  /// locate the real caller: '#3      ...'
  static String? locate(StackTrace current) {
    List<String> array = current.toString().split('\n');
    for (String line in array) {
      if (line.contains('lnc/src/log.dart:')) {
        // skip for Log
        continue;
      }
      // assert(line.startsWith('#3      '), 'unknown stack trace: $current');
      if (line.startsWith('#')) {
        return line;
      }
    }
    // unknown format
    return null;
  }

  /// parse caller info from trace
  static LogCaller? parse(StackTrace current) {
    String? text = locate(current);
    if (text == null) {
      // unknown format
      return null;
    }
    // skip '#0      '
    int pos = text.indexOf(' ');
    text = text.substring(pos + 1).trimLeft();
    // split 'name' & '(path:line:column)'
    pos = text.lastIndexOf(' ');
    String name = text.substring(0, pos);
    String tail = text.substring(pos + 1);
    String path = 'unknown.file';
    String line = '-1';
    int pos1 = tail.indexOf(':');
    if (pos1 > 0) {
      pos = tail.indexOf(':', pos1 + 1);
      if (pos > 0) {
        path = tail.substring(1, pos);
        pos1 = pos + 1;
        pos = tail.indexOf(':', pos1);
        if (pos > 0) {
          line = tail.substring(pos1, pos);
        } else if (pos1 < tail.length) {
          line = tail.substring(pos1, tail.length - 1);
        }
      }
    }
    return LogCaller(name, path, int.parse(line));
  }

}

GitHub 地址:

https://github.com/dimchat/sdk-dart/blob/main/lnc/lib/src/log.dart

结语

这里展示了一个高效简洁美观的 Flutter 日志模块,其中包含了"接口驱动"、"代理模式"、"混入模式"等设计思想。

在这里重点推介"接口驱动"这种设计思想,就是当你准备开发一个功能模块的时候,

首先要充分理解需求,然后根据需求定义接口(这时千万不要过多的考虑具体怎么实现,而是重点关注需求);然后再将具体的实现放到别的地方,从而达到接口与内部执行代码完全分离。

而使用者则无需关心你的内部实现,只需要了解接口定义即可。

这种设计思想,村里的老人们更喜欢称之为"干湿分离",希望对你有所帮助。 ^_^

如有其他问题,可以下载登录 Tarsier 与我交流(默认通讯录里找 Albert Moky / 章北海)

相关推荐
泓博15 分钟前
KMP(Kotlin Multiplatform)改造(Android/iOS)老项目
android·ios·kotlin
Digitally16 分钟前
如何将信息从 iPhone 同步到Mac(完整步骤和示意图)
macos·ios·iphone
大猫会长21 分钟前
使用Mac自带的图像捕捉导出 iPhone 相册
ios·iphone
移动开发者1号32 分钟前
使用Baseline Profile提升Android应用启动速度的终极指南
android·kotlin
移动开发者1号1 小时前
解析 Android Doze 模式与唤醒对齐
android·kotlin
菠萝加点糖3 小时前
Kotlin Data包含ByteArray类型
android·开发语言·kotlin
0wioiw06 小时前
Flutter基础(FFI)
flutter
Georgewu9 天前
【HarmonyOS 5】鸿蒙跨平台开发方案详解(一)
flutter·harmonyos
爱吃鱼的锅包肉9 天前
Flutter开发中记录一个非常好用的图片缓存清理的插件
flutter
IAM四十二9 天前
Google 端侧 AI 框架 LiteRT 初探
android·深度学习·tensorflow