質量監(jiān)控-資源使用

前言

應用性能的衡量標準有很多,從用戶的角度來看,卡頓是最明顯的表現,但這不意味看起來不卡頓的應用就不存在性能問題。從開發(fā)角度來看,衡量一段代碼或者說算法的標準包括空間復雜度和時間復雜度,分別對應內存和CPU兩種重要的計算機硬件。只有外在與內在都做沒問題,才能說應用的性能做好了。因此,一套應用性能監(jiān)控系統(tǒng)對開發(fā)者的幫助是巨大的,它能幫助你找到應用的性能瓶頸。

CPU

線程是程序運行的最小單位,換句話來說就是:我們的應用其實是由多個運行在CPU上面的線程組合而成的。要想知道應用占用了CPU多少資源,其實就是獲取應用所有線程占用CPU的使用量。結構體thread_basic_info封裝了單個線程的基本信息:

struct thread_basic_info {
    time_value_t    user_time;      /* user run time */
    time_value_t    system_time;    /* system run time */
    integer_t       cpu_usage;      /* scaled cpu usage percentage */
    policy_t        policy;         /* scheduling policy in effect */
    integer_t       run_state;      /* run state (see below) */
    integer_t       flags;          /* various flags (see below) */
    integer_t       suspend_count;  /* suspend count for thread */
    integer_t       sleep_time;     /* number of seconds that thread
                                   has been sleeping */
};

問題在于如何獲取這些信息。iOS的操作系統(tǒng)是基于Darwin內核實現的,這個內核提供了task_threads接口讓我們獲取所有的線程列表以及接口thread_info來獲取單個線程的信息:

kern_return_t task_threads
(
    task_inspect_t target_task,
    thread_act_array_t *act_list,
    mach_msg_type_number_t *act_listCnt
);

kern_return_t thread_info
(
    thread_inspect_t target_act,
    thread_flavor_t flavor,
    thread_info_t thread_info_out,
    mach_msg_type_number_t *thread_info_outCnt
);

第一個函數的target_task傳入進程標記,這里使用mach_task_self()獲取當前進程,后面兩個傳入兩個指針分別返回線程列表和線程個數,第二個函數的flavor通過傳入不同的宏定義獲取不同的線程信息,這里使用THREAD_BASIC_INFO。此外,參數存在多種類型,實際上大多數都是mach_port_t類型的別名:

因此可以得到下面的代碼來獲取應用對應的CPU占用信息。宏定義TH_USAGE_SCALE返回CPU處理總頻率:

- (double)currentUsage {
    double usageRatio = 0;
    thread_info_data_t thinfo;
    thread_act_array_t threads;
    thread_basic_info_t basic_info_t;
    mach_msg_type_number_t count = 0;
    mach_msg_type_number_t thread_info_count = THREAD_INFO_MAX;

    if (task_threads(mach_task_self(), &threads, &count) == KERN_SUCCESS) {
        for (int idx = 0; idx < count; idx++) {
            if (thread_info(threads[idx], THREAD_BASIC_INFO, (thread_info_t)thinfo, &thread_info_count) == KERN_SUCCESS) {
                basic_info_t = (thread_basic_info_t)thinfo;
                if (!(basic_info_t->flags & TH_FLAGS_IDLE)) {
                    usageRatio += basic_info_t->cpu_usage / (double)TH_USAGE_SCALE;
                }
            }
        }
        assert(vm_deallocate(mach_task_self(), (vm_address_t)threads, count * sizeof(thread_t)) == KERN_SUCCESS);
    }
    return usageRatio * 100.;
}

實踐

由于硬件爆發(fā)式的性能增長,使用舊設備進行性能監(jiān)控來優(yōu)化代碼的可行性更高。另外,不要害怕CPU的使用率過高。越高的占用率代表對CPU的有效使用越高,即便出現超出100%的使用率也不要害怕,需要結合FPS來觀察是否對使用造成了影響。

拿筆者最近的一次優(yōu)化來說,機型iPhone5c,性能瓶頸出現在啟動應用之后第一次進入城市選擇列表后出現的卡頓。通過監(jiān)控CPU發(fā)現在進行的時候達到了187%左右的使用率,幀數下降到27幀左右。最初的偽代碼如下:

static BOOL kmc_should_update_cities = YES;

- (void)fetchCities {
    if (kmc_should_update_cities) {
        [self fetchCitiesFromRemoteServer: ^(NSDictionary * allCities) {
            [self updateCities: allCities];
            [allCities writeToFile: [self localPath] atomically: YES];
        }];
    } else {
        [self fetchCitiesFromLocal: ^(NSDictionary * allCities) {
            [self updateCities: allCities];
        }];
    }
}

