iOS崩溃与日志分析

栏目: IOS · 发布时间: 7年前

内容简介:在iOS开发中经常需要靠记录日志来调试应用程序、解决崩溃问题等,整理常用的日志输出和崩溃日志分析。最新更新:2018-11-30基于CocoaLumberjack 的

在iOS开发中经常需要靠记录日志来调试应用程序、解决崩溃问题等,整理常用的日志输出和崩溃日志分析。

最新更新:2018-11-30

基于CocoaLumberjack 的 Swift使用封装库

一、崩溃的捕获

1、崩溃日志产生原因

1、应用中有Bug。
2、Watchdog 超时机制
3、用户强制退出
4、低内存终止
5、其他违反系统规则的操作,大部分是内存问题
发生崩溃,系统会生成一份崩溃日志在本地,或者上传 ITC

2、崩溃的类型(异常、信号错误)

异常类

NSRangeException等 NSException类

信号错误类

信号中断(SGIABRT)、非法指令信号(SIGILL)、总线错误信号(SIGBUS)、段错误信号(SIGSEGV)、访问一个已经释放的对象(EXC_BAD_ACCESS)

3、捕获异常崩溃信息

只能捕获一些异常崩溃,如 unrecognized selector、NSRangeException beyond bounds越界等Exception属错误

Appdelegate

在Appdelegate 的 didFinishLaunchingWithOptions 中 添加
NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);

方法实现如下
void UncaughtExceptionHandler(NSException *exception) {
    /**
     *  获取异常崩溃信息
     */
    NSArray *callStack = [exception callStackSymbols];
    NSString *reason = [exception reason];
    NSString *name = [exception name];
    NSString *content = [NSString stringWithFormat:@"========异常错误报告========\nname:%@\nreason:\n%@\ncallStackSymbols:\n%@",name,reason,[callStack componentsJoinedByString:@"\n"]];
    
    //将崩溃信息持久化在本地,下次程序启动时、或者后台,将崩溃信息作为日志发送给开发者。
    [[NSUserDefaults standardUserDefaults] setObject:content forKey:@"ExceptionContent"];
}

测试

数组越界错误

NSMutableArray *array = [NSMutableArray array];
NSLog(@"%@",array[1]);

4、捕获信号错误崩溃信息

信号类型崩溃捕获,测试的时候如果测试Signal类型的崩溃,不要在xcode下的debug模式进行测试。因为系统的debug会优先去拦截。应该build好应用之后直接点击运行app进行测试。

1、什么是信号

在计算机科学中,信号(英语:Signals)是Unix、类Unix以及其他POSIX兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统中断了进程正常的控制流程,此时,任何非原子操作都将被中断。如果进程定义了信号的处理函数,那么它将被执行,否则就执行默认的处理函数。

在iOS中就是未被捕获的Objective-C异常(NSException),导致程序向自身发送了SIGABRT信号而崩溃。

SIGABRT–程序中止命令中止信号
SIGALRM–程序超时信号
SIGFPE–程序浮点异常信号
SIGILL–程序非法指令信号
SIGHUP–程序终端中止信号
SIGINT–程序键盘中断信号
SIGKILL–程序结束接收中止信号
SIGTERM–程序kill中止信号
SIGSTOP–程序键盘中止信号
SIGSEGV–程序无效内存中止信号
SIGBUS–程序内存字节未对齐中止信号
SIGPIPE–程序Socket发送失败中止信号

更多信号

2、捕获方法

Appdelegate 的 didFinishLaunchingWithOptions 中 添加

signal(SIGHUP, SignalHandler);
signal(SIGINT, SignalHandler);
signal(SIGQUIT, SignalHandler);

signal(SIGABRT, SignalHandler);
signal(SIGILL, SignalHandler);
signal(SIGSEGV, SignalHandler);
signal(SIGFPE, SignalHandler);
signal(SIGBUS, SignalHandler);
signal(SIGPIPE, SignalHandler);

方法实现如下

