NSLog 和 print 源码阅读和捕获方案

前言

NSLog 作为 iOS开发常用的调试和日志打印方法,大家都是很熟悉了,
开源社区也为我们贡献了很多非常优秀的日志框架,比如OC中大名鼎鼎的CocoaLumberjack,有兴趣的同学可以移步https://github.com/CocoaLumberjack/CocoaLumberjack
在Swift语言下我们还有另外一种选择,那就是print

如果要自己做日志监控的话,就需要就需要自己重定向NSLogprint方法了

网络上大概有如下几种方法

  • asl读取日志(iOS10以后已经弃用)
    NSLog默认的输出到了系统的 /var/log/syslog这个文件,asl框架能从syslog中读取日志,此种方法对系统无侵入,可惜iOS10后已经获取不到日志
  • 采用dup2的重定向方式
    NSLog最后重定向的句柄是STDERR,NSLog输出的日志内容,最终都通过STDERR句柄来记录,使用dup2重定向STDERR句柄,可以将内容重定向指定的位置,但是重定向之后
  • 采用fishhook方式
    采用facebook的开源框架 fishhook来动态替换NSLogprint方法

本文选择基于fishhook的方式捕获 NSLog 和 print 方法
福利:https://github.com/madaoCN/Supervisor已实现轻量级的日志打印,使用fishhook hook了 NSLog 和 print方法

前期准备

  • fishhook原理
    网上有很多写的很好的文章,这里就不献丑啦,大家自行百度/谷歌哈

  • 编译swift源码
    swift已经开源了,我们可以阅读源码来一窥究竟
    项目地址 https://github.com/apple/swift
    编译也很简单,就是比较耗时

brew install cmake ninja
mkdir swift-source
cd swift-source
git clone https://github.com/apple/swift.git
./swift/utils/update-checkout --clone
cd swift
utils/build-script --release-debuginfo
image.png

最终的编译文件可能会比较大,我的大概是 44.18GB, 编译前请预留足够的空间

搞定了的话,黑喂狗~

NSLog源码阅读

NSLog的源码位置在 你的编译工程目录/Swift-Build/swift-corelibs-foundation

/* Output from NSLogv is serialized, in that only one thread in a process can be doing 
 * the writing/logging described above at a time. All attempts at writing/logging a 
 * message complete before the next thread can begin its attempts. The format specification 
 * allowed by these functions is that which is understood by NSString's formatting capabilities.
 * CFLog1() uses writev/fprintf to write to stderr. Both these functions ensure atomic writes.
 */

public func NSLogv(_ format: String, _ args: CVaListPointer) {
    let message = NSString(format: format, arguments: args)
    CFLog1(CFLogLevel(kCFLogLevelWarning), message._cfObject)
}

public func NSLog(_ format: String, _ args: CVarArg...) {
    withVaList(args) { 
        NSLogv(format, $0) 
    }
}

我们可以看到NSLog调用

NSLog ------> NSLogv ------> CFLog1

结合源码中的注释CFLog1() uses writev/fprintf to write to stderr基本可以猜到NSLog最终会调用writev 和 fprintf方法,接下来我们顺腾摸瓜看下 CFLog1的逻辑

void CFLog1(CFLogLevel lev, CFStringRef message) {
#if TARGET_OS_ANDROID
    if (message == NULL) message = CFSTR("NULL");

    ...

    CFStringEncoding encoding = kCFStringEncodingUTF8;
    CFIndex maxLength = CFStringGetMaximumSizeForEncoding(CFStringGetLength(message), encoding) + 1;

    ...

    if (maxLength == 1) {
        // was crashing with zero length strings
        // https://bugs.swift.org/browse/SR-2666
        strcpy(buffer, " "); // log empty string
    }
    else
        CFStringGetCString(message, buffer, maxLength, encoding);
    
        __android_log_print(priority, tag, "%s", buffer);
        // ======= 注意这里 =======
        fprintf(stderr, "%s\n", buffer);
    
    if (buffer != &stack_buffer[0]) free(buffer);
#else
    // ======= 注意这里 =======
    CFLog(lev, CFSTR("%@"), message);
#endif
}

可以看到如果是安卓环境下,会调用 fprintf, 否则会调用 CFLog方法

  NSLog 
    ↓
  NSLogv
    ↓
  CFLog1
    ↓
  CFLog

现在调用的顺序是这样滴,接下来往下走, 我们看看CFLog

