Android 原生ExoPlayer 解析
来源:互联网 发布:剑桥大学公开数据库 编辑:程序博客网 时间:2024/06/05 18:40
1.简述与应用范围
ExpPlayer是一个开源的,App等级的媒体API,它的开源项目包含了library和示例。
ExoPlayer相较于MediaPlayer有很多优点:
1. 支持基于http的移动流媒体协议,包括DASH,HSL,Smooth Stream。同时也支持文件流和udp流等。2. 支持更多媒体封装格式,包括mp4,mp3,Webm,aac,mkv,mpeg-ts。3. 支持DRM(Digital Right Management 数字版权管理)。4. 支持HD高清播放。5. 支持自定义和拓展使用场景。
2.上层调用方式
(本节说明重点为demo。)
简单来说,上层调用方式基本为:
PlayerActivity -> DemoPlayer -> ExoPlayer PlayerActivity -> RendererBuilder -> ExtractorRendererBuilder
类图为:
其中PlayerActivity面向UI层,一方面控制了播放器DemoPlayer,一方面选择了Renderer。
这里的Renderer指定了数据源格式、解码方式和缓冲区大小等。(说明,这里的缓冲区大小指RollingSampleBuffer的大小,不会影响进入播放的速度,只会影响缓存数据的最大值)
ExoPlayer则是媒体API接口。
DemoPlayer中直接封装了ExoPlayer和相关回调接口,负责播放器的逻辑控制和传入SurfaceView等操作,而非播放器的内部原理。
这里通过时序图来说明Demo中几个类的调用和封装方式。
3.代码结构
简单来说,代码结构是这样:
ExoPlayer ->ExoPlayerImpl -> ExoPlayerImplInternal -> TrackRenderer MediaCodecVideoTrackRenderer & MediaCodecAudioTrackRenderer -> MediaCodecTrackRenderer -> SampleSourceTrackRenderer -> SampleSource,SampleSourceReader ExtractorSampleSource -> DataSource & Extractor & Loader
这里,ExoPlayer为接口。ExoPlayerImpl为实现,实现的一些详细步骤在ExoPlayerImplInternal中。后者用Handler消息机制进行异步通信,必要时会阻塞。
TrackRenderer是渲染器接口。
MediaCodecTrackRenderer中加入了MediaCodec(Android硬解码)。这里能看出,ExoPlayer用的是硬解,并且要求4.1以上Android系统。
SampleSourceTrackRenderer中调用了SampleSource,SampleSourceReader接口。SampleSource在这里指的是解封装后的媒体数据。
ExtractorSampleSource相当于一个核心控制器,它实现了SampleSource和SampleSourceReader接口。它通过实际的控制线程Loader,把从某DataSource即数据源中传过来的原始数据,传递给某Extractor来解封装。原始数据解析成SampleSource后,储存在RollingSampleBuffer即环形缓冲区中。
MediaCodecTrackRenderer会间接通过ExtractorSampleSource间接从RollingSampleBuffer中读取数据并渲染成画面,显示到SurfaceView中。
最后的过程有些复杂,流程图如下所示:
4.代码原理
1.ExoPlayer -> ExoPlayerImpl -> ExoPlayerImplInternal
通过以下这段ExoPlayerImpl的构造方法代码,可以看出来ExoPlayerImpl中持有一个ExoPlayerImplInternal对象来控制播放器。创建ExoPlayerImplInternal对象时传入了一个eventHandler对象,把底层的错误信息和状态改变信息传递给上层处理。
ExoPlayerImpl类中构造方法:eventHandler = new Handler() { @Override public void handleMessage(Message msg) { ExoPlayerImpl.this.handleEvent(msg); }};internalPlayer = new ExoPlayerImplInternal(eventHandler, playWhenReady, selectedTrackIndices, minBufferMs, minRebufferMs);
具体的功能性代码块,都在ExoPlayerImplInternal中实现。
状态改变信息和错误信息会通过eventHandler传上来进行处理。
ExoPlayerImpl类:// Not private so it can be called from an inner class without going through// a thunk method./* package */ void handleEvent(Message msg) { switch (msg.what) { case ExoPlayerImplInternal.MSG_PREPARED: { System.arraycopy(msg.obj, 0, trackFormats, 0, trackFormats.length); playbackState = msg.arg1; for (Listener listener : listeners) { listener.onPlayerStateChanged(playWhenReady, playbackState); } break; } case ExoPlayerImplInternal.MSG_STATE_CHANGED: { playbackState = msg.arg1; for (Listener listener : listeners) { listener.onPlayerStateChanged(playWhenReady, playbackState); } break; } case ExoPlayerImplInternal.MSG_SET_PLAY_WHEN_READY_ACK: { pendingPlayWhenReadyAcks--; if (pendingPlayWhenReadyAcks == 0) { for (Listener listener : listeners) { listener.onPlayWhenReadyCommitted(); } } break; } case ExoPlayerImplInternal.MSG_ERROR: { ExoPlaybackException exception = (ExoPlaybackException) msg.obj; for (Listener listener : listeners) { listener.onPlayerError(exception); } break; } }}
这里的listeners是一个CopyOnWriteArrayList,里面的对象都是Listener,这里用的是一个观察者模式,用于给上层监听回调消息。上层即DemoPlayer或是EventLogger都在这里注册或注销监听。
2.ExoPlayerImplInternal -> TrackRenderer -> SampleSource,SampleSourceReader -> ExtractorSampleSource
1)ExoPlayerImplInternal中消息机制
ExoPlayerImplInternal类中构造方法: internalPlaybackThread = new PriorityHandlerThread(getClass().getSimpleName() + ":Handler", Process.THREAD_PRIORITY_AUDIO); internalPlaybackThread.start(); handler = new Handler(internalPlaybackThread.getLooper(), this);
ExoPlayerImplInternal实现了Handler.Callback接口:
ExoPlayerImplInternal类:@Overridepublic boolean handleMessage(Message msg) { try { switch (msg.what) { case MSG_PREPARE: { prepareInternal((TrackRenderer[]) msg.obj); return true; } case MSG_INCREMENTAL_PREPARE: { incrementalPrepareInternal(); return true; } case MSG_SET_PLAY_WHEN_READY: { setPlayWhenReadyInternal(msg.arg1 != 0); return true; } case MSG_DO_SOME_WORK: { doSomeWork(); return true; } case MSG_SEEK_TO: { seekToInternal(Util.getLong(msg.arg1, msg.arg2)); return true; } case MSG_STOP: { stopInternal(); return true; } case MSG_RELEASE: { releaseInternal(); return true; } case MSG_CUSTOM: { sendMessageInternal(msg.arg1, msg.obj); return true; } case MSG_SET_RENDERER_SELECTED_TRACK: { setRendererSelectedTrackInternal(msg.arg1, msg.arg2); return true; } default: return false; } } catch (ExoPlaybackException e) { Log.e(TAG, "Internal track renderer error.", e); eventHandler.obtainMessage(MSG_ERROR, e).sendToTarget(); stopInternal(); return true; } catch (RuntimeException e) { Log.e(TAG, "Internal runtime error.", e); eventHandler.obtainMessage(MSG_ERROR, new ExoPlaybackException(e, true)).sendToTarget(); stopInternal(); return true; }}
通过这段代码,可以看出来,在ExoPlayerImplInternal内部是通过消息来控制播放器逻辑(控制TrackRenderer)。
2)doSomeWork分析及作用
ExoPlayerImplInternal类:private void doSomeWork() throws ExoPlaybackException { TraceUtil.beginSection("doSomeWork"); long operationStartTimeMs = SystemClock.elapsedRealtime(); long bufferedPositionUs = durationUs != TrackRenderer.UNKNOWN_TIME_US ? durationUs : Long.MAX_VALUE; boolean allRenderersEnded = true; boolean allRenderersReadyOrEnded = true; updatePositionUs();// 笔记:更新positionUs for (int i = 0; i < enabledRenderers.size(); i++) { TrackRenderer renderer = enabledRenderers.get(i); // TODO: Each renderer should return the maximum delay before which // it wishes to be // invoked again. The minimum of these values should then be used as // the delay before the next // invocation of this method. // 笔记:这里调用了renderer的doSomeWork方法并传入了positionUs, // elapsedRealtimeUs是个独立的系统时间参考 renderer.doSomeWork(positionUs, elapsedRealtimeUs); allRenderersEnded = allRenderersEnded && renderer.isEnded(); // Determine whether the renderer is ready (or ended). If it's not, // throw an error that's // preventing the renderer from making progress, if such an error // exists. boolean rendererReadyOrEnded = rendererReadyOrEnded(renderer); if (!rendererReadyOrEnded) { renderer.maybeThrowError(); } allRenderersReadyOrEnded = allRenderersReadyOrEnded && rendererReadyOrEnded; if (bufferedPositionUs == TrackRenderer.UNKNOWN_TIME_US) { // We've already encountered a track for which the buffered // position is unknown. Hence the // media buffer position unknown regardless of the buffered // position of this track. } else { long rendererDurationUs = renderer.getDurationUs(); long rendererBufferedPositionUs = renderer.getBufferedPositionUs(); if (rendererBufferedPositionUs == TrackRenderer.UNKNOWN_TIME_US) { bufferedPositionUs = TrackRenderer.UNKNOWN_TIME_US; } else if (rendererBufferedPositionUs == TrackRenderer.END_OF_TRACK_US || (rendererDurationUs != TrackRenderer.UNKNOWN_TIME_US && rendererDurationUs != TrackRenderer.MATCH_LONGEST_US && rendererBufferedPositionUs >= rendererDurationUs)) { // This track is fully buffered. } else { bufferedPositionUs = Math.min(bufferedPositionUs, rendererBufferedPositionUs); } } } // 笔记:更新缓冲位置,主要用于上层回调 this.bufferedPositionUs = bufferedPositionUs; // 笔记:根据durationUs和positionUs来判断状态和开关渲染器(Renderer) if (allRenderersEnded && (durationUs == TrackRenderer.UNKNOWN_TIME_US || durationUs <= positionUs)) { setState(ExoPlayer.STATE_ENDED); stopRenderers(); } else if (state == ExoPlayer.STATE_BUFFERING && allRenderersReadyOrEnded) { setState(ExoPlayer.STATE_READY); if (playWhenReady) { startRenderers(); } } else if (state == ExoPlayer.STATE_READY && !allRenderersReadyOrEnded) { rebuffering = playWhenReady; setState(ExoPlayer.STATE_BUFFERING); stopRenderers(); } // 笔记:准备再次调用doSomework handler.removeMessages(MSG_DO_SOME_WORK); if ((playWhenReady && state == ExoPlayer.STATE_READY) || state == ExoPlayer.STATE_BUFFERING) { scheduleNextOperation(MSG_DO_SOME_WORK, operationStartTimeMs, RENDERING_INTERVAL_MS); } else if (!enabledRenderers.isEmpty()) { scheduleNextOperation(MSG_DO_SOME_WORK, operationStartTimeMs, IDLE_INTERVAL_MS); } TraceUtil.endSection();}private void scheduleNextOperation(int operationType, long thisOperationStartTimeMs, long intervalMs) { long nextOperationStartTimeMs = thisOperationStartTimeMs + intervalMs; long nextOperationDelayMs = nextOperationStartTimeMs - SystemClock.elapsedRealtime(); if (nextOperationDelayMs <= 0) { handler.sendEmptyMessage(operationType); } else { handler.sendEmptyMessageDelayed(operationType, nextOperationDelayMs); }}// 笔记:通过上层传入的eventHandler把状态改变信息传递给上层private void setState(int state) { if (this.state != state) { this.state = state; eventHandler.obtainMessage(MSG_STATE_CHANGED, state, 0).sendToTarget(); }}
doSomeWork方法是在播放器执行完prepare后执行的。是在准备动作都完成后,具体控制播放器开始渲染画面的方法。
在以上代码中我们可以看出来,这里完成的主要动作有:
1. 更新positionUs(以及elapsedRealtimeUs)2. renderer.doSomeWork3. 把播放状态回调上层4. 定时执行下一次doSomeWork
3)updataPositionUs和renderer.doSomeWork分析
positionUs指的是实际渲染位置。
ExoPlayerImplInternal类:private void updatePositionUs() { if (rendererMediaClock != null && enabledRenderers.contains(rendererMediaClockSource) && !rendererMediaClockSource.isEnded()) { positionUs = rendererMediaClock.getPositionUs(); standaloneMediaClock.setPositionUs(positionUs); } else { positionUs = standaloneMediaClock.getPositionUs(); } elapsedRealtimeUs = SystemClock.elapsedRealtime() * 1000;}
通过这段在ExoPlayerImplInternal类中的代码,我们看出,这有两个分支,第一个分支主要是用于有音频的情况下,音频时间可以作为整体参考时间,来调整positionUs。第二个分支是没有音频的情况下,用系统独立时钟作为整体参考时间,来调整positionUs。
MediaCodecTrackRenderer类:@Overrideprotected void doSomeWork(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { // 笔记:判断是否应该继续缓冲 sourceState = continueBufferingSource(positionUs) ? (sourceState == SOURCE_STATE_NOT_READY ? SOURCE_STATE_READY : sourceState) : SOURCE_STATE_NOT_READY; // 笔记:判断解码是否连续,如果不连续,则重启解码器 checkForDiscontinuity(positionUs); if (format == null) { // 笔记:读取格式 readFormat(positionUs); } if (codec == null && shouldInitCodec()) { // 笔记:当有格式无解码器时,开启解码器 maybeInitCodec(); } if (codec != null) { TraceUtil.beginSection("drainAndFeed"); // 笔记:如果解码器中可以输出缓冲,则会返回true,否则返回false while (drainOutputBuffer(positionUs, elapsedRealtimeUs)) { } // 笔记:如果解码器还可以输入原始帧,则返回true,否则返回false,第二个参数代表是否首次执行 if (feedInputBuffer(positionUs, true)) { while (feedInputBuffer(positionUs, false)) { } } TraceUtil.endSection(); } codecCounters.ensureUpdated();}
positionUs传递给了drainOutputBuffer方法和feedInputBuffer方法。用于调整播放时间,和获取缓冲帧。
drainOutputBuffer方法调用到了processOutputBuffer方法,这里处理缓冲帧。这个方法在MediaCodecTrackRenderer类中是个抽象方法,具体实现在MediaCodecVideoTrackRenderer和MediaCodecAudioTrackRenderer类中。
MediaCodecVideoTrackRenderer类:// 笔记:返回true意味着输出的缓冲帧已经被渲染,false意味着尚未被渲染@Overrideprotected boolean processOutputBuffer(long positionUs, long elapsedRealtimeUs, MediaCodec codec, ByteBuffer buffer, MediaCodec.BufferInfo bufferInfo, int bufferIndex, boolean shouldSkip) { if (shouldSkip) { skipOutputBuffer(codec, bufferIndex); return true; } if (!renderedFirstFrame) { if (Util.SDK_INT >= 21) { renderOutputBufferV21(codec, bufferIndex, System.nanoTime()); } else { renderOutputBuffer(codec, bufferIndex); } return true; } if (getState() != TrackRenderer.STATE_STARTED) { return false; } // Compute how many microseconds it is until the buffer's presentation // time. long elapsedSinceStartOfLoopUs = (SystemClock.elapsedRealtime() * 1000) - elapsedRealtimeUs; long earlyUs = bufferInfo.presentationTimeUs - positionUs - elapsedSinceStartOfLoopUs; // Compute the buffer's desired release time in nanoseconds. long systemTimeNs = System.nanoTime(); long unadjustedFrameReleaseTimeNs = systemTimeNs + (earlyUs * 1000); // Apply a timestamp adjustment, if there is one. long adjustedReleaseTimeNs = frameReleaseTimeHelper.adjustReleaseTime(bufferInfo.presentationTimeUs, unadjustedFrameReleaseTimeNs); earlyUs = (adjustedReleaseTimeNs - systemTimeNs) / 1000; // 笔记:以上是通过positionUs(实际渲染位置),elapsedRealtimeUs(独立时钟位置), // bufferInfo.presentationTimeUs(缓冲帧位置)得出缓冲位置和播放位置之间的时间差值。 // 笔记:如果渲染位置在此缓冲帧位置后面30ms,则弃掉此帧 if (earlyUs < -30000) { // We're more than 30ms late rendering the frame. dropOutputBuffer(codec, bufferIndex); return true; } if (Util.SDK_INT >= 21) { // 笔记:如果系统api在21以上,则可以在framework层控制渲染速度 // Let the underlying framework time the release. // 笔记:如果渲染位置在缓冲帧位置50毫秒之前,就return false。否则则渲染。 if (earlyUs < 50000) { renderOutputBufferV21(codec, bufferIndex, adjustedReleaseTimeNs); return true; } } else { // 笔记:如果系统api在21以下,我们需要自己控制渲染速度 // We need to time the release ourselves. if (earlyUs < 30000) { // 笔记:如果渲染位置和缓冲帧位置之差在30毫秒和11毫秒之间,则推迟至少1毫秒再渲染。 // 如果在11毫秒以内,则直接渲染。 if (earlyUs > 11000) { // We're a little too early to render the frame. Sleep until // the frame can be rendered. // Note: The 11ms threshold was chosen fairly arbitrarily. try { // Subtracting 10000 rather than 11000 ensures the sleep // time will be at least 1ms. Thread.sleep((earlyUs - 10000) / 1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } renderOutputBuffer(codec, bufferIndex); return true; } } // We're either not playing, or it's not time to render the frame yet. // 笔记:return false的意思是,我们既不播放,而且也不渲染这帧。 return false;}
在renderOutputBuffer中,
codec.releaseOutputBuffer(bufferIndex, true);
通过releaseOutputBuffer方法把相关帧播放到surface中。
以上是通过positionUs调整缓冲时间以及播放缓冲帧的代码。
在feedInputBuffer中,
result = readSource(positionUs, formatHolder, sampleHolder, false);
通过readSource,调用到了ExtractorSampleSource中的readData方法,从rollingBuffer中取到了数据。
这是通过positionUs获取缓冲帧的代码。
通过这些代码可以分析出,如果positionUs获取错误的话,那么会直接影响到播放流程中从缓冲区获取数据和解码器渲染数据等功能。
3.ExtractorSampleSource -> DataSource & Extractor & Loader
1)ExtractingLoadable分析
ExtractingLoadable是一个ExtractorSampleSource中的内部类。它实现了Loadable接口。Loadable接口应用于Loader,后者是一个异步线程。在这里主要用于从DataSource数据源中获取数据放进RollingSampleBuffer即缓冲区中。
/** * Loads the media stream and extracts sample data from it. */private static class ExtractingLoadable implements Loadable { private final Uri uri; private final DataSource dataSource; private final ExtractorHolder extractorHolder; private final Allocator allocator; private final int requestedBufferSize; private final PositionHolder positionHolder; private volatile boolean loadCanceled; private boolean pendingExtractorSeek; public ExtractingLoadable(Uri uri, DataSource dataSource, ExtractorHolder extractorHolder, Allocator allocator, int requestedBufferSize, long position) { this.uri = Assertions.checkNotNull(uri); this.dataSource = Assertions.checkNotNull(dataSource); this.extractorHolder = Assertions.checkNotNull(extractorHolder); this.allocator = Assertions.checkNotNull(allocator); this.requestedBufferSize = requestedBufferSize; positionHolder = new PositionHolder(); positionHolder.position = position; pendingExtractorSeek = true; } // 笔记:用于控制线程的关闭 @Override public void cancelLoad() { loadCanceled = true; } @Override public boolean isLoadCanceled() { return loadCanceled; } @Override public void load() throws IOException, InterruptedException { int result = Extractor.RESULT_CONTINUE; while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { ExtractorInput input = null; try { long position = positionHolder.position; // 笔记:开打数据源,这里C.LENGTH_UNBOUNDED值为-1 long length = dataSource.open(new DataSpec(uri, position, C.LENGTH_UNBOUNDED, null)); if (length != C.LENGTH_UNBOUNDED) { length += position; } // 笔记:这里的ExtractorInput是一个对于数据源、读取位置、读取长度的封装 // 用于向Extractor输入数据 input = new DefaultExtractorInput(dataSource, position, length); // 笔记:通过数据选择正确的Extractor即文件封装拆解器 Extractor extractor = extractorHolder.selectExtractor(input); if (pendingExtractorSeek) { extractor.seek(); pendingExtractorSeek = false; } // 笔记:这个循环用于从Extractor中不断读取数据,放进RollingSampleBuffer中 while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { allocator.blockWhileTotalBytesAllocatedExceeds(requestedBufferSize); result = extractor.read(input, positionHolder); // TODO: Implement throttling to stop us from buffering // data too often. } } finally { if (result == Extractor.RESULT_SEEK) { result = Extractor.RESULT_CONTINUE; } else if (input != null) { positionHolder.position = input.getPosition(); } // 笔记:关闭数据源 dataSource.close(); } } }}
我们可以看出,线程中进行的主要动作是
1. dataSource.open,即打开数据源2. Extractor extractor = extractorHolder.selectExtractor(input),选择正确的文件封装拆解器3. result = extractor.read(input, positionHolder),从数据源中读取数据4. dataSource.close,关闭数据源
2)ExtractorHolder分析
ExtractorHolder也是一个ExtractorSampleSource中的内部类。它主要负责持有Extractor。
ExtractorHolder类: public Extractor selectExtractor(ExtractorInput input) throws UnrecognizedInputFormatException, IOException, InterruptedException { if (extractor != null) { return extractor; } for (Extractor extractor : extractors) { try { // 笔记:一旦识别到正确的解析器,则会返回true if (extractor.sniff(input)) { this.extractor = extractor; break; } } catch (EOFException e) { // Do nothing. } input.resetPeekPosition(); } if (extractor == null) { throw new UnrecognizedInputFormatException(extractors); } // 笔记:这里调用了extractor.init即初始化 extractor.init(extractorOutput); return extractor; }
3)Extractor分析
Extractor是个接口,表示文件封装解析器。里面主要有四个方法:
void init(ExtractorOutput output);boolean sniff(ExtractorInput input) throws IOException, InterruptedException;int read(ExtractorInput input, PositionHolder seekPosition) throws IOException, InterruptedException;void seek();
read方法是阻塞的。每次调用read只会获取一小部分数据。
同时这里定义了三个read方法的特殊返回值:
RESULT_CONTINUE = 0; //表示需要继续读取数据RESULT_SEEK = 1; //表示需要重新定位数据RESULT_END_OF_INPUT = C.RESULT_END_OF_INPUT; //表示已经读取结束
通过Extractor的实现类我们可以找到,当调用read方法时,都会调到trackOutput.sampleData方法。这个方法表示输出解封装后的帧。具体就是把解封装的帧存入RollingSampleBuffer中,在TrackOutput的实现类DefaultTrackOutput中的如下代码可以印证这一点:
@Overridepublic void sampleData(ParsableByteArray buffer, int length) { rollingBuffer.appendData(buffer, length);}
具体的文件解封装这里不做细节分析。
4.其他
5.相关性补充
ijkplayer中Android部分:
ijkplayer是bilibili推出的同时支持ios和Android,硬解和软解的开源播放器框架。其中,在Android代码中,硬解部分应用了ExoPlayer,软解部分应用了ffmepg和sdl。
ijkplayer的demo中,调用方式是这样的:
VideoActivity -> IjkVideoView -> IMediaPlayer -> AbstractMediaPlayer AbstractMediaPlayer -> IjkExoMediaPlayer -> DemoPlayer -> ExoPlayer AbstractMediaPlayer -> IjkMediaPlayer -> ijkplayer_jni.c -> ijkplayer.c -> Ff_ffplayer.c
- Android 原生ExoPlayer 解析
- Android 原生ExoPlayer 解析
- ExoPlayer 的小解析
- Android原生Json解析
- android json原生解析
- android之ExoPlayer探索
- android exoplayer的使用
- ExoPlayer
- ExoPlayer
- android 视频播放 Google exoplayer
- Exoplayer解析hls流分析之一
- Android原生生成JSON与解析JSON
- Android 4.4系统原生截图解析
- Android里jsonarray原生解析数组/集合
- Android API Guides---ExoPlayer Supported Media Formats
- Android视频播放器Exoplayer自定义
- 使用Android.mk文件导入Exoplayer包
- Android 平台下的原生 Markdown 解析器
- 移动端meta设置大全
- ul实现横向排列不换行的两种解决方案
- java正则表达式
- PAT乙级1064
- android studio jni路径配置
- Android 原生ExoPlayer 解析
- 坑
- django解决跨域请求的问题
- 在controller中用@value读取配置文件的方法
- C# 特性
- Zeronet-搭建你的p2p全球网站,不要域名,不怕宕机
- 使用GDAL进行影像投影坐标、地理坐标、图上坐标的转换
- DataSource和SessionFactory的区别
- 算法提高 11-1实现strcmp函数