void SignalHandler(int signal){
    
    int32_t exceptionCount = OSAtomicIncrement32(&UncaughtExceptionCount);
    if (exceptionCount > UncaughtExceptionMaximum)
    {
        return;
    }
    
    void* callstack[128];
    int frames = backtrace(callstack, 128);
    char **strs = backtrace_symbols(callstack, frames);
    
    int i;
    NSMutableArray *backtrace = [NSMutableArray arrayWithCapacity:frames];
    for (
         i = UncaughtExceptionHandlerSkipAddressCount;
         i < UncaughtExceptionHandlerSkipAddressCount +
         UncaughtExceptionHandlerReportAddressCount;
         i++)
    {
        [backtrace addObject:[NSString stringWithUTF8String:strs[i]]];
    }
    free(strs);
    NSLog(@"%@",backtrace);
    NSLog(@"%@", [NSString stringWithFormat:
                  NSLocalizedString(@"Signal %d was raised.", nil),
                  signal]);
}

3、测试

UIView *tempView = [[UIView alloc]init];
[tempView release];

//对象已经被释放,内存不合法,此块内存地址又没被覆盖,所以此内存内垃圾内存,所以调用方法的时候会导致SIGSEGV的错误
[tempView setNeedsDisplay];

或者说 我在堆内存中找栈内存地址

id x_id = [self performSelector:@selector(createNum)];

- (int)createNum {
    return 10;
}

这种情况也是会导致SIGSEGV的错误的

如果在内存中释放不存在的空间,就会导致SIGABRT错误

Test * test = {1, 2};

free(test);


内存地址不对齐会导致SIGBUS错误

char *s = "hello world";
*s = 'H';

4、问题

信号捕获后 app卡死了

大部分这类型的错误会报错 EXC_BAD_ACCESS ,而这种错误都是发生在内存问题,例如

1、访问数据为空、数据类型不对

2、操作了不该操作的对象,野指针

5、EXC_BAD_ACCESS错误的调试

1、xcode可以用僵尸模式打印出对象 然后通过对象查找对应的代码位置

1、Edit Scheme - Diagnositics - Memory Management 勾选 Zombie Objects 和 Malloc Stack

2、会打印出 
cyuyan[7756:17601127] *** -[UIViewController respondsToSelector:]: message sent to deallocated instance 0x7fe71240d390

这句开启僵尸模式后打出来的输出,包含了我们需要的 进程pid、崩溃地址,终端通过下面命令查看堆栈日志来找到崩溃代码

3、查找日志
sudo malloc_history 7756 0x7fe71240d390

2、覆写一个object的respondsToSelector方法

在 other c flags中加入-D FOR_DEBUG(记住请只在Debug Configuration下加入此标记)。这样当你程序崩溃时,Xcode的console上就会准确地记录了最后运行的object的方法。重写一个object的respondsToSelector方法,打印报错前的

#ifdef _FOR_DEBUG_  
-(BOOL) respondsToSelector:(SEL)aSelector {  
    printf("SELECTOR: %s\n", [NSStringFromSelector(aSelector) UTF8String]);  
    return [super respondsToSelector:aSelector];  
}  
#endif

3、通过instruments的Zombies

引申:怎么定位到野指针的地方。如果还没定位到,这个对象被提前释放了,怎么知道该对象在什么地方释放的

一种是多线程,一种是野指针。这两种Crash都带随机性,我们要让随机crash变成不随机

把这一随机的过程变成不随机的过程。对象释放后在内存上填上不可访问的数据,其实这种技术其实一直都有,xcode的Enable Scribble就是这个作用。

1、Edit Scheme - Diagnositics - Memory Management 勾选 Malloc Scribble

但是此方法测试后暂时未解决

6、可能崩溃的一些场景

1、野指针

1、对象释放后内存没被改动过,原来的内存保存完好,可能不Crash或者出现逻辑错误(随机Crash)。
2、对象释放后内存没被改动过,但是它自己析构的时候已经删掉某些必要的东西,可能不Crash、Crash在访问依赖的对象比如类成员上、出现逻辑错误(随机Crash)。
3、对象释放后内存被改动过,写上了不可访问的数据,直接就出错了很可能Crash在objc_msgSend上面(必现Crash,常见)。
4、对象释放后内存被改动过,写上了可以访问的数据,可能不Crash、出现逻辑错误、间接访问到不可访问的数据(随机Crash)。
5、对象释放后内存被改动过,写上了可以访问的数据,但是再次访问的时候执行的代码把别的数据写坏了,遇到这种Crash只能哭了(随机Crash,难度大,概率低)!!
6、对象释放后再次release(几乎是必现Crash,但也有例外,很常见)。

