主頁 >  其他 > Android-WebRTC初體驗(Android-RTC-1-挖坑篇)

Android-WebRTC初體驗(Android-RTC-1-挖坑篇)

2021-04-13 12:35:38 其他

開篇導讀,工程目錄在 https://github.com/MrZhaozhirong/AppWebRTC 自行拾取,工程環境是Gradle4.0.x+Androidx,是手動重新fork整個WebRTCdemo,官方原始碼在這里,

前言

正式開始Android-WebRTC的內容,網上搜索到的不外乎就是WebRTC-Codelab的搬運教程,學習demo也是代碼片段;要不然就是老司機直接Nignx+coturn+webrtc.js.api搭載一套,這些內容我還是感覺不全面,沒有一個很清晰的整體架構認識,所以就決定去由淺到深,一步步的去挖掘,到現在我都不敢說已經完全了解WebRTC里面的所有內容,但起碼是有一個清晰的認知構成圖,WebRTC不單單是一套API或理解為一個SDK,它是一套基于瀏覽器web上針對RTC(Real-Time Communications)實時通信的一套開發協標準(或者說是解決方案?),既然能基于瀏覽器web上的,那其他平臺上肯定就有其對應的實作,

至于RTC與WebRTC有什么區別?實際上,二者不能劃等號,RTC從功能流程上來說,包含采集、編碼、前后處理、傳輸、解碼、緩沖、渲染等很多環節,每一個細分環節,還有更細分的技術模塊,比如,前后處理環節有美顏、濾鏡、回聲消除、噪聲抑制等,采集有麥克風陣列等,編解碼有VP8、VP9、H.264、H.265等等,WebRTC是RTC的一部分,是Google的一個專門針對網頁實時通信的標準及開源專案,只提供了基礎的前端功能實作,包括編碼解碼和抖動緩沖等,開發者若要基于WebRTC開發商用專案,那么需要自行做服務端實作和部署,信令前后端選型實作部署,以及手機適配等一系列具體作業;在此之外還要在可用性和高質量方面,進行大量的改進和打磨,對自身開發能力的門檻要求非常高,一個專業的RTC技術服務系統,需要除了涵蓋上述的通信環節外,實際上還需要有解決互聯網不穩定性的專用通信網路,以及針對互聯網信道的高容忍度的音視頻信號處理演算法,當然常規云服務的高可用、服務質量的保障和監控維護工具等都只能算是一個專業服務商的基本模塊,所以,WebRTC僅是RTC技術堆疊中的幾個小細分的技術組合,并不是一個全堆疊解決方案,

搞清楚這些關系之后,理論基礎是必不可少的,還是強烈建議初學小白先去了解WebRTC的理論知識(之前搬運的兩篇長篇理論文章就很不錯,趕緊淦),再然后才是找代碼閱讀,理論—實踐—再理論—再優化,唯有這條路才是走向技術專家的唯一捷徑,

如何開始?

按照RTC的技術方向劃分,可以簡單分類以下幾個大領域:

  • 端對端鏈接 (信令signal、stun打洞、turn轉發);
  • 視頻采集 / 編解碼 / 濾鏡處理;
  • 音頻采集 / 編解碼 / 回聲消除降噪處理;
  • 實時傳輸 / QoE質量保障;

本篇內容從第一部分(端對端鏈接)開始,分析Android WebRTCdemo的組成,簡單的延伸到官方套件api的解讀分析,

Android的WebRTC demo工程有兩個依賴,一個是官方包,一個是libs/autobanh.jar此包主要是負責websocket的通信,

dependencies {
    implementation fileTree(dir: "libs", include: ["*.jar"]) // libs/autobanh.jar
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'org.webrtc:google-webrtc:1.0.32006'
}

檔案組成及其主要功能,按繼承關系可以如下劃分,(暫不顯示org.webrtc:google-webrtc包里面的類)

Demo的核心基本就是兩大塊,AppRTCClient 和 PeerConnectClient,CallActivity作為一個載體承接邏輯互動,其余的都是圍繞這幾部分進行額外的引數設定,

接下來介紹1v1視頻通話測驗的邏輯,以及如何理解 信令Signal?

什么是信令Signal?

先說說如何利用demo進行測驗:

1. Go to https://appr.tc from any browser and create any room number 先在瀏覽器打開https://appr.tc生成room id,然后進入房間,
2. Start the Android app. Enter the room number and press call. Video call should start. 輸入room id然后鍵入呼叫,

經過步驟1~2之后,ConnectActivity就會跳轉到CallActivity.onCreate,有如下代碼片段:

peerConnectionParameters =
	new PeerConnectionClient.PeerConnectionParameters(
			intent.getBooleanExtra(EXTRA_VIDEO_CALL, true),
			loopback, tracing, videoWidth, videoHeight,
			intent.getIntExtra(EXTRA_VIDEO_FPS, 0),
			intent.getIntExtra(EXTRA_VIDEO_BITRATE, 0),
			intent.getStringExtra(EXTRA_VIDEOCODEC),
			intent.getBooleanExtra(EXTRA_HWCODEC_ENABLED, true),
			intent.getBooleanExtra(EXTRA_FLEXFEC_ENABLED, false),
			intent.getIntExtra(EXTRA_AUDIO_BITRATE, 0),
			intent.getStringExtra(EXTRA_AUDIOCODEC),
			intent.getBooleanExtra(EXTRA_NOAUDIOPROCESSING_ENABLED, false),
			intent.getBooleanExtra(EXTRA_AECDUMP_ENABLED, false),
			intent.getBooleanExtra(EXTRA_SAVE_INPUT_AUDIO_TO_FILE_ENABLED, false),
			intent.getBooleanExtra(EXTRA_OPENSLES_ENABLED, false),
			intent.getBooleanExtra(EXTRA_DISABLE_BUILT_IN_AEC, false),
			intent.getBooleanExtra(EXTRA_DISABLE_BUILT_IN_AGC, false),
			intent.getBooleanExtra(EXTRA_DISABLE_BUILT_IN_NS, false),
			intent.getBooleanExtra(EXTRA_DISABLE_WEBRTC_AGC_AND_HPF, false),
			intent.getBooleanExtra(EXTRA_ENABLE_RTCEVENTLOG, false),
			dataChannelParameters);