void CFLog(int32_t lev, CFStringRef format, ...) {
    va_list args;
    va_start(args, format); 
    _CFLogvEx3(NULL, NULL, NULL, NULL, lev, format, args, __builtin_return_address(0));
    va_end(args);
}

// 调用了_CFLogvEx3
CF_EXPORT void _CFLogvEx3(CFLogFunc logit, CFStringRef (*copyDescFunc)(void *, const void *), CFStringRef (*contextDescFunc)(void *, const void *, const void *, bool, bool *), CFDictionaryRef formatOptions, int32_t lev, CFStringRef format, va_list args, void *addr) {
    _CFLogvEx2Predicate(logit, copyDescFunc, contextDescFunc, formatOptions, lev, format, args, _cf_logging_style_legacy);
    
}

// 调用了_CFLogvEx2Predicate
static void _CFLogvEx2Predicate(CFLogFunc logit, CFStringRef (*copyDescFunc)(void *, const void *), CFStringRef (*contextDescFunc)(void *, const void *, const void *, bool, bool *), CFDictionaryRef formatOptions, int32_t lev, CFStringRef format, va_list args, _cf_logging_style loggingStyle) {
#if TARGET_OS_MAC
    uintptr_t val = (uintptr_t)_CFGetTSD(__CFTSDKeyIsInCFLog);
    if (3 < val) return; // allow up to 4 nested invocations
    _CFSetTSD(__CFTSDKeyIsInCFLog, (void *)(val + 1), NULL);
#endif
    CFStringRef str = format ? _CFStringCreateWithFormatAndArgumentsAux2(kCFAllocatorSystemDefault, copyDescFunc, contextDescFunc, formatOptions, (CFStringRef)format, args) : 0;
    CFIndex blen = str ? CFStringGetMaximumSizeForEncoding(CFStringGetLength(str), kCFStringEncodingUTF8) + 1 : 0;
    char *buf = str ? (char *)malloc(blen) : 0;
    if (str && buf) {
    Boolean converted = CFStringGetCString(str, buf, blen, kCFStringEncodingUTF8);
    size_t len = strlen(buf);
    // silently ignore 0-length or really large messages, and levels outside the valid range
    if (converted && !(len <= 0 || (1 << 24) < len) && !(lev < ASL_LEVEL_EMERG || ASL_LEVEL_DEBUG < lev)) {
            if (logit) {
                logit(lev, buf, len, 1);
            }
            else if (loggingStyle == _cf_logging_style_os_log) {
                // ======= 注意这里 =======
                __CFLogCString(lev, buf, len, 1);
            }
            else if (loggingStyle == _cf_logging_style_legacy) {
                // ======= 注意这里 =======
                __CFLogCStringLegacy(lev, buf, len, 1);
            }
    }
    }
    if (buf) free(buf);
    if (str) CFRelease(str);
#if TARGET_OS_MAC
    _CFSetTSD(__CFTSDKeyIsInCFLog, (void *)val, NULL);
#endif
}

会调用到 __CFLogCString__CFLogCStringLegacy这两个方法,那么现在调用的流程是这样

                      NSLog 
                        ↓
                      NSLogv
                        ↓
                      CFLog1
                        ↓
                      CFLog
                        ↓             
                   _CFLogvEx3
                        ↓             
               _CFLogvEx2Predicate
                        |
                       / \
                     /     \
                   /         \
          __CFLogCString   __CFLogCStringLegacy

继续阅读源码__CFLogCString__CFLogCStringLegacy这两个方法最终都调用了_logToStderr方法

static void _logToStderr(char *banner, const char *message, size_t length) {
#if TARGET_OS_MAC
    struct iovec v[3];
    v[0].iov_base = banner;
    v[0].iov_len = banner ? strlen(banner) : 0;
    v[1].iov_base = (char *)message;
    v[1].iov_len = length;
    v[2].iov_base = "\n";
    v[2].iov_len = (message[length - 1] != '\n') ? 1 : 0;
    int nv = (v[0].iov_base ? 1 : 0) + 1 + (v[2].iov_len ? 1 : 0);
    static CFLock_t lock = CFLockInit;
    __CFLock(&lock);

     // ======= 注意这里 =======

    writev(STDERR_FILENO, v[0].iov_base ? v : v + 1, nv);
    __CFUnlock(&lock);
#elif TARGET_OS_WIN32
    size_t bannerLen = strlen(banner);
    size_t bufLen = bannerLen + length + 1;
    char *buf = (char *)malloc(sizeof(char) * bufLen);
    if (banner) {
        // Copy the banner into the debug string
        memmove_s(buf, bufLen, banner, bannerLen);
        
        // Copy the message into the debug string
        strcpy_s(buf + bannerLen, bufLen - bannerLen, message);
    } else {
        strcpy_s(buf, bufLen, message);
    }
    buf[bufLen - 1] = '\0';
    fprintf_s(stderr, "%s\n", buf);
    // This Win32 API call only prints when a debugger is active
    // OutputDebugStringA(buf);
    free(buf);
#else
    size_t bannerLen = strlen(banner);
    size_t bufLen = bannerLen + length + 1;
    char *buf = (char *)malloc(sizeof(char) * bufLen);
    if (banner) {
        // Copy the banner into the debug string
        memmove(buf, banner, bannerLen);
        
        // Copy the message into the debug string
        strncpy(buf + bannerLen, message, bufLen - bannerLen);
    } else {
        strncpy(buf, message, bufLen);
    }
    buf[bufLen - 1] = '\0';

    // ======= 注意这里 =======

    fprintf(stderr, "%s\n", buf);
    free(buf);
#endif
}