iOS崩溃与日志分析

2、内存处理不当、内存泄露

3、主线程UI长时间卡死,系统强制销毁app

死锁?

4、多线程切换访问引起的crash

多线程抢写数据库?

5、和服务端约定的数据结构变更导致操作数据类型问题、或者操作空

二、崩溃日志的获取

1、iOS设备可以直接查看

路径:
ios 10之后:设置 -> 隐私 -> 分析 -> 数据分析
ios 10之前:设置 -> 隐私 -> 诊断与用量

2、链接设备到电脑 Itunes同步后,日志会保存在电脑上

mac路径:~/Library/Logs/CrashReporter/MobileDevice/
可以看到所有和该电脑同步过的设备的崩溃日志(.crash文件)

为什么有部分crash无法收集到?

3、xcode获取

xcode查看设备日志并导出日志
  
Window - Devices - 选择设备 - 点击View Device Logs -> All logs可以看到所有的崩溃日志。

4、线上的崩溃,没有设备

1、三方:bugly、crashlytics
2、后台、异步线程将上面描述的捕获到的崩溃上传服务器
3、线上的ITC上可能会有部分日志,可以通过Xcode同步下来崩溃与能耗日志
Xcode Window - Organizer - Crashes

iOS崩溃与日志分析

三、崩溃日志的解析

1、崩溃日志的实例

下面是一份测试过程产生的崩溃日志
//进程信息
Incident Identifier: 3C3F8BF8-3099-4E82-92E1-8690212E8FF9
CrashReporter Key:   bb5f9839ae661ab755f25eff65fee8fd41369628
Hardware Model:      iPod5,1
Process:             demo [973]
Path:                /private/var/containers/Bundle/Application/0D3657DE-DE1E-4FF0-A0F7-C09EBC002763/demo.app/demo
Identifier:          com.yanghuang.demo
Version:             17 (1.1.9)
Code Type:           ARM (Native)
Parent Process:      launchd [1]
//基本信息
Date/Time:           2017-08-22 16:11:49.49 +0800
Launch Time:         2017-08-22 16:11:40.40 +0800
OS Version:          iOS 9.3.5 (13G36)
Report Version:      104
//异常
Exception Type:  EXC_BREAKPOINT (SIGTRAP)
Exception Codes: 0x0000000000000001, 0x00000000e7ffdefe
Triggered by Thread:  0

Filtered syslog:
None found
//线程回溯
Thread 0 name:  Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0   libswiftCore.dylib              0x0033788c 0x1ac000 + 1620108
1   ...wiftSwiftOnoneSupport.dylib  0x009b4830 0x9ac000 + 34864
2   demo                            0x00029288 0x24000 + 21128
3   demo                            0x00029414 0x24000 + 21524
4   UIKit                           0x25cd2754 0x25c87000 + 309076
5   UIKit                           0x25cd26e0 0x25c87000 + 308960
6   UIKit                           0x25cba6d2 0x25c87000 + 210642
7   UIKit                           0x25cd2004 0x25c87000 + 307204
8   UIKit                           0x25cd1c7e 0x25c87000 + 306302
9   UIKit                           0x25cca68e 0x25c87000 + 276110
10  UIKit                           0x25c9b124 0x25c87000 + 82212
11  UIKit                           0x25c996d2 0x25c87000 + 75474
12  CoreFoundation                  0x216e1dfe 0x21626000 + 769534
13  CoreFoundation                  0x216e19ec 0x21626000 + 768492
14  CoreFoundation                  0x216dfd5a 0x21626000 + 761178
15  CoreFoundation                  0x2162f228 0x21626000 + 37416
16  CoreFoundation                  0x2162f014 0x21626000 + 36884
17  GraphicsServices                0x22c1fac8 0x22c16000 + 39624
18  UIKit                           0x25d03188 0x25c87000 + 508296
19  demo                            0x0002ff48 0x24000 + 48968
20  libdyld.dylib                   0x212d7872 0x212d5000 + 10354