Uri roomUri = intent.getData(); // 默認為https://appr.tc
String roomId = intent.getStringExtra(EXTRA_ROOMID);   // 房間rumber
String urlParameters = intent.getStringExtra(EXTRA_URLPARAMETERS);
roomConnectionParameters = new AppRTCClient.RoomConnectionParameters(
                                roomUri.toString(), roomId, loopback, urlParameters);

從名字和引數串列,可以大概知道PeerConnectionParameters是一些與音視頻模塊的相關配置引數;另外一個RoomConnectionParameters也很簡單,就是房間URL和房間ID,接下來就是實體化AppRtcClient和PeerConnectionClient,

// Create connection client. Use DirectRTCClient if room name is an IP 
// otherwise use the standard WebSocketRTCClient.
if (loopback || !DirectRTCClient.IP_PATTERN.matcher(roomId).matches()) {
    appRtcClient = new WebSocketRTCClient(this); // AppRTCClient.SignalingEvents Callback
} else {
    Log.i(TAG, "Using DirectRTCClient because room name looks like an IP.");
    appRtcClient = new DirectRTCClient(this);
}

// Create peer connection client.
peerConnectionClient = new PeerConnectionClient(getApplicationContext(), eglBase, peerConnectionParameters, CallActivity.this);

PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
if (loopback) {
    options.networkIgnoreMask = 0;
}
peerConnectionClient.createPeerConnectionFactory(options);
if (screencaptureEnabled) {
    startScreenCapture();
} else {
    startCall();
}

1、備注清楚的描述了,測驗使用ip的房間名字就實體DirectRTCClient,其余情況都使用WebSocketRTCClient,

2、PeerConnectClient是封裝PeerConnection的類,掌握WebRTC理論基礎的同學可以知道,WebRTC實作了以下三套API:

  • MediaStream (也可以叫作 getUserMedia)
  • RTCPeerConnection
  • RTCDataChannel

其中PeerConnection是WebRTC進行網路連接的核心,到Android版本的API使用了Factory工廠模式配置創建PeerConnection,這個留著下文展開分析,

3、第三個細節還想聊聊的就是WebRTC也用到EGL環境,證明底層視頻采集渲染處理都用到OpenGL,這也是留著以后慢慢分析,

4、最后就是Android版本的WebRTC也支持Android L以上的螢屏投影(哎喲~不錯喔);接下來分析startCall

    private void startCall() {
        if (appRtcClient == null) {
            Log.e(TAG, "AppRTC client is not allocated for a call.");
            return;
        }
        callStartedTimeMs = System.currentTimeMillis();
        // Start room connection.
        logAndToast(getString(R.string.connecting_to, roomConnectionParameters.roomUrl));
        appRtcClient.connectToRoom(roomConnectionParameters); // <- 這句是重點,

        // Create and audio manager that will take care of audio routing,
        // audio modes, audio device enumeration etc.
        audioManager = AppRTCAudioManager.create(getApplicationContext());
        // Store existing audio settings and change audio mode to
        // MODE_IN_COMMUNICATION for best possible VoIP performance.
        // This method will be called each time the number of available audio devices has changed.
        audioManager.start((device, availableDevices) -> {
            //onAudioManagerDevicesChanged(device, availableDevices);
            Log.d(TAG, "onAudioManagerDevicesChanged: " + availableDevices + ", " + "selected: " + device);
            // TODO: add callback handler.
        });
    }

看著還蠻簡單的,其中AppRTCAudioManager是創建和管理音頻設備,將負責音頻路由,音頻模式,音頻設備列舉等,功能非常豐富包含了:無線近場傳感器設備,藍牙耳機,傳統有線耳機等,但是這不是本篇文章分析的重點,有興趣的同學自行插眼,以后再學習,

重點是 AppRtcClient.connectToRoom(roomConnectionParameters); 具上分析知道此時AppRTCClient的實體物件是WebSocketRTCClient,里面跳轉到對應的代碼進行分析,

// Connects to room - function runs on a local looper thread.
private void connectToRoomInternal() {
    String connectionUrl = getConnectionUrl(connectionParameters);
    // connectionUrl = https://appr.tc/join/roomId;
   
    wsClient = new WebSocketChannelClient(handler, this);

    RoomParametersFetcher.RoomParametersFetcherEvents callbacks =
            new RoomParametersFetcher.RoomParametersFetcherEvents() {
        @Override
        public void onSignalingParametersReady(final SignalingParameters params) {
            WebSocketRTCClient.this.handler.post(new Runnable() {
                @Override
                public void run() {
                    WebSocketRTCClient.this.signalingParametersReady(params);
                }
            });
        }
        @Override
        public void onSignalingParametersError(String description) {
            WebSocketRTCClient.this.reportError(description);
        }
    };
    new RoomParametersFetcher(connectionUrl, null, callbacks).makeRequest();
}
// Callback issued when room parameters are extracted. Runs on local looper thread.
private void signalingParametersReady(final SignalingParameters signalingParameters) {
    Log.d(TAG, "Room connection completed.");
    if (!signalingParameters.initiator || signalingParameters.offerSdp != null) {
        reportError("Loopback room is busy.");
        return;
    }
    if (!signalingParameters.initiator && signalingParameters.offerSdp == null) {
        Log.w(TAG, "No offer SDP in room response.");
    }
    initiator = signalingParameters.initiator;
    messageUrl = getMessageUrl(connectionParameters, signalingParameters);
    // https://appr.tc/message/roomId/clientId
    leaveUrl = getLeaveUrl(connectionParameters, signalingParameters);
    // https://appr.tc/leave/roomId/clientId
    
    events.onConnectedToRoom(signalingParameters);
    
    wsClient.connect(signalingParameters.wssUrl, signalingParameters.wssPostUrl);
    wsClient.register(connectionParameters.roomId, signalingParameters.clientId);
}

1、請求連接 https://appr.tc/join/roomId,加入到roomId對應的房間,并回傳當前房間的信令引數signalingParameters,

2、通過回傳的信令引數signalingParameters,獲取兩個房間事件的url(message,leave)還有兩個WebSocket的url,可以看到類似日志列印如下:

com.zzrblog.appwebrtc D/RoomRTCClient: RoomId: 028711912. ClientId: 74648260
com.zzrblog.appwebrtc D/RoomRTCClient: Initiator: false
com.zzrblog.appwebrtc D/RoomRTCClient: WSS url: wss://apprtc-ws.webrtc.org:443/ws
com.zzrblog.appwebrtc D/RoomRTCClient: WSS POST url: https://apprtc-ws.webrtc.org:443
com.zzrblog.appwebrtc D/RoomRTCClient: Request TURN from: https://appr.tc/v1alpha/iceconfig?key=
com.zzrblog.appwebrtc D/WebSocketRTCClient: Room connection completed.
com.zzrblog.appwebrtc D/WebSocketRTCClient: Message URL: https://appr.tc/message/028711912/74648260
com.zzrblog.appwebrtc D/WebSocketRTCClient: Leave URL: https://appr.tc/leave/028711912/74648260

四個url只有一個signalingParameters.wssUrl是真的websocket的url,其他都是https是的請求,可以猜想wssUrl是負責房間內部訪客之間的信令互動的地址,其他的都是用于維護房間狀態,

所以到這里知道,信令其實就是一系列用于維護當前 終端/房間 進行端對端通話預連接的邏輯引數,所以WebRTC不可能定義或者實作 與業務強相關的api,因為不同業務需求,信令引數的型別就會改變,很難實作協議化的統一,

4、再分析wsClient(WebSocketChannelClient)的connect 和 register進一步肯定:wssUrl是用于send發送功能命令進行信令互動,wssPostUrl只有在退出房間的時候進行請求,

5、這部分邏輯分析完之后,不要忘了還有 events.onConnectedToRoom(signalingParameters); 把信令引數回呼宿主CallActivity,

private void onConnectedToRoomInternal(final AppRTCClient.SignalingParameters params) {
        signalingParameters = params;
        VideoCapturer videoCapturer = null;
        if (peerConnectionParameters.videoCallEnabled) {
            videoCapturer = createVideoCapturer();
        }
        peerConnectionClient.createPeerConnection(
                localProxyVideoSink, remoteSinks, videoCapturer, signalingParameters);

        if (signalingParameters.initiator) {
            // 房間創建第一人走這里
            peerConnectionClient.createOffer();
        } else {
            if (params.offerSdp != null) {
                peerConnectionClient.setRemoteDescription(params.offerSdp);
                // 如果不是房間創建第一人,那就判斷信令是否拿到offersdp,
                // 如果有有offersdp就證明有人進入房間并發出了offer
                // 設定remote sdp 并 answer
                peerConnectionClient.createAnswer();
            }
            if (params.iceCandidates != null) {
                // Add remote ICE candidates from room.
                for (IceCandidate iceCandidate : params.iceCandidates) {
                    peerConnectionClient.addRemoteIceCandidate(iceCandidate);
                }
            }
        }
    }

分析這一段CallActivity.onConnectedToRoom,配合注釋邏輯應該不難理解,正常測驗流程createPeerConnection之后一般都是走createAnswer的分支,因為用 https://appr.tc 進入房間后便成為創建房間的第一人,按照流程步驟先分析PeerConnection的創建程序,

PeerConnection的創建

現在開始分析PeerConnectionClient是如何創建PeerConnection物件,到現在為止PeerConnectionClient一共進行了三個步驟:1、PeerConnectionClient建構式;2、createPeerConnectionFactory;3、createPeerConnection;按照這個流程貼出詳細代碼,

1、PeerConnectionClient建構式

public PeerConnectionClient(Context appContext, EglBase eglBase,
                            PeerConnectionParameters peerConnectionParameters,
                            PeerConnectionEvents events)
{
    this.rootEglBase = eglBase; this.appContext = appContext; this.events = events;
    this.peerConnectionParameters = peerConnectionParameters;
    this.dataChannelEnabled = peerConnectionParameters.dataChannelParameters != null;

    final String fieldTrials = getFieldTrials(peerConnectionParameters);
    executor.execute(() -> {
        Log.d(TAG, "Initialize WebRTC. Field trials: " + fieldTrials);
        PeerConnectionFactory.initialize(
                PeerConnectionFactory.InitializationOptions.builder(appContext)
                .setFieldTrials(fieldTrials)
                .setEnableInternalTracer(true)
                .createInitializationOptions());
    });
}

WebRTC API代碼//
public static class InitializationOptions.Builder {
    private final Context applicationContext;
    private String fieldTrials = "";
    private boolean enableInternalTracer;
    private NativeLibraryLoader nativeLibraryLoader = new DefaultLoader();
    private String nativeLibraryName = "jingle_peerconnection_so";
    @Nullable private Loggable loggable;
    @Nullable private Severity loggableSeverity;
    ... ...
}

使用Factory的配置模式初始化PeerConnectionFactory,其中有兩個我稍微留意的地方:fieldTrials 和 nativeLibraryName = "jingle_peerconnection_so"; 這里買個關子,放到后面深入原始碼分析的再詳解,

2、createPeerConnectionFactory

private void createPeerConnectionFactoryInternal(PeerConnectionFactory.Options options) {
    // Check if ISAC is used by default.
    preferIsac = peerConnectionParameters.audioCodec!=null
            && peerConnectionParameters.audioCodec.equals(AUDIO_CODEC_ISAC);
    // Create peer connection factory.
    final boolean enableH264HighProfile =
            VIDEO_CODEC_H264_HIGH.equals(peerConnectionParameters.videoCodec);
    final VideoEncoderFactory encoderFactory;
    final VideoDecoderFactory decoderFactory;
    if (peerConnectionParameters.videoCodecHwAcceleration) {
        encoderFactory = new DefaultVideoEncoderFactory(
                rootEglBase.getEglBaseContext(), true, enableH264HighProfile);
        decoderFactory = new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext());
    } else {
        encoderFactory = new SoftwareVideoEncoderFactory();
        decoderFactory = new SoftwareVideoDecoderFactory();
    }
	final AudioDeviceModule adm = createJavaAudioDevice();
    factory = PeerConnectionFactory.builder()
            .setOptions(options)
            .setAudioDeviceModule(adm)
            .setVideoEncoderFactory(encoderFactory)
            .setVideoDecoderFactory(decoderFactory)
            .createPeerConnectionFactory();
    Log.d(TAG, "Peer connection factory created.");
    adm.release();
    // 篇幅關系,只顯示關鍵代碼,
}