可见NSLog最终都调用了writevfprintf方法

                      NSLog 
                        ↓
                      NSLogv
                        ↓
                      CFLog1
                        ↓
                      CFLog
                        ↓             
                   _CFLogvEx3
                        ↓             
               _CFLogvEx2Predicate
                        |
                       / \
                     /     \
                   /         \
          __CFLogCString   __CFLogCStringLegacy
                   \         /
                     \     /
                       \ /
                 _logToStderr
                        ↓
                  writev / fprintf

结果与之前的注释一致,那么我们只需要使用 fishhook 对 writev / fprintf方法进行hook就能达到我们的目的了,那么我们继续看看 print函数的源码

print函数源码阅读

print的源码位置在类似 你的编译工程目录/Swift-Build/build/Xcode-RelWithDebInfoAssert/swift-macosx-x86_64具体名字和编译参数和机器有关

我们很容易就找到了源码

// ============ print方法1
public func print(
  _ items: Any...,
  separator: String = " ",
  terminator: String = "\n"
) {
  if let hook = _playgroundPrintHook {
     // ======= 注意这里 =======
    var output = _TeeStream(left: "", right: _Stdout())
    _print(items, separator: separator, terminator: terminator, to: &output)
    hook(output.left)
  }
  else {
   // ======= 注意这里 =======
    var output = _Stdout()
    _print(items, separator: separator, terminator: terminator, to: &output)
  }
}

// ============ print方法2
public func print<Target: TextOutputStream>(
  _ items: Any...,
  separator: String = " ",
  terminator: String = "\n",
  to output: inout Target
) {
  // ======= 注意这里 =======
  _print(items, separator: separator, terminator: terminator, to: &output)
}

可见print 方法会调用_print方法

internal func _print<Target: TextOutputStream>(
  _ items: [Any],
  separator: String = " ",
  terminator: String = "\n",
  to output: inout Target
) {
  var prefix = ""
  output._lock()
  defer { output._unlock() }
  for item in items {
    output.write(prefix)

    // ======= 注意这里 =======
    _print_unlocked(item, &output)

    prefix = separator
  }
  output.write(terminator)
}

// _print_unlocked 源码
@usableFromInline
@_semantics("optimize.sil.specialize.generic.never")
internal func _print_unlocked<T, TargetStream: TextOutputStream>(
  _ value: T, _ target: inout TargetStream
) {
  // Optional has no representation suitable for display; therefore,
  // values of optional type should be printed as a debug
  // string. Check for Optional first, before checking protocol
  // conformance below, because an Optional value is convertible to a
  // protocol if its wrapped type conforms to that protocol.
  // Note: _isOptional doesn't work here when T == Any, hence we
  // use a more elaborate formulation:
  if _openExistential(type(of: value as Any), do: _isOptional) {
    let debugPrintable = value as! CustomDebugStringConvertible
    debugPrintable.debugDescription.write(to: &target)
    return
  }
  if case let streamableObject as TextOutputStreamable = value {
    streamableObject.write(to: &target)
    return
  }

  if case let printableObject as CustomStringConvertible = value {
    printableObject.description.write(to: &target)
    return
  }

  if case let debugPrintableObject as CustomDebugStringConvertible = value {
    debugPrintableObject.debugDescription.write(to: &target)
    return
  }

  let mirror = Mirror(reflecting: value)

  _adHocPrint_unlocked(value, mirror, &target, isDebugPrint: false)
}


可见调用流程如下

print ------> _print ------> _print_unlocked