上述的代碼對于數據的處理基本都放在子線程中處理,主線程實際上處理的工作并不多,但是同樣引發(fā)了卡頓。筆者的猜測是:CPU對所執(zhí)行的線程一視同仁,并不會因為主線程的關系就額外分配更多的處理時間。因此當子線程的任務處理超出了某個闕值時,主線程照樣會受到影響,因此將耗時任務放到子線程處理來避免主線程卡頓是存在前提的。那么可以比較輕松的找到代碼中CPU消耗最嚴重的地方:文件寫入

多線程陷阱一文中我提到過并發(fā)存在的缺陷,這種缺陷同上面的代碼是一致的。數據在寫入本地的時候會占據CPU資源,直到寫入操作完成,如果此時其他核心上同樣處理著大量任務,應用基本逃不開卡頓出現。筆者的解決方案是使用將數據分片寫入本地,由于NSStream自身的設計,可以保證寫入操作的流暢性:

case NSStreamEventHasSpaceAvailable: {
    LXDDispatchQueueAsyncBlockInUtility(^{
        uint8_t * writeBytes = (uint8_t *)_writeData.bytes;
        writeBytes += _currentOffset;
        NSUInteger dataLength = _writeData.length;
            
        NSUInteger writeLength = (dataLength - _currentOffset > kMaxBufferLength) ? kMaxBufferLength : (dataLength - _currentOffset);
        uint8_t buffer[writeLength];
        (void)memcpy(buffer, writeBytes, writeLength);
        writeLength = [self.outputStream write: buffer maxLength: writeLength];
        _currentOffset += writeLength;
    });
} break;

替換文件寫入的操作時候,再次進入幀數已經上升到了40左右了,但是仍然會在進行之后發(fā)生短暫的卡頓。進一步猜測原因是:在異步實現流寫入的操作時,同樣異步進行數據處理。多個線程與主線程搶占CPU資源,因此將數據處理的操作進一步延后,放到寫入操作完成之后:

case NSStreamEventOpenCompleted: {
    [self fetchCitiesFromLocal: ^(NSDictionary * allCities) {
        [self updateCities: allCities];
    }];
} break;

完成這一步之后,除了頁面跳轉時幀數會降到40左右,跳轉完成之后基本保持在52以上的幀數。此外,其他的優(yōu)化方案還包括UI展示時對數據的懶加載等,不過上面二個處理更符合CPU相關優(yōu)化的概念,因此其他的就不再多說。

內存

進程的內存使用信息同樣放在了另一個結構體mach_task_basic_info中,存儲了包括多種內存使用信息:

#define MACH_TASK_BASIC_INFO     20         /* always 64-bit basic info */
struct mach_task_basic_info {
    mach_vm_size_t  virtual_size;       /* virtual memory size (bytes) */
    mach_vm_size_t  resident_size;      /* resident memory size (bytes) */
    mach_vm_size_t  resident_size_max;  /* maximum resident memory size (bytes) */
    time_value_t    user_time;          /* total user run time for
                                           terminated threads */
    time_value_t    system_time;        /* total system run time for
                                           terminated threads */
    policy_t        policy;             /* default policy for new threads */
    integer_t       suspend_count;      /* suspend count for task */
};

對應的獲取函數名為task_info,傳入進程名、獲取的信息類型、信息存儲結構體以及數量變量:

kern_return_t task_info
(
    task_name_t target_task,
    task_flavor_t flavor,
    task_info_t task_info_out,
    mach_msg_type_number_t *task_info_outCnt
);

由于mach_task_basic_info中的內存使用bytes作為單位,在顯示之前我們還需要進行一層轉換。另外為了方便實際使用中的換算,筆者使用結構體來存儲內存相關信息:

#ifndef NBYTE_PER_MB
#define NBYTE_PER_MB (1024 * 1024)
#endif

typedef struct LXDApplicationMemoryUsage
{
    double usage;   ///< 已用內存(MB)
    double total;   ///< 總內存(MB)
    double ratio;   ///< 占用比率
} LXDApplicationMemoryUsage;

獲取內存占用量的代碼如下:

- (LXDApplicationMemoryUsage)currentUsage {
    struct mach_task_basic_info info;
    mach_msg_type_number_t count = sizeof(info) / sizeof(integer_t);
    if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &count) == KERN_SUCCESS) {
        return (LXDApplicationMemoryUsage){
            .usage = info.resident_size / NBYTE_PER_MB,
            .total = [NSProcessInfo processInfo].physicalMemory / NBYTE_PER_MB,
            .ratio = info.virtual_size / [NSProcessInfo processInfo].physicalMemory,
        };
    }
    return (LXDApplicationMemoryUsage){ 0 };
}