AudioDeviceModule createJavaAudioDevice() {
    if (!peerConnectionParameters.useOpenSLES) {
        Log.w(TAG, "External OpenSLES ADM not implemented yet.");
        // TODO: Add support for external OpenSLES ADM.
    }
    // Set audio record error callbacks.
    JavaAudioDeviceModule.AudioRecordErrorCallback audioRecordErrorCallback;
	// Set audio track error callbacks.
    JavaAudioDeviceModule.AudioTrackErrorCallback audioTrackErrorCallback;
    // Set audio record state callbacks.
    JavaAudioDeviceModule.AudioRecordStateCallback audioRecordStateCallback;
    // Set audio track state callbacks.
    JavaAudioDeviceModule.AudioTrackStateCallback audioTrackStateCallback;
    // 篇幅關系就不把代碼貼全了,
    return JavaAudioDeviceModule.builder(appContext)
            .setSamplesReadyCallback(saveRecordedAudioToFile)
            .setUseHardwareAcousticEchoCanceler(!peerConnectionParameters.disableBuiltInAEC)
            .setUseHardwareNoiseSuppressor(!peerConnectionParameters.disableBuiltInNS)
            .setAudioRecordErrorCallback(audioRecordErrorCallback)
            .setAudioTrackErrorCallback(audioTrackErrorCallback)
            .setAudioRecordStateCallback(audioRecordStateCallback)
            .setAudioTrackStateCallback(audioTrackStateCallback)
            .createAudioDeviceModule();
}

這里有幾個點需要標記,根據是否使用硬體加速初始化VideoEncode/DecoderFactory;然后就是AudioDeviceModule,是WebRTC-Java層代表音頻模塊的介面定義類,當前的音頻模塊只支持OpenSLES,然后有兩個我非常關心的設定setUseHardwareAcousticEchoCanceler / setUseHardwareNoiseSuppressor,眾所周知WebRTC被google收購前,最聞名的技術點就是音頻處理這一塊,以后必須深挖出文章,

用一張圖簡單描述PeerConnectionFactory的構成:

3、createPeerConnection

終于到了真正創建PeerConnection的地方了,廢話就不說了,看代碼吧,

public void createPeerConnection(final VideoSink localRender,
                                 final List<VideoSink> remoteSinks,
                                 final VideoCapturer videoCapturer,
                                 final AppRTCClient.SignalingParameters signalingParameters)
{
    this.localRender = localRender; // 本地視頻渲染載體VideoSink
    this.remoteSinks = remoteSinks; // 遠程端視頻渲染載體VideoSink,可能多個,所以是List
    this.videoCapturer = videoCapturer; // 本地視頻源
    this.signalingParameters = signalingParameters;
    executor.execute(() -> {
            createMediaConstraintsInternal();
            createPeerConnectionInternal();
            maybeCreateAndStartRtcEventLog();
    });
}

private void createMediaConstraintsInternal() {
    // Create video constraints if video call is enabled.
    if (isVideoCallEnabled()) {
        videoWidth = peerConnectionParameters.videoWidth;
        videoHeight = peerConnectionParameters.videoHeight;
        videoFps = peerConnectionParameters.videoFps;
    }
    // Create audio constraints.
    audioConstraints = new MediaConstraints();
    // added for audio performance measurements
    if (peerConnectionParameters.noAudioProcessing) {
        Log.d(TAG, "Audio constraints disable audio processing");
        audioConstraints.mandatory.add(
                new MediaConstraints.KeyValuePair(AUDIO_ECHO_CANCELLATION_CONSTRAINT, "false"));
        audioConstraints.mandatory.add(
                new MediaConstraints.KeyValuePair(AUDIO_AUTO_GAIN_CONTROL_CONSTRAINT, "false"));
        audioConstraints.mandatory.add(
                new MediaConstraints.KeyValuePair(AUDIO_HIGH_PASS_FILTER_CONSTRAINT, "false"));
        audioConstraints.mandatory.add(
                new MediaConstraints.KeyValuePair(AUDIO_NOISE_SUPPRESSION_CONSTRAINT, "false"));
    }
    // Create SDP constraints.
    sdpMediaConstraints = new MediaConstraints();
    sdpMediaConstraints.mandatory.add(
            new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"));
    sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair(
            "OfferToReceiveVideo", Boolean.toString(isVideoCallEnabled())));
}

private void createPeerConnectionInternal() {
    PeerConnection.RTCConfiguration rtcConfig =
            new PeerConnection.RTCConfiguration(signalingParameters.iceServers);
    // TCP candidates are only useful when connecting to a server that supports ICE-TCP.
    rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED;
    rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE;
    rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE;
    rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
    rtcConfig.keyType = PeerConnection.KeyType.ECDSA;  //Use ECDSA encryption.
    // Enable DTLS for normal calls and disable for loopback calls.
    rtcConfig.enableDtlsSrtp = !peerConnectionParameters.loopback;
    rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
    // 請關注這里
    peerConnection = factory.createPeerConnection(rtcConfig, pcObserver);

    List<String> mediaStreamLabels = Collections.singletonList("ARDAMS");
    if (isVideoCallEnabled()) {
        peerConnection.addTrack(createVideoTrack(videoCapturer), mediaStreamLabels);
        // We can add the renderers right away because we don't need to wait for an
        // answer to get the remote track.
        remoteVideoTrack = getRemoteVideoTrack();
        if (remoteVideoTrack != null) {
            remoteVideoTrack.setEnabled(renderVideo);
            for (VideoSink remoteSink : remoteSinks) {
                remoteVideoTrack.addSink(remoteSink);
            }
        }
    }
    peerConnection.addTrack(createAudioTrack(), mediaStreamLabels);
    if (isVideoCallEnabled()) {
        for (RtpSender sender : peerConnection.getSenders()) {
            if (sender.track() != null) {
                String trackType = sender.track().kind();
                if (trackType.equals(VIDEO_TRACK_TYPE)) {
                    Log.d(TAG, "Found video sender.");
                    localVideoSender = sender;
                }
            }
        }
    }

    if (peerConnectionParameters.aecDump) {
        try {
            ParcelFileDescriptor aecDumpFileDescriptor =
                    ParcelFileDescriptor.open("Download/audio.aecdump"), ...);
            factory.startAecDump(aecDumpFileDescriptor.detachFd(), -1);
        } catch (IOException e) {
            Log.e(TAG, "Can not open aecdump file", e);
        }
    }
}