这里的

  • TextOutputStreamable
  • CustomDebugStringConvertible
  • CustomStringConvertible
////////////////////   CustomStringConvertible
public protocol CustomStringConvertible {
  var description: String { get }
}

////////////////////   CustomDebugStringConvertible
public protocol CustomDebugStringConvertible {
  var debugDescription: String { get }
}

////////////////////   TextOutputStreamable
public protocol TextOutputStreamable {
  /// Writes a textual representation of this instance into the given output
  /// stream.
  func write<Target: TextOutputStream>(to target: inout Target)
}

等都是协议, 将 Target 传入并调用 Targetwrite 方法

我们回过头来看下函数名
internal func _print_unlocked<T, TargetStream: TextOutputStream>( _ value: T, _ target: inout TargetStream )

target 是遵循 TextOutputStream协议的对象,也就是我们之前看到的
_Stdout ()函数


////////////// TextOutputStream 协议
public protocol TextOutputStream {
  mutating func _lock()
  mutating func _unlock()

  /// Appends the given string to the stream.
  mutating func write(_ string: String)

  mutating func _writeASCII(_ buffer: UnsafeBufferPointer<UInt8>)
}

////////////// _Stdout 
internal struct _Stdout: TextOutputStream {
  internal init() {}

  internal mutating func _lock() {
    _swift_stdlib_flockfile_stdout()
  }

  internal mutating func _unlock() {
    _swift_stdlib_funlockfile_stdout()
  }

  internal mutating func write(_ string: String) {
    if string.isEmpty { return }

    var string = string
    _ = string.withUTF8 { utf8 in
      
       // ======= 注意这里 ======= 
      _swift_stdlib_fwrite_stdout(utf8.baseAddress!, 1, utf8.count)
    }
  }
}

// =========== _swift_stdlib_fwrite_stdout 源代码
SWIFT_RUNTIME_STDLIB_INTERNAL
__swift_size_t swift::_swift_stdlib_fwrite_stdout(const void *ptr,
                                                  __swift_size_t size,
                                                  __swift_size_t nitems) {
    // ======= 注意这里 ======= 
    return fwrite(ptr, size, nitems, stdout);
}

我们可以看到

_Stdout -> _swift_stdlib_fwrite_stdout -> fwrite

结合之前的调用方法, 最终也调用了 fwrite方法

print -> _print -> _print_unlocked ->  (print items) -> (write/description.write/debugDescription.write)-> Stdout -> _swift_stdlib_fwrite_stdout -> fwrite

绕了这么大的一圈,我们得出的结论是,print 函数最终调用了fwrite

中场休息~~~~

最终,如果我们要日志监控的话,只需要hook如下三个方法

NSLog 调用  writev / fprintf
print 调用  fwrite

Hook代码

  • 首先我们hook writev, 函数原型
static ssize_t writev(int a, const struct iovec * v, int v_len);

/// struct iovec 类型
struct iovec {
    void *   iov_base;      /* [XSI] Base address of I/O memory region */
    size_t   iov_len;       /* [XSI] Size of region iov_base points to */
};

具体hook代码

//--------------------------------------------------------------------------
// MARK: hook NSLog
//--------------------------------------------------------------------------

// origin writev IMP
static ssize_t (*orig_writev)(int a, const struct iovec * v, int v_len);

// swizzle method
ssize_t asl_writev(int a, const struct iovec *v, int v_len) {
    
    NSMutableString *string = [NSMutableString string];
    for (int i = 0; i < v_len; i++) {
        char *c = (char *)v[i].iov_base;
        [string appendString:[NSString stringWithCString:c encoding:NSUTF8StringEncoding]];
    }
    
    ////////// do something  这里可以捕获到日志 string
    
    // invoke origin mehtod
    ssize_t result = orig_writev(a, v, v_len);
    return result;
}
  • 然后是 fprintf 函数,这里因为 fprintf 是可变参数,具体可变参数相关使用可见博主的另外一篇博客 va_list 可变参数概览

这里先使用NSString 的 @selector(initWithFormat : arguments)方法生成要输出的字符串,直接调用 origin_fprintf 将自行生成的字符串作为参数就行了,免去再次传递可变参数至 origin_fprintf

//--------------------------------------------------------------------------
// MARK: hook fprintf
//--------------------------------------------------------------------------

// origin fprintf IMP
static int     (*origin_fprintf)(FILE * __restrict, const char * __restrict, ...);