Thread 1 name:  Dispatch queue: com.apple.libdispatch-manager
Thread 1:
0   libsystem_kernel.dylib          0x213ac2f8 0x21396000 + 90872
1   libdispatch.dylib               0x212a1d60 0x2128b000 + 93536
2   libdispatch.dylib               0x212a1abe 0x2128b000 + 92862

... 省略部分内容

//二进制映像
Binary Images
0x24000 - 0x33fff demo armv7  
<aa31c8c1f8cb333596dbfe056b120673>
  /var/containers/Bundle/Application/0D3657DE-DE1E-4FF0-A0F7-C09EBC002763/demo.app/demo
0x140000 - 0x15bfff Masonry armv7  <9615e97c54d335f7821568396c65d324> /var/containers/Bundle/Application/0D3657DE-DE1E-4FF0-A0F7-C09EBC002763/demo.app/Frameworks/Masonry.framework/Masonry
</aa31c8c1f8cb333596dbfe056b120673>

1.进程信息

第一部分是闪退进程的相关信息。

Incident Identifier 是崩溃报告的唯一标识符。

CrashReporter Key 是与设备标识相对应的唯一键值。虽然它不是真正的设备标识符,但也是一个非常有用的情报:如果你看到100个崩溃日志的CrashReporter Key值都是相同的,或者只有少数几个不同的CrashReport值,说明这不是一个普遍的问题,只发生在一个或少数几个设备上。

Hardware Model 标识设备类型。 如果很多崩溃日志都是来自相同的设备类型,说明应用只在某特定类型的设备上有问题。上面的日志里,崩溃日志产生的设备是iPhone 4s。

Process 是应用名称。中括号里面的数字是闪退时应用的进程ID。

2.基本信息

这部分给出了一些基本信息,包括闪退发生的日期和时间,设备的iOS版本。如果有很多崩溃日志都来自iOS 6.0,说明问题只发生在iOS 6.0上。

Version: 崩溃进程的版本号. 这个值包含在 CFBundleVersion and CFBundleVersionString中.

Code Type: 崩溃日志所在设备的架构. 会是ARM-64, ARM, x86-64, or x86中的一个.

OS Version: 崩溃发生时的系统版本

3.异常信息

异常信息会列出异常的类型、位置。

在这部分,你可以看到闪退发生时抛出的异常类型。还能看到异常编码和抛出异常的线程。根据崩溃报告类型的不同,在这部分你还能看到一些另外的信息。

Exception Codes: 处理器的具体信息有关的异常编码成一个或多个64位进制数。通常情况下,这个区域不会被呈现,因为将异常代码解析成人们可以看懂的描述是在其它区域进行的。

Exception Subtype: 供人们可读的异常代码的名字

Exception Message: 从异常代码中提取的额外的可供人们阅读的信息.

Exception Note: 不是特定于一个异常类型的额外信息.如果这个区域包含SIMULATED (这不是一个崩溃)然后进程没有崩溃,但是被watchdog杀掉了

Termination Reason: 当一个进程被终止的时的原因。

Triggered by Thread: 异常所在的线程.

4.线程回溯

这部分提供应用中所有线程的回溯日志。 回溯是闪退发生时所有活动帧清单。它包含闪退发生时调用函数的清单。看下面这行日志:

2   demo     0x00029288 0x24000 + 21128

它包括四列:

帧编号—— 此处是2。

二进制库的名称 ——此处是 demo.

调用方法的地址 ——此处是 0x00029288.

第四列分为两个子列,一个基本地址和一个偏移量。此处是0×0x24000 + 21128, 第一个数字指向文件,第二个数字指向文件中的代码行。

5.二进制映像

这部分列出了闪退时已经加载的二进制文件。

2、符号化Symbolication

iOS崩溃与日志分析

第一次看到崩溃日志上的回溯时,你或许会觉得它没什么意义。我们习惯使用方法名和行数,而非像这样的神秘位置:

2   demo     0x00029288 0x24000 + 21128

将这些十六进制地址转化成方法名称和行数的过程称之为符号化。

从Xcode的Organizer窗口获取崩溃日志后过几秒钟,崩溃日志将被自动符号化。上面那行被符号化后的版本如下 :

2   demo     0x00029288 ViewController.crashAction(Any) -> () (ViewController.swift:36)