private void maybeCreateAndStartRtcEventLog() {
    rtcEventLog = new RtcEventLog(peerConnection);
    rtcEventLog.start(createRtcEventLogOutputFile());
}

代碼編幅有點長,已經是壓縮保留有用的部分,有WebRTC基礎的同學應該可以理解 函式createMediaConstraintsInternal 的邏輯,對應getUserMedia(mediaStreamConstraints)的約束條件設定,

再認真看看 函式createPeerConnectionInternal的邏輯,設定PeerConnection.RTCConfiguration,重點是信令引數SignalingParameters中的iceServers,記錄著業務服務器(就是我們程式員)所提供的打洞服務器stun的url 和 轉發服務器的turn的url,然后使用 PeerConnectionFactory.createPeerConnection(rtcConfig, pcObserver);創建PeerConnection,并通過pcObserver回呼各種狀態處理,由于篇幅關系就不貼代碼了,同學可以自行跟進代碼,記住那幾個IceCandidateConnection的回呼就可以了,

接著就是PeerConnection的兩個addTrack,一起來解讀一下:

List<String> mediaStreamLabels = Collections.singletonList("ARDAMS");
peerConnection.addTrack(createVideoTrack(videoCapturer), mediaStreamLabels);
peerConnection.addTrack(createAudioTrack(), mediaStreamLabels);

private @Nullable AudioTrack createAudioTrack() {
    audioSource = factory.createAudioSource(audioConstraints);
    localAudioTrack = factory.createAudioTrack(AUDIO_TRACK_ID, audioSource);
    localAudioTrack.setEnabled(enableAudio);
    return localAudioTrack;
}
private @Nullable VideoTrack createVideoTrack(VideoCapturer capturer) {
    surfaceTextureHelper =
            SurfaceTextureHelper.create("CaptureThread", rootEglBase.getEglBaseContext());
    videoSource = factory.createVideoSource(capturer.isScreencast());
    capturer.initialize(surfaceTextureHelper, appContext, videoSource.getCapturerObserver());
    capturer.startCapture(videoWidth, videoHeight, videoFps);
    localVideoTrack = factory.createVideoTrack(VIDEO_TRACK_ID, videoSource);
    localVideoTrack.setEnabled(renderVideo);
    localVideoTrack.addSink(localRender);
    return localVideoTrack;
}
///     WebRTC.PeerConnection.內部代碼
public RtpSender addTrack(MediaStreamTrack track, List<String> streamIds) {
    if (track != null && streamIds != null) {
        RtpSender newSender = this.nativeAddTrack(track.getNativeMediaStreamTrack(), streamIds);
        if (newSender == null) {
            throw new IllegalStateException("C++ addTrack failed.");
        } else {
            this.senders.add(newSender);
            return newSender;
        }
    } else {
        throw new NullPointerException("No MediaStreamTrack specified in addTrack.");
    }
}

第一個重點,在PeerConnection.addTrack的內部,通過nativeAddTrack之后回傳一個RtpSender的物件,并在java層上維護起來;

好奇細心的同學可能還會發現,除了RtpSender,還有RtpReceiver and RtpTransceiver,RtpTransceiver = RtpSender + RtpReceiver ;看起來有點凌亂,現在先有個認識(挖坑)以后再來深入分析(填坑)

public class PeerConnection {
private final List<MediaStream> localStreams;
private final long nativePeerConnection;
private List<RtpSender> senders;
private List<RtpReceiver> receivers;
private List<RtpTransceiver> transceivers;
... ...
}
public class RtpTransceiver {
private long nativeRtpTransceiver;
private RtpSender cachedSender;
private RtpReceiver cachedReceiver;
... ...
}
public class RtpReceiver {
private long nativeRtpReceiver;
private long nativeObserver;
@Nullable private MediaStreamTrack cachedTrack;
... ...
}
public class RtpSender {
private long nativeRtpSender;
@Nullable private MediaStreamTrack cachedTrack;
@Nullable private final DtmfSender dtmfSender;
... ...
}

第二個重點,把本地的videoTrack和audioTrack添加到PeerConnection之后,接著嘗試從PeerConnection的RtpTransceiver中獲取 遠端的remoteVideoTrack,并把當前對應渲染遠程視頻的VideoSink加入到remoteVideoTrack;同時再獲取對應的,貼出對應的代碼片段:

remoteVideoTrack = getRemoteVideoTrack();
if (remoteVideoTrack != null) {
    for (VideoSink remoteSink : remoteSinks) {
        remoteVideoTrack.addSink(remoteSink);
    }
}

// Returns the remote VideoTrack, assuming there is only one.
private @Nullable VideoTrack getRemoteVideoTrack() {
    for (RtpTransceiver transceiver : peerConnection.getTransceivers()) {
        MediaStreamTrack track = transceiver.getReceiver().track();
        if (track instanceof VideoTrack) {
            return (VideoTrack) track;
        }
    }
    return null;
}

到這里基本已經解讀完 createPeerConnection的邏輯,這里給出兩個總結思路點:

1、怎樣理解Track,Source,Sink的三者關系,它們是如何連接上的?

答:其實從字面意思可以理解:首先source就是資料來源或者資料輸入的封裝,對于視頻來源一般都是攝像頭物件或者檔案物件,source注入到track輸送軌道,成為一個source與sink的溝通橋梁,sink就相當于輸送軌道的終點水槽,一個source可以流經多個track,一個track最終也可以流到多個sink,并通過不同的sink做處理之后進行輸出,至于在WebRTC的代碼中,VideoSink無外乎就是surfaceview等android系統的渲染載體,也可以是本地檔案寫入,抽象理解這些物件之間的關系有助于以后深入分析代碼,

