Android HLS协议相关记录及部分解析
来源:互联网 发布:篆刻印章制作软件 编辑:程序博客网 时间:2024/05/16 09:04
github:AndroidVideoServer(参考库)
Android 底层实现HLS协议的部分解析
由于目前网络不好,暂时先记录想到的,因为HLS是最近开始学习研究的,害怕最近项目忙忘记,所以先记录下(以下位于LibStageFright):
- Android.mk
- HTTPDownloader.cpp
- HTTPDownloader.h
- LiveDataSource.cpp
- LiveDataSource.h
- LiveSession.cpp
- LiveSession.h
- M3UParser.cpp
- M3UParser.h
- MODULE_LICENSE_APACHE2
- PlaylistFetcher.cpp
- PlaylistFetcher.h
上面三个算是底层主要是的核心东西.我先大致讲一下Android手机播放HLS的流程.
- 首先Android给VideoView设置Url.
- 接下来设置Url后,就会进入JNI创建MediaPlayerService,然后与底层进行Binder通信.
- 然后会在底层创建一个MediaPlayerService,在MediaPlayerServices中的Create方法中会判断类型.
- 根据Url地址进行判断,如果是Http或者Https开头的,然后是否包含了M3u8的就创建一个NuPlayerDriver.
- 其实底层核心的是StrageFright框架.创建了具体的播放对象后,会判断是否采用硬件解码,并进行解码播放工作.
MediaPlayerService(底层)
连接:https://android.googlesource.com/platform/frameworks/av/+/jb-release/media/libmediaplayerservice/MediaPlayerService.cpp
主要是根据url地址创建不同的播放器对象,具体代码如下:
static sp<MediaPlayerBase> createPlayer(player_type playerType, void* cookie, notify_callback_f notifyFunc) { sp<MediaPlayerBase> p; switch (playerType) { // 根据类型创建不同的播放器对象. case SONIVOX_PLAYER: ALOGV(" create MidiFile"); p = new MidiFile(); break; case STAGEFRIGHT_PLAYER: ALOGV(" create StagefrightPlayer"); p = new StagefrightPlayer; break; case NU_PLAYER: ALOGV(" create NuPlayer"); p = new NuPlayerDriver; //专门用来播放HLS协议的播放器对象 break; case TEST_PLAYER: ALOGV("Create Test Player stub"); p = new TestPlayerStub(); break; case AAH_RX_PLAYER: ALOGV(" create A@H RX Player"); p = createAAH_RXPlayer(); break; case AAH_TX_PLAYER: ALOGV(" create A@H TX Player"); p = createAAH_TXPlayer(); break; default: ALOGE("Unknown player type: %d", playerType); return NULL; } if (p != NULL) { if (p->initCheck() == NO_ERROR) { p->setNotifyCallback(cookie, notifyFunc); } else { p.clear(); } } if (p == NULL) { ALOGE("Failed to create player object"); } return p; }
那么我们去看看PlayerType在哪儿获得的,怎么获得的:
player_type getPlayerType(const char* url) { if (TestPlayerStub::canBeUsed(url)) { return TEST_PLAYER; } if (!strncasecmp("http://", url, 7) || !strncasecmp("https://", url, 8)) { //判断是否为https,或者http的 size_t len = strlen(url); if (len >= 5 && !strcasecmp(".m3u8", &url[len - 5])) { // 并且是有一个有效的m3u8结尾的url return NU_PLAYER; } if (strstr(url,"m3u8")) { return NU_PLAYER; } } if (!strncasecmp("rtsp://", url, 7)) { return NU_PLAYER; } if (!strncasecmp("aahRX://", url, 8)) { return AAH_RX_PLAYER; } // use MidiFile for MIDI extensions int lenURL = strlen(url); for (int i = 0; i < NELEM(FILE_EXTS); ++i) { int len = strlen(FILE_EXTS[i].extension); int start = lenURL - len; if (start > 0) { if (!strncasecmp(url + start, FILE_EXTS[i].extension, len)) { return FILE_EXTS[i].playertype; } } } return getDefaultPlayerType(); // 如果之前都没有,那么返回默认的. }
其中遇到的问题有:
- 如何实现边看边缓存ts — 已经解决(方法不是很好)
- 拖拽重复请求 —- 看源码正在寻求解决方案
- Android底层是如何缓存ts或者轮训播放的 —- 自己猜想过,目前看到了一部分源码可能是,但是还在着手看.
- Android 3.0以下如何支持HLS — 看VCL源码中…
HttpLiveSession去获取Ts
for (size_t i = 0; i < kNumSources; ++i) { // 创建了2个获取器,去获取TS资源. mPacketSources.add(indexToType(i), new AnotherPacketSource(NULL /* meta */)); mPacketSources2.add(indexToType(i), new AnotherPacketSource(NULL /* meta */)); }
将数据交给NuPlayerDriver
sp<AnotherPacketSource> LiveSession::getMetadataSource( sp<AnotherPacketSource> sources[kNumSources], uint32_t streamMask, bool newUri) { // todo: One case where the following strategy can fail is when audio and video // are in separate playlists, both are transport streams, and the metadata // is actually contained in the audio stream. ALOGV("[timed_id3] getMetadataSourceForUri streamMask %x newUri %s", streamMask, newUri ? "true" : "false"); if ((sources[kVideoIndex] != NULL) // video fetcher; or ... || (!(streamMask & STREAMTYPE_VIDEO) && sources[kAudioIndex] != NULL)) { // ... audio fetcher for audio only variant return getPacketSourceForStreamIndex(kMetaDataIndex, newUri); } return NULL; }
PlaylistFetcher
看名字就知道是来获取PlayList索引文件里面的数据,比如ts解析每个字段什么的.
我们看看它具体做了些什么:
PlaylistFetcher::PlaylistFetcher( // 很多参数 const sp<AMessage> ¬ify, const sp<LiveSession> &session, const char *uri, int32_t id, int32_t subtitleGeneration) : mNotify(notify), mSession(session), mURI(uri), mFetcherID(id), mStreamTypeMask(0), mStartTimeUs(-1ll), mSegmentStartTimeUs(-1ll), mDiscontinuitySeq(-1ll), mStartTimeUsRelative(false), mLastPlaylistFetchTimeUs(-1ll), mPlaylistTimeUs(-1ll), mSeqNumber(-1), mNumRetries(0), mStartup(true), mIDRFound(false), mSeekMode(LiveSession::kSeekModeExactPosition), mTimeChangeSignaled(false), mNextPTSTimeUs(-1ll), mMonitorQueueGeneration(0), mSubtitleGeneration(subtitleGeneration), mLastDiscontinuitySeq(-1ll), mRefreshState(INITIAL_MINIMUM_RELOAD_DELAY), mFirstPTSValid(false), mFirstTimeUs(-1ll), mVideoBuffer(new AnotherPacketSource(NULL)), mThresholdRatio(-1.0f), mDownloadState(new DownloadState()), mHasMetadata(false) { memset(mPlaylistHash, 0, sizeof(mPlaylistHash)); mHTTPDownloader = mSession->getHTTPDownloader(); // HttpLiveSession对象去获取DownLoader. } // 看到LiveSession中去直接创建了一个HTTPDownloader对象. sp<HTTPDownloader> LiveSession::getHTTPDownloader() { return new HTTPDownloader(mHTTPService, mExtraHeaders); } /*我们看看HTTPDownloader,这个对象主要有三个方法:fetchBlock(),fetchFile(),fetchPlaylist()*/ HTTPDownloader::HTTPDownloader( //构造函数 const sp<IMediaHTTPService> &httpService, const KeyedVector<String8, String8> &headers) : mHTTPDataSource(new MediaHTTP(httpService->makeHTTPConnection())), // MediaHttp的连接对象. mExtraHeaders(headers), mDisconnecting(false) { } //fetchFile方法主要是获取Ts文件 ssize_t HTTPDownloader::fetchFile( const char *url, sp<ABuffer> *out, String8 *actualUrl) { ssize_t err = fetchBlock(url, out, 0, -1, 0, actualUrl, true /* reconnect */); // 可以看到主要是调用了fetchBlock方法. // close off the connection after use mHTTPDataSource->disconnect(); return err; } //那我们接下来去看看fetchBlock,这个方法有点复杂,大致就是获取流媒体内存区域的块 /* * Illustration of parameters: * * 0 `range_offset` * +------------+-------------------------------------------------------+--+--+ * | | | next block to fetch | | | * | | `source` handle => `out` buffer | | | | * | `url` file |<--------- buffer size --------->|<--- `block_size` -->| | | * | |<----------- `range_length` / buffer capacity ----------->| | * |<------------------------------ file_size ------------------------------->| * * Special parameter values: * - range_length == -1 means entire file * - block_size == 0 means entire range * */ ssize_t HTTPDownloader::fetchBlock( const char *url, sp<ABuffer> *out, int64_t range_offset, int64_t range_length, uint32_t block_size, /* download block size */ String8 *actualUrl, bool reconnect /* force connect HTTP when resuing source */) { if (isDisconnecting()) { return ERROR_NOT_CONNECTED; } off64_t size; if (reconnect) { if (!strncasecmp(url, "file://", 7)) { mDataSource = new FileSource(url + 7); } else if (strncasecmp(url, "http://", 7) && strncasecmp(url, "https://", 8)) { return ERROR_UNSUPPORTED; } else { KeyedVector<String8, String8> headers = mExtraHeaders; if (range_offset > 0 || range_length >= 0) { headers.add( String8("Range"), String8( AStringPrintf( "bytes=%lld-%s", range_offset, range_length < 0 ? "" : AStringPrintf("%lld", range_offset + range_length - 1).c_str()).c_str())); } status_t err = mHTTPDataSource->connect(url, &headers); if (isDisconnecting()) { return ERROR_NOT_CONNECTED; } if (err != OK) { return err; } mDataSource = mHTTPDataSource; } } status_t getSizeErr = mDataSource->getSize(&size); if (isDisconnecting()) { return ERROR_NOT_CONNECTED; } if (getSizeErr != OK) { size = 65536; } sp<ABuffer> buffer = *out != NULL ? *out : new ABuffer(size); if (*out == NULL) { buffer->setRange(0, 0); } ssize_t bytesRead = 0; // adjust range_length if only reading partial block if (block_size > 0 && (range_length == -1 || (int64_t)(buffer->size() + block_size) < range_length)) { range_length = buffer->size() + block_size; } for (;;) { // Only resize when we don't know the size. size_t bufferRemaining = buffer->capacity() - buffer->size(); if (bufferRemaining == 0 && getSizeErr != OK) { size_t bufferIncrement = buffer->size() / 2; if (bufferIncrement < 32768) { bufferIncrement = 32768; } bufferRemaining = bufferIncrement; ALOGV("increasing download buffer to %zu bytes", buffer->size() + bufferRemaining); sp<ABuffer> copy = new ABuffer(buffer->size() + bufferRemaining); memcpy(copy->data(), buffer->data(), buffer->size()); copy->setRange(0, buffer->size()); buffer = copy; } size_t maxBytesToRead = bufferRemaining; if (range_length >= 0) { int64_t bytesLeftInRange = range_length - buffer->size(); if (bytesLeftInRange < (int64_t)maxBytesToRead) { maxBytesToRead = bytesLeftInRange; if (bytesLeftInRange == 0) { break; } } } // The DataSource is responsible for informing us of error (n < 0) or eof (n == 0) // to help us break out of the loop. ssize_t n = mDataSource->readAt( buffer->size(), buffer->data() + buffer->size(), maxBytesToRead); if (isDisconnecting()) { return ERROR_NOT_CONNECTED; } if (n < 0) { return n; } if (n == 0) { break; } buffer->setRange(0, buffer->size() + (size_t)n); bytesRead += n; } *out = buffer; if (actualUrl != NULL) { *actualUrl = mDataSource->getUri(); if (actualUrl->isEmpty()) { *actualUrl = url; } } return bytesRead; } //接下来就是:fetchPlaylist方法. sp<M3UParser> HTTPDownloader::fetchPlaylist( const char *url, uint8_t *curPlaylistHash, bool *unchanged) { ALOGV("fetchPlaylist '%s'", url); *unchanged = false; sp<ABuffer> buffer; String8 actualUrl; ssize_t err = fetchFile(url, &buffer, &actualUrl);//首先去获取文件 // close off the connection after use mHTTPDataSource->disconnect(); if (err <= 0) { return NULL; } // MD5 functionality is not available on the simulator, treat all // playlists as changed. #if defined(__ANDROID__) uint8_t hash[16]; MD5_CTX m; MD5_Init(&m); MD5_Update(&m, buffer->data(), buffer->size()); MD5_Final(hash, &m); if (curPlaylistHash != NULL && !memcmp(hash, curPlaylistHash, 16)) { // playlist unchanged *unchanged = true; return NULL; } if (curPlaylistHash != NULL) { memcpy(curPlaylistHash, hash, sizeof(hash)); } #endif sp<M3UParser> playlist = new M3UParser(actualUrl.string(), buffer->data(), buffer->size());// 可以看到去解析了M3u8文件. if (playlist->initCheck() != OK) { // 检查是否解析成功的. ALOGE("failed to parse .m3u8 playlist"); return NULL; } return playlist; // 得到一个M3UParser对象. } //接下来看看M3UParser吧,这个一看名字就知道是在解析M3u8文件,然后解析m3u8根据Hls的版本解析. status_t M3UParser::parse(const void *_data, size_t size) { int32_t lineNo = 0; sp<AMessage> itemMeta; const char *data = (const char *)_data; size_t offset = 0; uint64_t segmentRangeOffset = 0; while (offset < size) { size_t offsetLF = offset; while (offsetLF < size && data[offsetLF] != '\n') { ++offsetLF; } AString line; if (offsetLF > offset && data[offsetLF - 1] == '\r') { line.setTo(&data[offset], offsetLF - offset - 1); } else { line.setTo(&data[offset], offsetLF - offset); } // ALOGI("#%s#", line.c_str()); if (line.empty()) { offset = offsetLF + 1; continue; } if (lineNo == 0 && line == "#EXTM3U") { //是否为M3u8协议头 mIsExtM3U = true; } if (mIsExtM3U) { status_t err = OK; if (line.startsWith("#EXT-X-TARGETDURATION")) { if (mIsVariantPlaylist) { return ERROR_MALFORMED; } err = parseMetaData(line, &mMeta, "target-duration"); } else if (line.startsWith("#EXT-X-MEDIA-SEQUENCE")) { //协议序列 if (mIsVariantPlaylist) { return ERROR_MALFORMED; } err = parseMetaData(line, &mMeta, "media-sequence"); } else if (line.startsWith("#EXT-X-KEY")) { // url地址key if (mIsVariantPlaylist) { return ERROR_MALFORMED; } err = parseCipherInfo(line, &itemMeta, mBaseURI); } else if (line.startsWith("#EXT-X-ENDLIST")) { // 结束tag mIsComplete = true; } else if (line.startsWith("#EXT-X-PLAYLIST-TYPE:EVENT")) { mIsEvent = true; } else if (line.startsWith("#EXTINF")) { // 每一个item的开头 if (mIsVariantPlaylist) { return ERROR_MALFORMED; } err = parseMetaDataDuration(line, &itemMeta, "durationUs"); } else if (line.startsWith("#EXT-X-DISCONTINUITY")) { if (mIsVariantPlaylist) { return ERROR_MALFORMED; } if (itemMeta == NULL) { itemMeta = new AMessage; } itemMeta->setInt32("discontinuity", true); ++mDiscontinuityCount; } else if (line.startsWith("#EXT-X-STREAM-INF")) { if (mMeta != NULL) { return ERROR_MALFORMED; } mIsVariantPlaylist = true; err = parseStreamInf(line, &itemMeta); } else if (line.startsWith("#EXT-X-BYTERANGE")) { if (mIsVariantPlaylist) { return ERROR_MALFORMED; } uint64_t length, offset; err = parseByteRange(line, segmentRangeOffset, &length, &offset); if (err == OK) { if (itemMeta == NULL) { itemMeta = new AMessage; } itemMeta->setInt64("range-offset", offset); itemMeta->setInt64("range-length", length); segmentRangeOffset = offset + length; } } else if (line.startsWith("#EXT-X-MEDIA")) { err = parseMedia(line); } else if (line.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE")) { if (mIsVariantPlaylist) { return ERROR_MALFORMED; } size_t seq; err = parseDiscontinuitySequence(line, &seq); if (err == OK) { mDiscontinuitySeq = seq; } } if (err != OK) { return err; } } if (!line.startsWith("#")) { if (!mIsVariantPlaylist) { int64_t durationUs; if (itemMeta == NULL || !itemMeta->findInt64("durationUs", &durationUs)) { return ERROR_MALFORMED; } itemMeta->setInt32("discontinuity-sequence", mDiscontinuitySeq + mDiscontinuityCount); } mItems.push(); Item *item = &mItems.editItemAt(mItems.size() - 1); CHECK(MakeURL(mBaseURI.c_str(), line.c_str(), &item->mURI)); item->mMeta = itemMeta; itemMeta.clear(); } offset = offsetLF + 1; ++lineNo; } // error checking of all fields that's required to appear once // (currently only checking "target-duration"), and // initialization of playlist properties (eg. mTargetDurationUs) if (!mIsVariantPlaylist) { int32_t targetDurationSecs; if (mMeta == NULL || !mMeta->findInt32( "target-duration", &targetDurationSecs)) { ALOGE("Media playlist missing #EXT-X-TARGETDURATION"); return ERROR_MALFORMED; } mTargetDurationUs = targetDurationSecs * 1000000ll; mFirstSeqNumber = 0; if (mMeta != NULL) { mMeta->findInt32("media-sequence", &mFirstSeqNumber); } mLastSeqNumber = mFirstSeqNumber + mItems.size() - 1; } return OK; } /*好了以上大致流程看完了,留下一个问题,我们在播放ts的时候什么时候去下载下一个ts呢.?*/
void PlaylistFetcher::DownloadState::saveState( // 存储一些状态 AString &uri, sp<AMessage> &itemMeta, sp<ABuffer> &buffer, sp<ABuffer> &tsBuffer, int32_t &firstSeqNumberInPlaylist, int32_t &lastSeqNumberInPlaylist) { mHasSavedState = true; mUri = uri; // 当前URi地址 mItemMeta = itemMeta; // 当前播放的数据 mBuffer = buffer; // 当前的Buffer mTsBuffer = tsBuffer; // 当前ts的Buffer mFirstSeqNumberInPlaylist = firstSeqNumberInPlaylist; // 主要是看到有2个序列 mLastSeqNumberInPlaylist = lastSeqNumberInPlaylist; }
上面看到了留着一个问题,就是什么时候去下载下一个ts呢.我们要去看看HttpDownLoader?之前也说就只有几个方法,所以肯定不在这儿.真正实现其实是在PlayListFetcher
void PlaylistFetcher::onMonitorQueue() { // in the middle of an unfinished download, delay // playlist refresh as it'll change seq numbers if (!mDownloadState->hasSavedState()) { refreshPlaylist(); } int64_t targetDurationUs = kMinBufferedDurationUs; if (mPlaylist != NULL) { targetDurationUs = mPlaylist->getTargetDuration(); } int64_t bufferedDurationUs = 0ll; status_t finalResult = OK; if (mStreamTypeMask == LiveSession::STREAMTYPE_SUBTITLES) { sp<AnotherPacketSource> packetSource = mPacketSources.valueFor(LiveSession::STREAMTYPE_SUBTITLES); bufferedDurationUs = packetSource->getBufferedDurationUs(&finalResult); } else { // Use min stream duration, but ignore streams that never have any packet // enqueued to prevent us from waiting on a non-existent stream; // when we cannot make out from the manifest what streams are included in // a playlist we might assume extra streams. bufferedDurationUs = -1ll; for (size_t i = 0; i < mPacketSources.size(); ++i) { if ((mStreamTypeMask & mPacketSources.keyAt(i)) == 0 || mPacketSources[i]->getLatestEnqueuedMeta() == NULL) { continue; } int64_t bufferedStreamDurationUs = mPacketSources.valueAt(i)->getBufferedDurationUs(&finalResult); FSLOGV(mPacketSources.keyAt(i), "buffered %lld", (long long)bufferedStreamDurationUs); if (bufferedDurationUs == -1ll || bufferedStreamDurationUs < bufferedDurationUs) { bufferedDurationUs = bufferedStreamDurationUs; } } if (bufferedDurationUs == -1ll) { bufferedDurationUs = 0ll; } } if (finalResult == OK && bufferedDurationUs < kMinBufferedDurationUs) { FLOGV("monitoring, buffered=%lld < %lld", (long long)bufferedDurationUs, (long long)kMinBufferedDurationUs); // delay the next download slightly; hopefully this gives other concurrent fetchers // a better chance to run. // onDownloadNext(); //这儿文档也说的很明白了.其实就是handler机制,进行通信,所以主要what就是kWhatDownloadNext,我们去搜一下kWhatDownloadNext吧. sp<AMessage> msg = new AMessage(kWhatDownloadNext, this); msg->setInt32("generation", mMonitorQueueGeneration); msg->post(1000l); } else { // We'd like to maintain buffering above durationToBufferUs, so try // again when buffer just about to go below durationToBufferUs // (or after targetDurationUs / 2, whichever is smaller). int64_t delayUs = bufferedDurationUs - kMinBufferedDurationUs + 1000000ll; if (delayUs > targetDurationUs / 2) { delayUs = targetDurationUs / 2; } FLOGV("pausing for %lld, buffered=%lld > %lld", (long long)delayUs, (long long)bufferedDurationUs, (long long)kMinBufferedDurationUs); postMonitorQueue(delayUs); } } //搜到了.kWhatDownloadNext void PlaylistFetcher::onMessageReceived(const sp<AMessage> &msg) { switch (msg->what()) { case kWhatStart: { status_t err = onStart(msg); sp<AMessage> notify = mNotify->dup(); notify->setInt32("what", kWhatStarted); notify->setInt32("err", err); notify->post(); break; } case kWhatPause: { onPause(); sp<AMessage> notify = mNotify->dup(); notify->setInt32("what", kWhatPaused); notify->setInt32("seekMode", mDownloadState->hasSavedState() ? LiveSession::kSeekModeNextSample : LiveSession::kSeekModeNextSegment); notify->post(); break; } case kWhatStop: { onStop(msg); sp<AMessage> notify = mNotify->dup(); notify->setInt32("what", kWhatStopped); notify->post(); break; } case kWhatFetchPlaylist: { bool unchanged; sp<M3UParser> playlist = mHTTPDownloader->fetchPlaylist( mURI.c_str(), NULL /* curPlaylistHash */, &unchanged); sp<AMessage> notify = mNotify->dup(); notify->setInt32("what", kWhatPlaylistFetched); notify->setObject("playlist", playlist); notify->post(); break; } case kWhatMonitorQueue: case kWhatDownloadNext: // 看到了吧,下载下一个,其中有一个错误的延时1s重新下载的机制.,最有才会放弃 { int32_t generation; CHECK(msg->findInt32("generation", &generation)); if (generation != mMonitorQueueGeneration) { // Stale event break; } if (msg->what() == kWhatMonitorQueue) { // retry机制 onMonitorQueue(); } else { // 否则下载下一个. onDownloadNext(); } break; } case kWhatResumeUntil: { onResumeUntil(msg); break; } default: TRESPASS(); } }
以上都是纯手打记录下,等空闲了,把代码和UML图都贴出来更加仔细的分析.因为最近项目太忙了,担心忘记了.还有一些底层一些工作流程也会陆续记录下.坚持.!!!
随后会把边看边缓存ts这个实现方案发出来,目前都没有找到好的解决方案.求大神指导.
2 0
- Android HLS协议相关记录及部分解析
- HLS协议及java切片相关
- HLS协议及java切片相关
- HLS协议相关
- HLS协议相关知识
- HLS协议解析1
- 直播电视HLS协议分析及实现1---相关开源工程代码
- (HLS播放器之一)HLS协议之M3U8解析
- (HLS播放器之一)HLS协议之M3U8解析
- (HLS播放器之一)HLS协议之M3U8解析
- 【整理】HLS视频协议第二弹--裁剪部分视频及m3u8文件,编写通用客户端以播放m3u8视频
- HLS协议
- HLS 协议
- HLS 协议
- Android播放HLS协议的流媒体
- Android播放HLS协议的流媒体
- Android播放HLS协议的流媒体
- Android播放HLS协议的流媒体
- 定时任务 设置时间语法
- 258. Add Digits
- hibernate框架学习(关联关系)
- iso uinavigationcontrollerdemo2
- 最近5年133个Java面试问题列表
- Android HLS协议相关记录及部分解析
- Android中PhoneGap的使用方法
- Linux下面安培星际译王-Stardict
- 在命令行中通过adb shell am broadcast发送广播通知
- only 程序员的一个小总结
- SQL 实现Split函数
- scala 基础 隐式类型
- Codeforces Round #336 (Div. 2) 608B Hamming Distance Sum(dp)
- 【项目管理复习】:概述知识点