一直在忙, 也沒寫過幾次博客! 但一直熱衷于直播開發(fā)技術(shù), 公司又不是直播方向的, 所以就年前忙里偷閑研究了一下直播開發(fā), 然后翻閱了很多大神的技術(shù)博客等, 寫了一個簡單的Demo, 又根據(jù)網(wǎng)上大神們的技術(shù)博客搭建了簡易的本地RTMP服務器! 由于時間問題, 沒來記得來記錄下來, 目前demo 只完成了直播音視頻采集, 轉(zhuǎn)碼, RTMP協(xié)議推流, 和本地RTMP簡易服務器 推流這一環(huán)節(jié), 拉流還沒來得及寫, RTMP流的播放用的是VLC, 來實現(xiàn)視頻流的播放的!
網(wǎng)上有各種大牛寫的播客, 都很好的, 但我寫這篇播客的目的就是, 想記錄一下當時的思路, 還有分享出來, 讓各位大神指點一下不足之處, 來完善這個小項目! 表達一下我對直播開發(fā)的熱愛哈哈...如果有幸能給大家?guī)托┟? 我倍感榮幸!
?該篇文章只是對直播需要了解的一些概念等的一下介紹, 然后還有如何進行視頻采集! 近期有時間會逐步一點一點詳細的寫文章來介紹!
計劃步驟下:
好, 廢話不多說, 接下來我們直接開始!
iOS-直播開發(fā)(開發(fā)從底層做起)
1. ?iOS直播開發(fā)(開發(fā)從底層做起)之音視頻采集
2. ?iOS-直播開發(fā)(開發(fā)從底層做起) --- 音視頻硬編碼
3. ?iOS-直播開發(fā)(開發(fā)從底層做起) --- RTMP 協(xié)議推流
4. ?iOS-直播開發(fā)(開發(fā)從底層做起) --- nginx 直播本地服務器搭建
等等
來一張實戰(zhàn)圖
代碼鏈接: Github: https://github.com/jessonliu/JFLivePlaye
技術(shù)部分------ ??
腦涂: ![ 直播思維導圖.png ]
視頻直播的大概流程就上腦涂上所畫的, 還有一些沒列出來, 比如, 聊天, 送禮, 踢出, 禁言, 等等一系列功能, 但本文只是針對視頻直播的簡單實現(xiàn)!
下邊來說一下以下的幾個點和使用到的類(后邊會附上demo, 里邊還有詳細的備注)
> 1. 音視頻采集
音視頻采集, 網(wǎng)上也有很多大神些的技術(shù)博客, demo 等, 我這里邊只針對iOS 原聲的來介紹以下
利用AVFoundation框架, 進行音視頻采集
AVCaptureSession ? ? ? ? ? ? ? ? ? ? ? // 音視頻錄制期間管理者
AVCaptureDevice ? ? ? ? ? ? ? ? ? ? ? ? // 設備管理者, (用來操作所閃光燈, 聚焦, 攝像頭切換等)
AVCaptureDeviceInput ? ? ? ? ? ? ? ?// 音視頻輸入數(shù)據(jù)的管理對象
AVCaptureVideoDataOutput ? ? ? ?// 視頻輸出數(shù)據(jù)的管理者
AVCaptureAudioDataOutput ? ? ? ?// 音頻輸出數(shù)據(jù)的管理者
AVCaptureVideoPreviewLayer ? ? // 用來展示視頻的圖像
注意, 必須要設置音視頻輸出對象的代理方法, 然后在代理方法中獲取sampleBuffer, 然后判斷captureOutput是音頻還是視頻, 來進行音視頻數(shù)據(jù)相應的編碼
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
}
也可以利用GPUImageVideoCamera 來進行視頻數(shù)據(jù)的采集獲取, 可以利用GPUImage 進行美顏, 添加水印, 人臉識別等
2.流媒體
流媒體是指采用流式傳輸?shù)姆绞皆诰W(wǎng)上播放的媒體格式, 是邊傳邊播的媒體,是多媒體的一種!
然后就是大家需要了解的幾個關(guān)鍵詞
幀:視頻是由很多連續(xù)圖像組成, 每一幀就代表一幅靜止的圖像
GOP:(Group of Pictures)畫面組熏瞄,一個GOP就是一組連續(xù)的畫面匆骗,每個畫面都是一幀,GOP就是很多幀的集合!
幀的分類:I幀、P幀军俊、B幀
為了提高壓縮比例赤炒,降低視頻文件的大小,在針對連續(xù)動態(tài)圖像編碼時登失,一般會將連續(xù)若干幅圖像編碼為P、B挖炬、I三種幀類型
I幀:一組連續(xù)畫面(GOP)的第一個幀, I幀采用幀內(nèi)壓縮法(也成關(guān)鍵幀壓縮法), I幀的壓縮不依靠與其他幀, 靠盡可能去除圖像空間冗余信息來壓縮的, 可以單獨作為圖像!
P幀:預測幀(也叫前向參考幀), P幀的壓縮依賴于前一幀, 通過充分降低與圖像序列中前面已編碼幀的時間冗余信息來壓縮傳輸數(shù)據(jù)量的編碼圖像!
B幀:也叫雙向預測幀, 當把一幀壓縮成B幀時揽浙,它根據(jù)鄰近的前幾幀、本幀以及后幾幀數(shù)據(jù)的不同點來壓縮本幀意敛,也即僅記錄本幀與前后幀的差值馅巷。
幀率:就是在1秒鐘時間里傳輸?shù)膱D片的幀數(shù),也可以理解為圖形處理器每秒鐘能夠刷新幾次草姻,通常用FPS表示, 每秒鐘幀數(shù) (fps) 愈多钓猬,所顯示的動作就會愈流暢!
碼率: ? 也成為比特率, 是指每秒傳送的比特(bit)數(shù), 比特率越高,傳送數(shù)據(jù)速度越快, 單位為 bps(Bit Per Second)撩独。
3. 音視頻的編解碼
音視頻編解碼, 說白了就是對音視頻數(shù)據(jù)進行壓縮, 減少數(shù)據(jù)對空間的占用, 便于網(wǎng)絡傳輸, 存儲和使用!
目前直播常用的音視頻編解碼方式是h.264/AVC, AAC/MP3
硬軟編解碼的區(qū)別:
硬解碼:由顯卡核心GPU來對高清視頻進行解碼工作敞曹,CPU占用率很低,畫質(zhì)效果比軟解碼略差一點综膀,需要對播放器進行設置澳迫。
優(yōu)點:播放流暢、低功耗
缺點:受視頻格式限制剧劝、功耗大橄登、畫質(zhì)沒有軟解碼好
軟解碼:由CPU負責解碼進行播放
優(yōu)點:不受視頻格式限制、畫質(zhì)略好于硬解
缺點:會占用過高的資源担平、對于高清視頻可能沒有硬解碼流暢(主要看CPU的能力)
蘋果API有提供音視頻硬編解碼接口, 但只針對iOS8.0以上版本!
利用VideoToolbox 和AudioToolbox 這連個框架進行音視頻的硬編碼!
這里附上前輩們的關(guān)于VideoToolbox使用的簡書, http://www.reibang.com/p/6dfe49b5dab8
和AudioToolbox的技術(shù)簡書http://www.reibang.com/p/a671f5b17fc1
感興趣的話可以研究一下!
4.流媒體數(shù)據(jù)封裝
TS: 是流媒體封裝格式的一種示绊,流媒體封裝的好處就是不需要加載索引再播放,大大降低了首次載入的延遲,兩個TS片段可以無縫拼接暂论,播放器能連續(xù)播放!
FLV: 也是一種流媒體的封裝格式,但他形成的文件極小面褐、加載速度極快,使得網(wǎng)絡觀看視頻文件成為可能,因此FLV格式成為了當今主流視頻格式
5.RTMP推流
大家先看一張圖, 常用的直播協(xié)議比較
這里只介紹一下RTMP協(xié)議, 如果還想了解更多的可在網(wǎng)上查找一下, 有很多關(guān)于流媒體協(xié)議的技術(shù)博客!
RTMP協(xié)議是基于TCP/IP 的協(xié)議簇;RTMP(Real Time Messaging Protocol)實時消息傳送協(xié)議是Adobe Systems公司為Flash播放器和服務器之間音頻取胎、視頻和數(shù)據(jù)傳輸 開發(fā)的開放協(xié)議
它有多種變種:
a, RTMP工作在TCP之上展哭,默認使用端口1935;
b, RTMPE在RTMP的基礎上增加了加密功能闻蛀;
c, RTMPT封裝在HTTP請求之上匪傍,可穿透防火墻;
d, RTMPS類似RTMPT觉痛,增加了TLS/SSL的安全功能役衡;
它是一個互聯(lián)網(wǎng)TCP/IP體系結(jié)構(gòu)中應用層的協(xié)議。RTMP協(xié)議中基本的數(shù)據(jù)單元稱為消息(Message)薪棒。當RTMP協(xié)議在互聯(lián)網(wǎng)中傳輸數(shù)據(jù)的時候手蝎,消息會被拆分成更小的單元榕莺,稱為消息塊(Chunk)。RTMP傳輸媒體數(shù)據(jù)的過程中棵介,發(fā)送端首先把媒體數(shù)據(jù)封裝成消息钉鸯,然后把消息分割成消息塊,最后將分割后的消息塊通過TCP協(xié)議發(fā)送出去邮辽。接收端在通過TCP協(xié)議收到數(shù)據(jù)后唠雕,首先把消息塊重新組合成消息,然后通過對消息進行解封裝處理就可以恢復出媒體數(shù)據(jù)吨述。
播放一個RTMP協(xié)議的流媒體需要經(jīng)過以下幾個步驟:握手岩睁,建立連接,建立流揣云,播放笙僚。
demo中RTMP協(xié)議推流, 用的是librtmp-iOS框架! 參考https://my.oschina.net/jerikc/blog/501948
6. 播放器
IJKPlayer 是一個基于 ffplay 的輕量級 Android/iOS 視頻播放器。API 易于集成灵再;編譯配置可裁剪,方便控制安裝包大幸隗浴翎迁;支持 硬件加速解碼,更加省電净薛。而DanmakuFlameMaster(開源彈幕框架) 架構(gòu)清晰汪榔,簡單易用,支持多種高效率繪制方式選擇肃拜,支持多種自定義功能設置!
代碼:
#import "JFLiveShowVC.h" 該類負責音視頻采集及展示, 用于時間沒問題, 沒有吧音視頻采集單獨拿出來封裝!
`
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
// 需要用到的線程
videoProcessingQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
audioProcessingQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
_jfEncodeQueue_video = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
_jfEncodeQueue_audio = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
// 檢查權(quán)限和設備
[self checkDeviceAuth];
// 數(shù)據(jù)保存路徑
self.documentDictionary = [(NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask, YES)) objectAtIndex:0];
// 音頻編碼對象初始化
self.audioEncoder = [[AACEncoder alloc] init];
self.audioEncoder.delegate = self; ? ?// 設置代理
self.videoEncoder = [[JFVideoEncoder alloc] init]; // 視頻編碼對象初始化
self.videoEncoder.delegate = self; ? ? ? ? ? ? ? ? ? ? ? ? // 設置代理
_lock = dispatch_semaphore_create(1); // 當并行執(zhí)行的處理更新數(shù)據(jù)時,會產(chǎn)生數(shù)據(jù)不一致的情況,使用Serial Dipatch queue 進行同步, 控制并發(fā)
}
// 檢查是否授權(quán)攝像頭的使用權(quán)限
- (void)checkDeviceAuth {
switch ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]) {
case AVAuthorizationStatusAuthorized: ? // 已授權(quán)
NSLog(@"已授權(quán)");
[self initAVCaptureSession];
break;
case AVAuthorizationStatusNotDetermined: ? ?// 用戶尚未進行允許或者拒絕,
{
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
if (granted) {
NSLog(@"已授權(quán)");
[self initAVCaptureSession];
} else {
NSLog(@"用戶拒絕授權(quán)攝像頭的使用, 返回上一頁, 請打開--> 設置 -- > 隱私 --> 通用等權(quán)限設置");
}
}];
}
break;
default:
{
NSLog(@"用戶尚未授權(quán)攝像頭的使用權(quán)");
}
break;
}
}
// 初始化 管理者
- (void)initAVCaptureSession {
self.session = [[AVCaptureSession alloc] init];
// 設置錄像的分辨率
// 先判斷是被是否支持要設置的分辨率
if ([self.session canSetSessionPreset:AVCaptureSessionPreset1280x720]) {
// 如果支持則設置
[self.session canSetSessionPreset:AVCaptureSessionPreset1280x720];
} else if ([self.session canSetSessionPreset:AVCaptureSessionPresetiFrame960x540]) {
[self.session canSetSessionPreset:AVCaptureSessionPresetiFrame960x540];
} else if ([self.session canSetSessionPreset:AVCaptureSessionPreset640x480]) {
[self.session canSetSessionPreset:AVCaptureSessionPreset640x480];
}
// 開始配置
[self.session beginConfiguration];
// 初始化視頻管理
self.videoDevice = nil;
// 創(chuàng)建攝像頭類型數(shù)組
NSArray *devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
// 便利管理抓捕道德所有支持制定類型的 設備集合
for (AVCaptureDevice *device in devices) {
if (device.position == AVCaptureDevicePositionFront) {
self.videoDevice = device;
}
}
// 視頻
[self videoInputAndOutput];
// 音頻
[self audioInputAndOutput];
// 錄制的同時播放
[self initPreviewLayer];
// 提交配置
[self.session commitConfiguration];
}
// 視頻輸入輸出
- (void)videoInputAndOutput {
NSError *error;
// 視頻輸入
// 初始化 根據(jù)輸入設備來初始化輸出對象
self.videoInput = [[AVCaptureDeviceInput alloc] initWithDevice:self.videoDevice error:&error];
if (error) {
NSLog(@"-- 攝像頭出錯 -- %@", error);
return;
}
// 將輸入對象添加到管理者 -- AVCaptureSession 中
// 先判斷是否能搞添加輸入對象
if ([self.session canAddInput:self.videoInput]) {
// 管理者能夠添加 才可以添加
[self.session addInput:self.videoInput];
}
// 視頻輸出
// 初始化 輸出對象
self.videoOutput = [[AVCaptureVideoDataOutput alloc] init];
// 是否允許卡頓時丟幀
self.videoOutput.alwaysDiscardsLateVideoFrames = NO;
if ([self supportsFastTextureUpload])
{
// 是否支持全頻色彩編碼 YUV 一種色彩編碼方式, 即YCbCr, 現(xiàn)在視頻一般采用該顏色空間, 可以分離亮度跟色彩, 在不影響清晰度的情況下來壓縮視頻
BOOL supportsFullYUVRange = NO;
// 獲取輸出對象 支持的像素格式
NSArray *supportedPixelFormats = self.videoOutput.availableVideoCVPixelFormatTypes;
for (NSNumber *currentPixelFormat in supportedPixelFormats)
{
if ([currentPixelFormat intValue] == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)
{
supportsFullYUVRange = YES;
}
}
// 根據(jù)是否支持 來設置輸出對象的視頻像素壓縮格式,
if (supportsFullYUVRange)
{
[self.videoOutput setVideoSettings:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange] forKey:(id)kCVPixelBufferPixelFormatTypeKey]];
}
else
{
[self.videoOutput setVideoSettings:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange] forKey:(id)kCVPixelBufferPixelFormatTypeKey]];
}
}
else
{
[self.videoOutput setVideoSettings:[NSDictionary dictionaryWithObject:[NSNumber numberWithInt:kCVPixelFormatType_32BGRA] forKey:(id)kCVPixelBufferPixelFormatTypeKey]];
}
// 設置代理
[self.videoOutput setSampleBufferDelegate:self queue:videoProcessingQueue];
// 判斷管理是否可以添加 輸出對象
if ([self.session canAddOutput:self.videoOutput]) {
[self.session addOutput:self.videoOutput];
AVCaptureConnection *connection = [self.videoOutput connectionWithMediaType:AVMediaTypeVideo];
// 設置視頻的方向
connection.videoOrientation = AVCaptureVideoOrientationPortrait;
// 視頻穩(wěn)定設置
if ([connection isVideoStabilizationSupported]) {
connection.preferredVideoStabilizationMode = AVCaptureVideoStabilizationModeAuto;
}
connection.videoScaleAndCropFactor = connection.videoMaxScaleAndCropFactor;
}
}
// 音頻輸入輸出
- (void)audioInputAndOutput {
NSError *jfError;
// 音頻輸入設備
self.audioDevice = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeAudio];
// 音頻輸入對象
self.audioInput = [[AVCaptureDeviceInput alloc] initWithDevice:self.audioDevice error:&jfError];
if (jfError) {
NSLog(@"-- 錄音設備出錯 -- %@", jfError);
}
// 將輸入對象添加到 管理者中
if ([self.session canAddInput:self.audioInput]) {
[self.session addInput:self.audioInput];
}
// 音頻輸出對象
self.audioOutput = [[AVCaptureAudioDataOutput alloc] init];
// 將輸出對象添加到管理者中
if ([self.session canAddOutput:self.audioOutput]) {
[self.session addOutput:self.audioOutput];
}
// 設置代理
[self.audioOutput setSampleBufferDelegate:self queue:audioProcessingQueue];
}
// 播放同時進行播放
- (void)initPreviewLayer {
[self.view layoutIfNeeded];
// 初始化對象
self.previewLayer = [[AVCaptureVideoPreviewLayer alloc] initWithSession:self.session];
self.previewLayer.frame = self.view.layer.bounds;
self.previewLayer.connection.videoOrientation = [self.videoOutput connectionWithMediaType:AVMediaTypeVideo].videoOrientation;
self.previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill;
self.previewLayer.position = CGPointMake(self.liveView.frame.size.width*0.5,self.liveView.frame.size.height*0.5);
CALayer *layer = self.liveView.layer;
layer.masksToBounds = true;
[layer addSublayer:self.previewLayer];
}
#pragma mark 返回上一級
- (IBAction)backAction:(id)sender {
// 結(jié)束直播
[self.socket stop];
[self.session stopRunning];
[self.videoEncoder stopEncodeSession];
fclose(_h264File);
fclose(_aacFile);
[self.navigationController popViewControllerAnimated:YES];
}
#pragma mark 開始直播
- (IBAction)startLiveAction:(UIButton *)sender {
_h264File = fopen([[NSString stringWithFormat:@"%@/jf_encodeVideo.h264", self.documentDictionary] UTF8String], "wb");
_aacFile = fopen([[NSString stringWithFormat:@"%@/jf_encodeAudio.aac", self.documentDictionary] UTF8String], "wb");
// 初始化 直播流信息
JFLiveStreamInfo *streamInfo = [[JFLiveStreamInfo alloc] init];
streamInfo.url = @"rtmp://192.168.1.110:1935/rtmplive/room";
self.socket = [[JFRtmpSocket alloc] initWithStream:streamInfo];
self.socket.delegate = self;
[self.socket start];
// 開始直播
[self.session startRunning];
sender.hidden = YES;
}
#pragma mark -- ?AVCaptureAudioDataOutputSampleBufferDelegate
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
if (captureOutput == self.audioOutput) {
[self.audioEncoder encodeSampleBuffer:sampleBuffer timeStamp:self.currentTimestamp completionBlock:^(NSData *encodedData, NSError *error) {
fwrite(encodedData.bytes, 1, encodedData.length, _aacFile);
}];
} else {
[self.videoEncoder encodeWithSampleBuffer:sampleBuffer timeStamp:self.currentTimestamp completionBlock:^(NSData *data, NSInteger length) {
fwrite(data.bytes, 1, length, _h264File);
}];
}
}
- (void)dealloc {
if ([self.session isRunning]) {
[self.session stopRunning];
}
[self.videoOutput setSampleBufferDelegate:nil queue:dispatch_get_main_queue()];
[self.audioOutput setSampleBufferDelegate:nil queue:dispatch_get_main_queue()];
}
// 是否支持快速紋理更新
- (BOOL)supportsFastTextureUpload;
{
#if TARGET_IPHONE_SIMULATOR
return NO;
#else
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wtautological-pointer-compare"
return (CVOpenGLESTextureCacheCreate != NULL);
#pragma clang diagnostic pop
#endif
}
// 保存h264數(shù)據(jù)到文件
- (void) writeH264Data:(void*)data length:(size_t)length addStartCode:(BOOL)b
{
// 添加4字節(jié)的 h264 協(xié)議 start code
const Byte bytes[] = "\x00\x00\x00\x01";
if (_h264File) {
if(b)
fwrite(bytes, 1, 4, _h264File);
fwrite(data, 1, length, _h264File);
} else {
NSLog(@"_h264File null error, check if it open successed");
}
}
#pragma mark - JFRtmpSocketDelegate
- (void)jf_videoEncoder_call_back_videoFrame:(JFVideoFrame *)frame {
if (self.uploading) {
[self.socket sendFrame:frame];
}
}
#pragma mark - AACEncoderDelegate
- (void)jf_AACEncoder_call_back_audioFrame:(JFAudioFrame *)audionFrame {
if (self.uploading) {
[self.socket sendFrame:audionFrame];
}
}
#pragma mark -- JFRtmpSocketDelegate
- (void)socketStatus:(nullable JFRtmpSocket *)socket status:(JFLiveState)status {
switch (status) {
case JFLiveReady:
NSLog(@"準備");
break;
case JFLivePending:
NSLog(@"鏈接中");
break;
case JFLiveStart:
NSLog(@"已連接");
if (!self.uploading) {
self.timestamp = 0;
self.isFirstFrame = YES;
self.uploading = YES;
}
break;
case JFLiveStop:
NSLog(@"已斷開");
break;
case JFLiveError:
NSLog(@"鏈接出錯");
self.uploading = NO;
self.isFirstFrame = NO;
self.uploading = NO;
break;
default:
break;
}
}
// 獲取當前時間戳
- (uint64_t)currentTimestamp{
dispatch_semaphore_wait(_lock, DISPATCH_TIME_FOREVER);
uint64_t currentts = 0;
if(_isFirstFrame == true) {
_timestamp = NOW;
_isFirstFrame = false;
currentts = 0;
}
else {
currentts = NOW - _timestamp;
}
dispatch_semaphore_signal(_lock);
return currentts;
}
`
// 注: 必須控制好線程, 不然很容易出現(xiàn)卡死或閃退的情況!
關(guān)于音視頻編解碼的代碼, 就不在這里展示了, 放在demo 中, 有需要的話話可以下載!
Github: https://github.com/jessonliu/JFLivePlaye
本地流媒體服務器的搭建這個給大家一個連接: http://www.reibang.com/p/8ea016b2720e
以上就是直播開發(fā)中所要設計道德知識點和一些第三方框架, 如果全是用第三方的話, 就會省事很多, 用起來也很方便, 但我個人比較喜歡刨根問題, 想了解原理!
如果寫的不妥當或不足的地方, 希望大神指正和補充! 由于前段時間比較忙, 拉流, 解碼和播放還沒來得及寫, 我在直播的路上還有很長的路要走, 還需要不斷地學習提高, 了解更底層的東西, 才能更好的掌握直播的整個流程技術(shù), 后期寫完會更新一個完整的Demo!