2、PeerConnection的構成如下所示:(緊接著上方Factory的圖)

onConnectedToRoom

還記得PeerConnection是從哪里觸發創建的嗎?(參考onConnectedToRoomInternal代碼片段)就是在連接房間前訪問https://appr.tc/join/roomid,獲取到信令引數之后的SignalingEvents.onConnectedToRoom回呼,那么現在回歸到這里,正常測驗用瀏覽器訪問 https://appr.tc 隨機生成roomid后進入房間便成為創建房間的第一人,所以在createPeerConnection之后一般都是走createAnswer的分支,從代碼看到createOffer / createAnswer / setRemoteDescription,唯獨缺了setLocalDescription,帶著這些疑問我們繼續分析PeerConnectionClient的邏輯流程,

public void createAnswer() {
    executor.execute(() -> {
        if (peerConnection != null && !isError) {
            isInitiator = false;
            peerConnection.createAnswer(sdpObserver, sdpMediaConstraints);
        }
    });
}
public void createOffer() {
    executor.execute(() -> {
        if (peerConnection != null && !isError) {
            isInitiator = true;
            peerConnection.createOffer(sdpObserver, sdpMediaConstraints);
        }
    });
}
public void setRemoteDescription(final SessionDescription sdp) {
    executor.execute(() -> {
        // 按需修改sdp設定
        SessionDescription sdpRemote = new SessionDescription(sdp.type, sdpDescription);
        peerConnection.setRemoteDescription(sdpObserver, sdpRemote);
    });
}

private class SDPObserver implements SdpObserver {
    @Override
    public void onCreateSuccess(SessionDescription sdp) {
        if (localSdp != null) {
            reportError("LocalSdp has created.");
            return;
        }
        String sdpDescription = sdp.description;
        if (preferIsac) {
            sdpDescription = preferCodec(sdpDescription, AUDIO_CODEC_ISAC, true);
        }
        if (isVideoCallEnabled()) {
            sdpDescription =
                    preferCodec(sdpDescription, getSdpVideoCodecName(peerConnectionParameters), false);
        }
        final SessionDescription renewSdp = new SessionDescription(sdp.type, sdpDescription);
        localSdp = renewSdp;
        executor.execute(() -> {
            if (peerConnection != null && !isError) {
                Log.d(TAG, "Set local SDP from " + sdp.type);
                peerConnection.setLocalDescription(sdpObserver, sdp);
            }
        });
    }

    @Override
    public void onSetSuccess() {
        executor.execute(() -> {
            if (peerConnection == null || isError) {
                return;
            }
            if (isInitiator) {
                // For offering peer connection we first create offer and set
                // local SDP, then after receiving answer set remote SDP.
                if (peerConnection.getRemoteDescription() == null) {
                    // We've just set our local SDP so time to send it.
                    Log.d(TAG, "Local SDP set successfully");
                    events.onLocalDescription(localSdp);
                } else {
                    // We've just set remote description,
                    // so drain remote and send local ICE candidates.
                    Log.d(TAG, "Remote SDP set successfully");
                    drainCandidates();
                }
            } else {
                // For answering peer connection we set remote SDP and then
                // create answer and set local SDP.
                if (peerConnection.getLocalDescription() != null) {
                    // We've just set our local SDP so time to send it, drain
                    // remote and send local ICE candidates.
                    Log.d(TAG, "Local SDP set successfully");
                    events.onLocalDescription(localSdp);
                    drainCandidates();
                } else {
                    Log.d(TAG, "Remote SDP set succesfully");
                }
            }
        });
    }

    @Override
    public void onCreateFailure(String error) {
        reportError("createSDP error: " + error);
    }
    @Override
    public void onSetFailure(String error) {
        reportError("setSDP error: " + error);
    }
}

從實作代碼可以看到 PeerConnection的createOffer/Answer的事件都由一個SDPObserver接管,其中有兩個回呼函式onCreateSuccess/onSetSuccess,看名字就可以猜測到onCreateSuccess是createOffer/Answer成功之后的回呼,onSetSuccess是setLocal/RemoteDescription的回呼,

按照正常流程進行解讀:

1、在createPeerConnection之后呼叫createAnswer,觸發回呼SDPObserver.onCreateSuccess,此時全域變數localSdp==null,會跟著創建localSdp并呼叫setLocalDescription

2、創建localSdp呼叫setLocalDescription后,觸發SDPObserver.onSetSuccess,因為是非創建第一人,走isInitiator==false的分支;因為在第一步setLocalDescription,所以PeerConnection.getLocalDescription() != null,回呼PeerConnectionEvents.onLocalDescription(localSdp)到CallActivity;

 implements  PeerConnectionClient.PeerConnectionEvents
@Override
public void onLocalDescription(SessionDescription sdp) {
    runOnUiThread(() -> {
        if (appRtcClient != null) {
            if (signalingParameters!=null && signalingParameters.initiator) {
                appRtcClient.sendOfferSdp(sdp);
            } else {
                appRtcClient.sendAnswerSdp(sdp);
            }
            // ... ...
        }
    });
}

3、因為是非創建房間第一人,走isInitiator==false的分支,觸發AppRTCClient實體WebSocketRTCClient.sendAnswerSdp

@Override
public void sendAnswerSdp(SessionDescription sdp) {
    handler.post(new Runnable() {
        @Override
        public void run() {
            JSONObject json = new JSONObject();
            jsonPut(json, "sdp", sdp.description);
            jsonPut(json, "type", "answer");
            wsClient.send(json.toString());
        }
    });
}