展示

內存和CPU的監(jiān)控并不像其他設備信息一樣,能做更多有趣的事情。實際上,這兩者的獲取是一段枯燥又固定的代碼,因此并沒有太多可說的。對于這兩者的信息,基本上是開發(fā)階段展示出來觀察性能的。因此設置一個良好的查詢周期以及展示是這個過程中相對好玩的地方。筆者最終監(jiān)控的效果如下:

不知道什么原因導致了task_info獲取到的內存信息總是比Xcode自身展示的要多20M左右,因此使用的時候自行扣去這一部分再做衡量。為了保證展示器總能顯示在頂部,筆者創(chuàng)建了一個UIWindow的單例,通過設置windowLevel的值為CGFLOAT_MAX來保證顯示在最頂層,并且重寫了一部分方法保證不被修改:

- (instancetype)initWithFrame: (CGRect)frame {
    if (self = [super initWithFrame: frame]) {
        [super setUserInteractionEnabled: NO];
        [super setWindowLevel: CGFLOAT_MAX];
    
        self.rootViewController = [UIViewController new];
        [self makeKeyAndVisible];
    }
    return self;
}

- (void)setWindowLevel: (UIWindowLevel)windowLevel { }
- (void)setBackgroundColor: (UIColor *)backgroundColor { }
- (void)setUserInteractionEnabled: (BOOL)userInteractionEnabled { }

三個標簽欄采用異步繪制的方式保證更新文本的時候不影響主線程,核心代碼:

CGSize textSize = [attributedText.string boundingRectWithSize: size options: NSStringDrawingUsesLineFragmentOrigin attributes: @{ NSFontAttributeName: self.font } context: nil].size;
textSize.width = ceil(textSize.width);
textSize.height = ceil(textSize.height);
    
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, CGRectMake((size.width - textSize.width) / 2, 5, textSize.width, textSize.height));
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)attributedText);
CTFrameRef frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attributedText.length), path, NULL);
CTFrameDraw(frame, context);
    
UIImage * contents = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
CFRelease(frameSetter);
CFRelease(frame);
CFRelease(path);
dispatch_async(dispatch_get_main_queue(), ^{
    self.layer.contents = (id)contents.CGImage;
});

其他

除了監(jiān)控應用本身占用的CPU和內存資源之外,Darwin提供的接口還允許我們去監(jiān)控整個設備本身的內存和CPU使用量,筆者分別封裝了額外兩個類來獲取這些數據。最后統(tǒng)一封裝了LXDResourceMonitor類來監(jiān)控這些資源的使用,通過枚舉來控制監(jiān)控內容:

typedef NS_ENUM(NSInteger, LXDResourceMonitorType)
{
    LXDResourceMonitorTypeDefault = (1 << 2) | (1 << 3),
    LXDResourceMonitorTypeSystemCpu = 1 << 0,   ///<    監(jiān)控系統(tǒng)CPU使用率,優(yōu)先級低
    LXDResourceMonitorTypeSystemMemory = 1 << 1,    ///<    監(jiān)控系統(tǒng)內存使用率,優(yōu)先級低
    LXDResourceMonitorTypeApplicationCpu = 1 << 2,  ///<    監(jiān)控應用CPU使用率,優(yōu)先級高
    LXDResourceMonitorTypeApplicationMemoty = 1 << 3,   ///<    監(jiān)控應用內存使用率,優(yōu)先級高
};

這里使用到了位運算的內容,相比起其他的手段要更簡潔高效。APM系列至此已經完成了大半,當然除了網上常用的APM手段之外,筆者還會加入包括RunLoop優(yōu)化運用相關的技術。

Demo在此

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,820評論 25 708
  • 從三月份找實習到現在,面了一些公司,掛了不少,但最終還是拿到小米、百度、阿里、京東、新浪、CVTE、樂視家的研發(fā)崗...
    時芥藍閱讀 42,340評論 11 349
  • 又來到了一個老生常談的問題,應用層軟件開發(fā)的程序員要不要了解和深入學習操作系統(tǒng)呢? 今天就這個問題開始,來談談操...
    tangsl閱讀 4,162評論 0 23
  • 準確衡量自己,沒必要自卑,活出自己 剛才下火車對吳老師不自信,也沒做錯啥。大膽去說,差不多即可。和真老師打電話有點...
    三不主義閱讀 143評論 0 0
  • 今天終于將公眾號注冊成功了,說起注冊公眾號也是一條坎坷之路,去年報寫手圈訓練營自己的作品需要一個寄存之處,好多同學...
    山水伊人兒閱讀 182評論 0 0