Xcode符号化崩溃日志时,需要访问与App Store上对应的应用二进制文件以及生成二进制文件时产生的 .dSYM 文件。必需完全匹配才行。否则,日志将无法被完全符号化。

所以,保留每个分发给用户的编译版本非常重要。提交应用前进行归档时,Xcode将保存应用的二进制文件。可以在Xcode Organizer的Archives标签栏下找到所有已归档的应用文件。

在发现崩溃日志时,如果有相匹配的.dSYM文件和应用二进制文件,Xcode会自动对崩溃日志进行符号化。如果你换到别的电脑或创建新的账户,务必将所有二进制文件移动到正确的位置,使Xcode能找到它们。

注意:

1、你必需同时保留应用二进制文件和.dSYM文件才能将崩溃日志完整符号化。每次提交到iTunes Connect的构建都必需归档。 .dSYM文件和二进制文件是特定绑定于每一次构建和后续构建的,即使来自相同的源代码文件,每一次构建也与其他构建不同,不能相互替换。如果你使用Build 和 Archive 命令,这些文件会自动放在适当位置。 如果不是使用Build 和 Archive命令,放在Spotlight能够搜索到的位置(比如Home目录)即可。

2、xcode debug方式打包默认没有DSYM文件,只需要修改对应的build options即可

build settings -> build options

把debug 项改成 DWARF with dSYM File 即可

如何通过.crash文件反编译得到明文的crash文件

需要文件:

1、demo.app

2、demo.app.dSYM

3、demo.crash (已获得)

4、symbolicatecrash

符号化前先检查一下三者的uuid是不是一致的,只有是一致的才能符号化成功。

查看xx.app文件的uuid的方法:

dwarfdump --uuid xxx.app/xxx (xxx工程名)

查看xx.app.dSYM文件的uuid的方法令:

dwarfdump --uuid xxx.app.dSYM (xxx工程名)

而.crash的uuid位于,crash日志中的Binary Images:中的第一行尖括号内。如:armv7<8bdeaf1a0b233ac199728c2a0ebb4165>

步骤如下:

1、首先我们找到Archives目录(/Users/用户名/Library/Developer/Xcode/Archives/2017-08-22/demo)

2、找到对应app目录、对应的Archives文件、显示包内容打开。在dSYMs文件夹中找到demo.app.dSYM

在Products->Applications文件夹中找到 demo.app

3、找到symbolicatecrash

find /Applications/Xcode.app -name symbolicatecrash -type f
//终端输入上面命令、得到一个路径,这个路径就是symbolicatecrash的路径
拷贝到和上面文件同一目录

3: 在终端中输入以下命令

./symbolicatecrash -v demo.crash demo.app.dSYM

如果出现Error: "DEVELOPER_DIR" is not defined 再执行下面一句后再次执行

export DEVELOPER_DIR="/Applications/XCode.app/Contents/Developer"

然后用控制台打开你的demo.crash文件, 你就会看到编译后的crash文件,  同Xcode看到的崩溃日志一致。通过查看崩溃日志,可以轻易的找到崩溃原因并修正。

Thread 0 name:  Dispatch queue: com.apple.main-thread
Thread 0 Crashed:
0   libswiftCore.dylib              0x0033788c 0x1ac000 + 1620108
1   ...wiftSwiftOnoneSupport.dylib  0x009b4830 0x9ac000 + 34864
2   demo                            0x00029288 ViewController.crashAction(Any) -> () (ViewController.swift:36)
3   demo                            0x00029414 @objc ViewController.crashAction(Any) -> () (ViewController.swift:0)
4   UIKit                           0x25cd2754 -[UIApplication sendAction:to:from:forEvent:] + 80
5   UIKit                           0x25cd26e0 -[UIControl sendAction:to:forEvent:] + 64
6   UIKit                           0x25cba6d2 -[UIControl _sendActionsForEvents:withEvent:] + 466
7   UIKit                           0x25cd2004 -[UIControl touchesEnded:withEvent:] + 604
8   UIKit                           0x25cd1c7e -[UIWindow _sendTouchesForEvent:] + 646
9   UIKit                           0x25cca68e -[UIWindow sendEvent:] + 642
10  UIKit                           0x25c9b124 -[UIApplication sendEvent:] + 204
11  UIKit                           0x25c996d2 _UIApplicationHandleEventQueue + 5010
12  CoreFoundation                  0x216e1dfe __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 14
13  CoreFoundation                  0x216e19ec __CFRunLoopDoSources0 + 452
14  CoreFoundation                  0x216dfd5a __CFRunLoopRun + 794
15  CoreFoundation                  0x2162f228 CFRunLoopRunSpecific + 520
16  CoreFoundation                  0x2162f014 CFRunLoopRunInMode + 108
17  GraphicsServices                0x22c1fac8 GSEventRunModal + 160
18  UIKit                           0x25d03188 UIApplicationMain + 144
19  demo                            0x0002ff48 main (AppDelegate.swift:13)
20  libdyld.dylib                   0x212d7872 start + 2