public void onWebSocketMessage(String message) {

        JSONObject json = new JSONObject(message);
        String msgText = json.getString("msg");
        String errorText = json.optString("error");
        if (msgText.length() > 0) {
            json = new JSONObject(msgText);
            String type = json.optString("type");
            if (type.equals("candidate")) {
                events.onRemoteIceCandidate(toJavaCandidate(json));
            }else if (type.equals("remove-candidates")) {
                JSONArray candidateArray = json.getJSONArray("candidates");
                IceCandidate[] candidates = new IceCandidate[candidateArray.length()];
                for (int i = 0; i < candidateArray.length(); ++i) {
                    candidates[i] = toJavaCandidate(candidateArray.getJSONObject(i));
                }
                events.onRemoteIceCandidatesRemoved(candidates);
            } else if (type.equals("answer")) {
                if (initiator) {
                    SessionDescription sdp = new SessionDescription(
                            SessionDescription.Type.fromCanonicalForm(type), json.getString("sdp"));
                    events.onRemoteDescription(sdp);
                } else {
                    reportError("Received answer for call initiator: " + message);
                }
            } else if (type.equals("offer")) {
                if (!initiator) {
                    SessionDescription sdp = new SessionDescription(
                            SessionDescription.Type.fromCanonicalForm(type), json.getString("sdp"));
                    events.onRemoteDescription(sdp);
                } else {
                    reportError("Received offer for call receiver: " + message);
                }
            } else if (type.equals("bye")) {
                events.onChannelClose();
            } else {
                reportError("Unexpected WebSocket message: " + message);
            }
        } else {
            if (errorText.length() > 0) {
                reportError("WebSocket error message: " + errorText);
            } else {
                reportError("Unexpected WebSocket message: " + message);
            }
        }
}

4、wsClient是利用WebSocket建立連接wssUrl的通信物件,之前就分析過wssUrl對應的服務是用于負責房間內部訪客之間交換信令引數,在onWebSocketMessage處理各種型別的訊息并進行回呼,但是這里并不是回呼 type.equals("answer") 型別的資訊,有興趣的同學把這個回呼的資訊全列印出來,這對于了解整個信令引數互動的程序是非常有幫助的,

我這里直接給出答案:可能是 "candidate" / "offer"型別的訊息,也可能是什么資訊都沒收到了, "candidate"型別的訊息可能性比較大,因為"offer"在wssUrl創建鏈接成功之后一般就會立刻收到此類資訊,進而回呼CallActivity.onRemoteDescription,進而呼叫PeerConnectionClient.createAnswer(),有同學可能就懵逼了,第1步不是已經createAnswer了嚒?是的,但是這次回呼SDPObserver.onCreateSuccess后,localSdp!=null,就不再往外發送任何型別的資訊了,到此local/remote的sdp都已經設定成功了,

結束了嗎?

到此本篇文章算是結束了,但是Android-WebRTC還有很多很多內容值得深挖,后續會有一系列文章,記錄自己的學習程序,重點是放在網路連接傳輸,視頻編解碼,音頻處理(回聲消除降噪)等模塊,

下一篇內容多數會是涉及WebRTC Java的API分析,以及打開jingle_peerconnection_so的原始碼之門,

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/275554.html

標籤:其他

上一篇:qt+sdl+ffmpeg 實作音視頻播放器

下一篇:C++跳躍游戲之能否跳到某個位置

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 網閘典型架構簡述

    網閘架構一般分為兩種:三主機的三系統架構網閘和雙主機的2+1架構網閘。 三主機架構分別為內端機、外端機和仲裁機。三機無論從軟體和硬體上均各自獨立。首先從硬體上來看,三機都用各自獨立的主板、記憶體及存盤設備。從軟體上來看,三機有各自獨立的作業系統。這樣能達到完全的三機獨立。對于“2+1”系統,“2”分為 ......

    uj5u.com 2020-09-10 02:00:44 more
  • 如何從xshell上傳檔案到centos linux虛擬機里

    如何從xshell上傳檔案到centos linux虛擬機里及:虛擬機CentOs下執行 yum -y install lrzsz命令,出現錯誤:鏡像無法找到軟體包 前言 一、安裝lrzsz步驟 二、上傳檔案 三、遇到的問題及解決方案 總結 前言 提示:其實很簡單,往虛擬機上安裝一個上傳檔案的工具 ......

    uj5u.com 2020-09-10 02:00:47 more
  • 一、SQLMAP入門

    一、SQLMAP入門 1、判斷是否存在注入 sqlmap.py -u 網址/id=1 id=1不可缺少。當注入點后面的引數大于兩個時。需要加雙引號, sqlmap.py -u "網址/id=1&uid=1" 2、判斷文本中的請求是否存在注入 從文本中加載http請求,SQLMAP可以從一個文本檔案中 ......

    uj5u.com 2020-09-10 02:00:50 more
  • Metasploit 簡單使用教程

    metasploit 簡單使用教程 浩先生, 2020-08-28 16:18:25 分類專欄: kail 網路安全 linux 文章標簽: linux資訊安全 編輯 著作權 metasploit 使用教程 前言 一、Metasploit是什么? 二、準備作業 三、具體步驟 前言 Msfconsole ......

    uj5u.com 2020-09-10 02:00:53 more
  • 游戲逆向之驅動層與用戶層通訊

    驅動層代碼: #pragma once #include <ntifs.h> #define add_code CTL_CODE(FILE_DEVICE_UNKNOWN,0x800,METHOD_BUFFERED,FILE_ANY_ACCESS) /* 更多游戲逆向視頻www.yxfzedu.com ......

    uj5u.com 2020-09-10 02:00:56 more
  • 北斗電力時鐘(北斗授時服務器)讓網路資料更精準

    北斗電力時鐘(北斗授時服務器)讓網路資料更精準 北斗電力時鐘(北斗授時服務器)讓網路資料更精準 京準電子科技官微——ahjzsz 近幾年,資訊技術的得了快速發展,互聯網在逐漸普及,其在人們生活和生產中都得到了廣泛應用,并且取得了不錯的應用效果。計算機網路資訊在電力系統中的應用,一方面使電力系統的運行 ......

    uj5u.com 2020-09-10 02:01:03 more
  • 【CTF】CTFHub 技能樹 彩蛋 writeup

    ?碎碎念 CTFHub:https://www.ctfhub.com/ 筆者入門CTF時時剛開始刷的是bugku的舊平臺,后來才有了CTFHub。 感覺不論是網頁UI設計,還是題目質量,賽事跟蹤,工具軟體都做得很不錯。 而且因為獨到的金幣制度的確讓人有一種想去刷題賺金幣的感覺。 個人還是非常喜歡這個 ......

    uj5u.com 2020-09-10 02:04:05 more
  • 02windows基礎操作

    我學到了一下幾點 Windows系統目錄結構與滲透的作用 常見Windows的服務詳解 Windows埠詳解 常用的Windows注冊表詳解 hacker DOS命令詳解(net user / type /md /rd/ dir /cd /net use copy、批處理 等) 利用dos命令制作 ......

    uj5u.com 2020-09-10 02:04:18 more
  • 03.Linux基礎操作

    我學到了以下幾點 01Linux系統介紹02系統安裝,密碼啊破解03Linux常用命令04LAMP 01LINUX windows: win03 8 12 16 19 配置不繁瑣 Linux:redhat,centos(紅帽社區版),Ubuntu server,suse unix:金融機構,證券,銀 ......

    uj5u.com 2020-09-10 02:04:30 more
  • 05HTML

    01HTML介紹 02頭部標簽講解03基礎標簽講解04表單標簽講解 HTML前段語言 js1.了解代碼2.根據代碼 懂得挖掘漏洞 (POST注入/XSS漏洞上傳)3.黑帽seo 白帽seo 客戶網站被黑帽植入劫持代碼如何處理4.熟悉html表單 <html><head><title>TDK標題,描述 ......

    uj5u.com 2020-09-10 02:04:36 more