// swizzle method
int     asl_fprintf(FILE * __restrict file, const char * __restrict format, ...)
{
    /*
     typedef struct {
         
        unsigned int gp_offset;
        unsigned int fp_offset;
        void *overflow_arg_area;
        void *reg_save_area;
     } va_list[1];
     */
    va_list args;
    
    va_start(args, format);

    NSString *formatter = [NSString stringWithUTF8String:format];
    NSString *string = [[NSString alloc] initWithFormat:formatter arguments:args];
    
    ////////// do something  这里可以捕获到日志

    // invoke orign fprintf
    int result = origin_fprintf(file, [string UTF8String]);
    
    va_end(args);

    return result;
}
  • 然后是 fprintf方法

调用例如 fprintf ("test");方法时候 asl_fwrite会调用两次,参数一次是test,另一次是\n,所以先将字符串放入__messageBuffer,等收到\n时,再将 __messageBuffer中转成字符串一次性读取

//--------------------------------------------------------------------------
// MARK: hook print for swift
//--------------------------------------------------------------------------

// origin fwrite IMP
static size_t (*orig_fwrite)(const void * __restrict, size_t, size_t, FILE * __restrict);

static char *__messageBuffer = {0};
static int __buffIdx = 0;
void reset_buffer()
{
    __messageBuffer = calloc(1, sizeof(char));
    __messageBuffer[0] = '\0';
    __buffIdx = 0;
}


// swizzle method
size_t asl_fwrite(const void * __restrict ptr, size_t size, size_t nitems, FILE * __restrict stream) {
        
    if (__messageBuffer == NULL) {
        // initial Buffer
        reset_buffer();
    }
    
    char *str = (char *)ptr;
    
    NSString *s = [NSString stringWithCString:str encoding:NSUTF8StringEncoding];
    
    if (__messageBuffer != NULL) {
        
        if (str[0] == '\n' && __messageBuffer[0] != '\0') {
            
            s = [[NSString stringWithCString:__messageBuffer encoding:NSUTF8StringEncoding] stringByAppendingString:s];
            
            // reset buffIdx
            reset_buffer();

            ////////// do something  这里可以捕获到日志
        }
        else {
            
            // append buffer
            __messageBuffer = realloc(__messageBuffer, sizeof(char) * (__buffIdx + nitems + 1));
            for (size_t i = __buffIdx; i < nitems; i++) {
                __messageBuffer[i] = str[i];
                __buffIdx ++;
            }
            __messageBuffer[__buffIdx + 1] = '\0';
            __buffIdx ++;
        }
    }
    
    return orig_fwrite(ptr, size, nitems, stream);
}

最后就是 hook 的代码,没啥好说的

//--------------------------------------------------------------------------
// MARK: fishhook调用
//--------------------------------------------------------------------------
// hook writev
rebind_symbols((struct rebinding[1]){{
    "writev",
    asl_writev,
    (void*)&orig_writev
}}, 1);

// hook fwrite
rebind_symbols((struct rebinding[1]){{
    "fwrite",
    asl_fwrite,
    (void *)&orig_fwrite}}, 1);

// hook fprintf
rebind_symbols((struct rebinding[1]){{
    "fprintf",
    asl_fprintf,
    (void *)&origin_fprintf}}, 1);

接下来我们看下成果


image.png

具体的代码,请见https://github.com/madaoCN/Supervisor 功能还在完善中,将间断更新

参考

用fishhook hook输出方法(NSLog, print)
捕获NSLog日志小记
GodEye日志监控
iOS逆向工程 - fishhook原理

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容

  • 日志几乎是我们每一个iOS开发者每一天都要打交道的东西,比如运行时想看一下某个变量的值,那就NSLog()一下;当...
    子循_陈奕龙阅读 2,814评论 2 8
  • 更新2021/2/26(感谢@lgq_9b65的提醒, 由于我一直没用真机测试, 才搞出这个乌龙.) 真机测试中发...
    kagenZhao阅读 4,913评论 6 25
  • 因为要结局swift3.0中引用snapKit的问题,看到一篇介绍Xcode8,swift3变化的文章,觉得很详细...
    uniapp阅读 4,405评论 0 12
  • 峰会结束,傻瓜归来。大家好,我是耀华。TMD88大区北京峰会中文演讲比赛,我做了一个叫演讲,名字叫傻瓜。讲了我生活...
    朱耀华阅读 404评论 0 2
  • 这周持续练习固柢,嘴角起了一个疙瘩,红肿发炎,左边的脖子的红疹发的越拉越大了,之前也有过,不知道是否联系后的好转反...
    c126f72d7d3d阅读 217评论 0 1