3、低内存闪退

因为低内存崩溃日志与普通崩溃日志略有不同。

iOS设备检测到低内存时,虚拟内存系统发出通知请求应用释放内存。这些通知发送到所有正在运行的应用和进程,试图收回一些内存。

如果内存使用依然居高不下,系统将会终止后台线程以缓解内存压力。如果可用内存足够,应用将能够继续运行而不会产生崩溃报告。否则,应用将被iOS终止,并产生低内存崩溃报告。

低内存崩溃日志上没有应用线程的堆栈回溯。相反,上面显示的是以内存页数为单位的各进程内存使用量。

被iOS因释放内存页终止的进程名称后面你会看到jettisoned 字样。如果看到它出现在你的应用名称后面,说明你的应用因使用太多内存而被终止了。

低内存崩溃日志看起来像这样:

iOS崩溃与日志分析

4、异常编码

通常,异常编码以一些文字开头,紧接着是一个或多个十六进制值,此数值正是说明闪退根本性质的所在。  从这些编码中,可以区分出闪退是因为程序错误、非法内存访问或者是其他原因。

下面是一些常见的异常编码:

0x8badf00d: 该编码表示应用是因为发生watchdog超时而被iOS终止的。  通常是应用花费太多时间而无法启动、终止或响应用系统事件。

0xbad22222: 该编码表示 VoIP 应用因为过于频繁重启而被终止。

0xdead10cc: 该代码表明应用因为在后台运行时占用系统资源,如通讯录数据库不释放而被终止 。

0xdeadfa11: 该代码表示应用是被用户强制退出的。根据苹果文档, 强制退出发生在用户长按开关按钮直到出现 “滑动来关机”, 然后长按 Home按钮。强制退出将产生 包含0xdeadfa11 异常编码的崩溃日志, 因为大多数是强制退出是因为应用阻塞了界面。

EXC_CRASH // SIGABRT: 进程异常退出。该异常类型崩溃最常见的原因是未捕获的Objective-C和C++异常和调用abort()。如果他们需要太多的时间来初始化,程序将被终止,因为触发了看门狗。如果是因为启动的时候被挂起,所产生的崩溃报告异常类型(Exception Subtype)将是launch_hang。

EXC_BREAKPOINT // SIGTRAP:进程试图执行非法或未定义指令。这个进程可能试图通过一个配置错误的函数指针,跳到一个无效的地址。

SIGSEGV、SIGBUS 这些在前面捕获异常的内容有描述

注意: 在后台任务列表中关闭已挂起的应用不会产生崩溃日志。 一旦应用被挂起,它何时被终止都是合理的。所以不会产生崩溃日志。

四、线上问题的处理

思考:

1、工作中是否有遇到过线上反馈回来的问题,你是如何定位问题处理的

2、如果有特殊账户才出现的问题,但是又拿不到用户账户,该如何处理

符号化

官方文档

参考文档

参考文档

更详细的参考文档

作者:就叫yang

链接:https://www.jianshu.com/p/5b132f0e31a3


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Usability for the Web

Usability for the Web

Tom Brinck、Darren Gergle、Scott D. Wood / Morgan Kaufmann / 2001-10-15 / USD 65.95

Every stage in the design of a new web site is an opportunity to meet or miss deadlines and budgetary goals. Every stage is an opportunity to boost or undercut the site's usability. Thi......一起来看看 《Usability for the Web》 这本书的介绍吧!

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

URL 编码/解码
URL 编码/解码

URL 编码/解码