最新发布
  • 2023年最新微信小程式抓包教程

    01 開門見山 隔一個月發一篇文章,不過分。 首先回顧一下《微信系結手機號資料庫被脫庫事件》,我也是第一時間得知了這個訊息,然后跟蹤了整件事情的經過。下面是這起事件的相關截圖以及近日流出的一萬條資料樣本: 個人認為這件事也沒什么,還不如關注一下之前45億快遞資料查詢渠道疑似在近日復活的訊息。 訊息是 ......

    uj5u.com 2023-04-20 08:48:24 more
  • web3 產品介紹:metamask 錢包 使用最多的瀏覽器插件錢包

    Metamask錢包是一種基于區塊鏈技術的數字貨幣錢包,它允許用戶在安全、便捷的環境下管理自己的加密資產。Metamask錢包是以太坊生態系統中最流行的錢包之一,它具有易于使用、安全性高和功能強大等優點。 本文將詳細介紹Metamask錢包的功能和使用方法。 一、 Metamask錢包的功能 數字資 ......

    uj5u.com 2023-04-20 08:47:46 more
  • vulnhub_Earth

    前言 靶機地址->>>vulnhub_Earth 攻擊機ip:192.168.20.121 靶機ip:192.168.20.122 參考文章 https://www.cnblogs.com/Jing-X/archive/2022/04/03/16097695.html https://www.cnb ......

    uj5u.com 2023-04-20 07:46:20 more
  • 從4k到42k,軟體測驗工程師的漲薪史,給我看哭了

    清明節一過,盲猜大家已經無心上班,在數著日子準備過五一,但一想到銀行卡里的余額……瞬間心情就不美麗了。最近,2023年高校畢業生就業調查顯示,本科畢業月平均起薪為5825元。調查一出,便有很多同學表示自己又被平均了。看著這一資料,不免讓人想到前不久中國青年報的一項調查:近六成大學生認為畢業10年內會 ......

    uj5u.com 2023-04-20 07:44:00 more
  • 最新版本 Stable Diffusion 開源 AI 繪畫工具之中文自動提詞篇

    🎈 標簽生成器 由于輸入正向提示詞 prompt 和反向提示詞 negative prompt 都是使用英文,所以對學習母語的我們非常不友好 使用網址:https://tinygeeker.github.io/p/ai-prompt-generator 這個網址是為了讓大家在使用 AI 繪畫的時候 ......

    uj5u.com 2023-04-20 07:43:36 more
  • 漫談前端自動化測驗演進之路及測驗工具分析

    隨著前端技術的不斷發展和應用程式的日益復雜,前端自動化測驗也在不斷演進。隨著 Web 應用程式變得越來越復雜,自動化測驗的需求也越來越高。如今,自動化測驗已經成為 Web 應用程式開發程序中不可或缺的一部分,它們可以幫助開發人員更快地發現和修復錯誤,提高應用程式的性能和可靠性。 ......

    uj5u.com 2023-04-20 07:43:16 more
  • CANN開發實踐:4個DVPP記憶體問題的典型案例解讀

    摘要:由于DVPP媒體資料處理功能對存放輸入、輸出資料的記憶體有更高的要求(例如,記憶體首地址128位元組對齊),因此需呼叫專用的記憶體申請介面,那么本期就分享幾個關于DVPP記憶體問題的典型案例,并給出原因分析及解決方法。 本文分享自華為云社區《FAQ_DVPP記憶體問題案例》,作者:昇騰CANN。 DVPP ......

    uj5u.com 2023-04-20 07:43:03 more
  • msf學習

    msf學習 以kali自帶的msf為例 一、msf核心模塊與功能 msf模塊都放在/usr/share/metasploit-framework/modules目錄下 1、auxiliary 輔助模塊,輔助滲透(埠掃描、登錄密碼爆破、漏洞驗證等) 2、encoders 編碼器模塊,主要包含各種編碼 ......

    uj5u.com 2023-04-20 07:42:59 more
  • Halcon軟體安裝與界面簡介

    1. 下載Halcon17版本到到本地 2. 雙擊安裝包后 3. 步驟如下 1.2 Halcon軟體安裝 界面分為四大塊 1. Halcon的五個助手 1) 影像采集助手:與相機連接,設定相機引數,采集影像 2) 標定助手:九點標定或是其它的標定,生成標定檔案及內參外參,可以將像素單位轉換為長度單位 ......

    uj5u.com 2023-04-20 07:42:17 more
  • 在MacOS下使用Unity3D開發游戲

    第一次發博客,先發一下我的游戲開發環境吧。 去年2月份買了一臺MacBookPro2021 M1pro(以下簡稱mbp),這一年來一直在用mbp開發游戲。我大致分享一下我的開發工具以及使用體驗。 1、Unity 官網鏈接: https://unity.cn/releases 我一般使用的Apple ......

    uj5u.com 2023-04-20 07:40:19 more