一、引言

        近期,我发现电视家、火星直播等在线看电视直播的软件都已倒闭,而我奶奶也再无法通过这些平台看电视了。她已六十多岁,快七十岁啦。这些平台的倒下对我来说其实没有多大的影响,但是对于文化不多的她而言,生活中却是少了一大乐趣。因为自己学过编程,所以我想帮她解决这个问题。她只听得懂白话,又最爱看“广东珠江台”,因此,我通过Android的编程技术,为她专门定制一款可以自动看广东珠江台的App,打开即用,免了点来点去的麻烦。虽说需求很小、只够她一人使用,但实现起来却并不简单呀。通过两天时间的深入钻研,最终我还是把这个小需求给实现了。为此编写一篇博客,如果日后自己还需要解决这样的问题时,我就直接ctrl+c加ctrl+v。当然,也希望这篇博客能够为你提供一些指导和帮助。

二、访问网页视频+自动播放的实现思路

        由于许多的m3u8链接都已经失效,现在看电视直播只能通过一些官方网页来实现,比如央视网等等。那么,访问网页的话是可以通过Android的WebView来实现,实现的方法非常简单,就是在Activity界面之中添加一个WebView空间,然后通过下面的代码来访问网页:

main_wv = findViewById(R.id.main_wv);
main_wv.setWebViewClient(new WebViewClient());
main_wv.setWebChromeClient(new WebChromeClient());
main_wv.loadUrl("https://***.***");

        但是这样的话,最多也只能够竖屏中观看视频,显示十分地有限,而且还不会自动播放,如图:

        所以,这里先介绍实现网页访问+自动播放的思路。WebView可以通过自定义的设置来控制它是否支持JavaScript脚本注入,即通过js来实现网页视频的自动播放。第一步是对WebView进行设置,而第二步是执行js自动播放视频的脚本,代码如下:

        设置WebView支持js脚本

WebSettings settings = main_wv.getSettings(); // main_wv为WebView控件
settings.setJavaScriptEnabled(true);

        执行js自动播放视频的脚本

String js = "javascript:";
js += "var videos = document.getElementsByTagName('video');";
js += "var video_last;";
js += "var video = videos[videos.length-1];";
js += "if (video != undefined && video != video_last) {";
{
    js += "video_last = video;";
    js += "function video_start() {";
    {
        js += "_VideoEnabledWebView.notifyVideoStart();";
    }
    js += "}";
    js += "video.addEventListener('play', video_start);";
}
js += "}";
main_wv.loadUrl(js);    // main_wv为WebView控件

        忘了介绍,该js脚本在什么时候执行了。这里补充一下,为了使得页面自动播放视频,需要重写一个WebViewClient类,类名可以随你定义,比如“MyWebViewClient”,然后重写public void onPageFinished(WebView view, String url)方法,如下:

@Override
public void onPageFinished(WebView view, String url) {
    super.onPageFinished(view, url);
    String js = "javascript:";
    js += "var videos = document.getElementsByTagName('video');";
    js += "var video_last;";
    js += "var video = videos[videos.length-1];";
    js += "if (video != undefined && video != video_last) {";
    {
        js += "video_last = video;";
        js += "function video_start() {";
        {
            js += "_VideoEnabledWebView.notifyVideoStart();";
        }
        js += "}";
        js += "video.addEventListener('play', video_start);";
    }
    js += "}";
    main_wv.loadUrl(js); // main_wv为WebView控件
}

        至此,还请记得改

main_wv.setWebViewClient(new WebViewClient()); // main_wv为WebView控件

        为

main_wv.setWebViewClient(new MyWebViewClient()); // main_wv为WebView控件

        这样,在访问网页时,视频就可以自动播放啦。

三、网页自动全屏思路

        网页全屏的实现思路比较复杂,因为Android开发者的初衷是使网页设计者无法通过js的方式直接全屏地播放视频。也就是说,通过js注入的方式无法直接在WebView中实现网页的全屏播放。为什么呢?根据其他人的说法,这是因为Android开发者担心你的手机不小心访问到一个流氓网页,然后该网页直接全屏,使你的手机失控,被它播放的视频霸占许久、不能退出。我想了想,觉得这个理由倒是有些许合理的。因此执行js脚本注入无法直接在WebView中实现网页视频的全屏播放。

        但是网页全屏的实现是完全没有问题的。大体的思路是:重写WebChromeClient和WebView这两个类,通过重写其中的内部方法,在加载完网页后,再调用js脚本注入,进而触发视频的全屏播放。而触发视频的全屏播放的js脚本为:

"javascript:(" +
"function() { " +
" var videos = document.getElementsByTagName('video'); " +
" var video = videos[0]; " +
" if (!document.webkitFullScreen && video.webkitEnterFullscreen) {" +
" video.webkitEnterFullscreen(); " +
" } " +
" })()"

        由于实现的细节很多,再加上我只是简单地研究了一下,所以没法更详细地展开说说了。请大家看后边“全部代码”的章节部分,了解更多细节。

四、手机默认横屏思路

        手机默认横屏的思路实现起来非常简单,简单得不得了,只需要在Manifest.xml这个清单文件中对指定的Activity声明相关的属性即可。例如,用于播放的Activity为MainActivity,那么就这样设置:

<activity android:name=".MainActivity"
    android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
    android:screenOrientation="landscape"
    android:configChanges="orientation|screenSize|keyboardHidden"
    android:hardwareAccelerated="true">
</activity>

        其中,最关键的两行代码为

android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
android:screenOrientation="landscape"

        上面一行说的是让app在手机上运行时不显示标题栏(这个可以看你个人需求,有的人喜欢留着,有的人喜欢去掉),而下面一行则是实现横屏的开关,landscape指的是风景,意为通过手机横屏的方式欣赏图片中的风景,以尽可能地使你更加清楚地目睹一张横向的风景图。

五、全部代码

        项目的代码目录,其中画横线部分是重点,我从创建项目到生成可运行且有效果的App只改动过这些文件。下面我将每个框中的文件中的代码罗列出来。

        AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="***.***.******">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity"
            android:hardwareAccelerated="true"
            android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
            android:screenOrientation="landscape">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

        MainActivity.java

package ***.***.***;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.Bundle;
import android.view.ViewGroup;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;

public class MainActivity extends Activity {
    VideoEnabledWebView mainWebView;
    RelativeLayout mainNonVideoRelativeLayout;
    ViewGroup mainVideoLayout;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        initView();         // initialize ui components
        setWebView();       // set webview's settings
        loadVideoUrl();     // load video url
    }

    private void initView() {
        setContentView(R.layout.activity_main);
        mainNonVideoRelativeLayout = (RelativeLayout) findViewById(R.id.main_rl_non_video);
        mainVideoLayout = (ViewGroup)findViewById(R.id.main_rl_video);
    }

    @SuppressLint("SetJavaScriptEnabled")
    private void setWebView() {
        // create a webview instance
        mainWebView = new VideoEnabledWebView(this);
        mainWebView.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));

        // add the webview instance to the main layout
        mainNonVideoRelativeLayout.addView(mainWebView);

        // set general settings of webview
        WebSettings settings = mainWebView.getSettings();
        settings.setAllowFileAccess(true);
        settings.setBuiltInZoomControls(false);
        settings.setJavaScriptEnabled(true);
        settings.setBuiltInZoomControls(false);

        // create a WebChromeClient instance and use it to set the webview
        VideoEnabledWebChromeClient videoEnabledWebChromeClient = new VideoEnabledWebChromeClient(mainNonVideoRelativeLayout, mainVideoLayout,null, mainWebView);
        mainWebView.setWebChromeClient(videoEnabledWebChromeClient);

        // create a WebViewClient for webview
        mainWebView.setWebViewClient(new WebViewClient(){
            @Override
            public void onPageFinished(WebView view, String url) {
                super.onPageFinished(view, url);
                // execute a javascript to automatically play the video
                String js = "javascript:";
                js += "var videos = document.getElementsByTagName('video');";
                js += "var video_last;";
                js += "var video = videos[videos.length-1];";
                js += "if (video != undefined && video != video_last) {";
                {
                    js += "video_last = video;";
                    js += "function video_start() {";
                    {
                        js += "_VideoEnabledWebView.notifyVideoStart();";
                    }
                    js += "}";
                    js += "video.addEventListener('play', video_start);";
                }
                js += "}";
                mainWebView.loadUrl(js);
            }
        });
    }

    private void loadVideoUrl() {
        mainWebView.loadUrl("https://******"); // your url that contains the video
    }
}

        activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <RelativeLayout
        android:id="@+id/main_rl_non_video"
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
    </RelativeLayout>
    <RelativeLayout
        android:id="@+id/main_rl_video"
        android:layout_width="match_parent"
        android:layout_height="match_parent" >
    </RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

        VideoEnabledWebChromeClient.java

package ***.***.***;

import android.media.MediaPlayer;
import android.view.SurfaceView;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebChromeClient;
import android.widget.FrameLayout;

public class VideoEnabledWebChromeClient extends WebChromeClient implements MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener {
    public interface ToggledFullscreenCallback {
        void toggledFullscreen(boolean fullscreen);
    }

    private View activityNonVideoView;
    private ViewGroup activityVideoView;
    private View loadingView;
    private VideoEnabledWebView webView;

    // Indicates if the video is being displayed using a custom view (typically full-screen)
    private boolean isVideoFullscreen;

    private FrameLayout videoViewContainer;
    private CustomViewCallback videoViewCallback;

    private ToggledFullscreenCallback toggledFullscreenCallback;

    /**
     * Never use this constructor alone.
     * This constructor allows this class to be defined as an inline inner class in which the user can override methods
     */
    @SuppressWarnings("unused")
    public VideoEnabledWebChromeClient() {
    }

    /**
     * Builds a video enabled WebChromeClient.
     * @param activityNonVideoView A View in the activity's layout that contains every other view that should be hidden when the video goes full-screen.
     * @param activityVideoView    A ViewGroup in the activity's layout that will display the video. Typically you would like this to fill the whole layout.
     */
    @SuppressWarnings("unused")
    public VideoEnabledWebChromeClient(View activityNonVideoView, ViewGroup activityVideoView) {
        this.activityNonVideoView = activityNonVideoView;
        this.activityVideoView = activityVideoView;
        this.loadingView = null;
        this.webView = null;
        this.isVideoFullscreen = false;
    }

    /**
     * Builds a video enabled WebChromeClient.
     * @param activityNonVideoView A View in the activity's layout that contains every other view that should be hidden when the video goes full-screen.
     * @param activityVideoView    A ViewGroup in the activity's layout that will display the video. Typically you would like this to fill the whole layout.
     * @param loadingView          A View to be shown while the video is loading (typically only used in API level <11). Must be already inflated and not attached to a parent view.
     */
    @SuppressWarnings("unused")
    public VideoEnabledWebChromeClient(View activityNonVideoView, ViewGroup activityVideoView, View loadingView) {
        this.activityNonVideoView = activityNonVideoView;
        this.activityVideoView = activityVideoView;
        this.loadingView = loadingView;
        this.webView = null;
        this.isVideoFullscreen = false;
    }

    /**
     * Builds a video enabled WebChromeClient.
     * @param activityNonVideoView A View in the activity's layout that contains every other view that should be hidden when the video goes full-screen.
     * @param activityVideoView    A ViewGroup in the activity's layout that will display the video. Typically you would like this to fill the whole layout.
     * @param loadingView          A View to be shown while the video is loading (typically only used in API level <11). Must be already inflated and not attached to a parent view.
     * @param webView              The owner VideoEnabledWebView. Passing it will enable the VideoEnabledWebChromeClient to detect the HTML5 video ended event and exit full-screen.
     *                             Note: The web page must only contain one video tag in order for the HTML5 video ended event to work. This could be improved if needed (see Javascript code).
     */
    @SuppressWarnings("unused")
    public VideoEnabledWebChromeClient(View activityNonVideoView, ViewGroup activityVideoView, View loadingView, VideoEnabledWebView webView) {
        this.activityNonVideoView = activityNonVideoView;
        this.activityVideoView = activityVideoView;
        this.loadingView = loadingView;
        this.webView = webView;
        this.isVideoFullscreen = false;
    }

    /**
     * Indicates if the video is being displayed using a custom view (typically full-screen)
     * @return true it the video is being displayed using a custom view (typically full-screen)
     */
    public boolean isVideoFullscreen() {
        return isVideoFullscreen;
    }

    /**
     * Set a callback that will be fired when the video starts or finishes displaying using a custom view (typically full-screen)
     * @param callback A VideoEnabledWebChromeClient.ToggledFullscreenCallback callback
     */
    @SuppressWarnings("unused")
    public void setOnToggledFullscreen(ToggledFullscreenCallback callback) {
        this.toggledFullscreenCallback = callback;
    }

    @Override
    public void onShowCustomView(View view, CustomViewCallback callback) {
        if (view instanceof FrameLayout) {
            // A video wants to be shown
            FrameLayout frameLayout = (FrameLayout) view;
            View focusedChild = frameLayout.getFocusedChild();

            // Save video related variables
            this.isVideoFullscreen = true;
            this.videoViewContainer = frameLayout;
            this.videoViewCallback = callback;

            // Hide the non-video view, add the video view, and show it
            activityNonVideoView.setVisibility(View.INVISIBLE);
            activityVideoView.addView(videoViewContainer, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
            activityVideoView.setVisibility(View.VISIBLE);

            if (focusedChild instanceof android.widget.VideoView) {
                // android.widget.VideoView (typically API level <11)
                android.widget.VideoView videoView = (android.widget.VideoView) focusedChild;

                // Handle all the required events
                videoView.setOnPreparedListener(this);
                videoView.setOnCompletionListener(this);
                videoView.setOnErrorListener(this);
            } else {
                // Other classes, including:
                // - android.webkit.HTML5VideoFullScreen$VideoSurfaceView, which inherits from android.view.SurfaceView (typically API level 11-18)
                // - android.webkit.HTML5VideoFullScreen$VideoTextureView, which inherits from android.view.TextureView (typically API level 11-18)
                // - com.android.org.chromium.content.browser.ContentVideoView$VideoSurfaceView, which inherits from android.view.SurfaceView (typically API level 19+)
                // Handle HTML5 video ended event only if the class is a SurfaceView
                // Test case: TextureView of Sony Xperia T API level 16 doesn't work fullscreen when loading the javascript below
                if (webView != null && webView.getSettings().getJavaScriptEnabled() && focusedChild instanceof SurfaceView) {
                    // Run javascript code that detects the video end and notifies the Javascript interface
                    String js = "javascript:";
                    js += "var _ytrp_html5_video_last;";
                    js += "var _ytrp_html5_video = document.getElementsByTagName('video')[0];";
                    js += "if (_ytrp_html5_video != undefined && _ytrp_html5_video != _ytrp_html5_video_last) {";
                    {
                        js += "_ytrp_html5_video_last = _ytrp_html5_video;";
                        js += "function _ytrp_html5_video_ended() {";
                        {
                            js += "_VideoEnabledWebView.notifyVideoEnd();"; // Must match Javascript interface name and method of VideoEnableWebView
                        }
                        js += "}";
                        js += "_ytrp_html5_video.addEventListener('ended', _ytrp_html5_video_ended);";
                    }
                    js += "}";
                    webView.loadUrl(js);
                }
            }

            // Notify full-screen change
            if (toggledFullscreenCallback != null) {
                toggledFullscreenCallback.toggledFullscreen(true);
            }
        }
    }

    @Override
    // Available in API level 14+, deprecated in API level 18+
    public void onShowCustomView(View view, int requestedOrientation, CustomViewCallback callback) {
        onShowCustomView(view, callback);
    }

    @Override
    // This method should be manually called on video end in all cases because it's not always called automatically.
    // This method must be manually called on back key press (from this class' onBackPressed() method).
    public void onHideCustomView() {
        if (isVideoFullscreen) {
            // Hide the video view, remove it, and show the non-video view
            activityVideoView.setVisibility(View.INVISIBLE);
            activityVideoView.removeView(videoViewContainer);
            activityNonVideoView.setVisibility(View.VISIBLE);

            // Call back (only in API level <19, because in API level 19+ with chromium webview it crashes)
            if (videoViewCallback != null && !videoViewCallback.getClass().getName().contains(".chromium.")) {
                videoViewCallback.onCustomViewHidden();
            }

            // Reset video related variables
            isVideoFullscreen = false;
            videoViewContainer = null;
            videoViewCallback = null;

            // Notify full-screen change
            if (toggledFullscreenCallback != null) {
                toggledFullscreenCallback.toggledFullscreen(false);
            }
        }
    }

    @Override
    // Video will start loading
    public View getVideoLoadingProgressView() {
        if (loadingView != null) {
            loadingView.setVisibility(View.VISIBLE);
            return loadingView;
        } else {
            return super.getVideoLoadingProgressView();
        }
    }

    @Override
    // Video will start playing, only called in the case of android.widget.VideoView (typically API level <11)
    public void onPrepared(MediaPlayer mp) {
        if (loadingView != null) {
            loadingView.setVisibility(View.GONE);
        }
    }

    @Override
    // Video finished playing, only called in the case of android.widget.VideoView (typically API level <11)
    public void onCompletion(MediaPlayer mp) {
        onHideCustomView();
    }

    @Override
    // Error while playing video, only called in the case of android.widget.VideoView (typically API level <11)
    public boolean onError(MediaPlayer mp, int what, int extra) {
        return false; // By returning false, onCompletion() will be called
    }

    /**
     * Notifies the class that the back key has been pressed by the user.
     * This must be called from the Activity's onBackPressed(), and if it returns false, the activity itself should handle it. Otherwise don't do anything.
     * @return Returns true if the event was handled, and false if was not (video view is not visible)
     */
    @SuppressWarnings("unused")
    public boolean onBackPressed() {
        if (isVideoFullscreen) {
            onHideCustomView();
            return true;
        } else {
            return false;
        }
    }
}

        VideoEnabledWebView.java

package ***.***.***;

import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.AttributeSet;
import android.webkit.WebChromeClient;
import android.webkit.WebView;

import java.util.Map;

public class VideoEnabledWebView extends WebView {
    public class JavascriptInterface {
        @android.webkit.JavascriptInterface
        @SuppressWarnings("unused")
        // Must match Javascript interface method of VideoEnabledWebChromeClient
        public void notifyVideoEnd() {
            // This code is not executed in the UI thread, so we must force that to happen
            new Handler(Looper.getMainLooper()).post(new Runnable() {
                @Override
                public void run() {
                    if (videoEnabledWebChromeClient != null) {
                        videoEnabledWebChromeClient.onHideCustomView();
                    }
                }
            });
        }

        @android.webkit.JavascriptInterface
        @SuppressWarnings("unused")
        // Must match Javascript interface method of VideoEnabledWebChromeClient
        public void notifyVideoStart() {
            // This code is not executed in the UI thread, so we must force that to happen
            new Handler(Looper.getMainLooper()).post(new Runnable() {
                @Override
                public void run() {
                    loadUrl("javascript:(" +
                            "function() { " +
                            " var videos = document.getElementsByTagName('video'); " +
                            " var video = videos[0]; " +
                            " if (!document.webkitFullScreen && video.webkitEnterFullscreen) {" +
                            " video.webkitEnterFullscreen(); " +
                            " } " +
                            " })()");
                }
            });
        }
    }

    private VideoEnabledWebChromeClient videoEnabledWebChromeClient;
    private boolean addedJavascriptInterface;

    @SuppressWarnings("unused")
    public VideoEnabledWebView(Context context) {
        super(context);
        addedJavascriptInterface = false;
    }

    @SuppressWarnings("unused")
    public VideoEnabledWebView(Context context, AttributeSet attrs) {
        super(context, attrs);
        addedJavascriptInterface = false;
    }

    @SuppressWarnings("unused")
    public VideoEnabledWebView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        addedJavascriptInterface = false;
    }

    /**
     * Indicates if the video is being displayed using a custom view (typically full-screen)
     *
     * @return true it the video is being displayed using a custom view (typically full-screen)
     */
    @SuppressWarnings("unused")
    public boolean isVideoFullscreen() {
        return videoEnabledWebChromeClient != null && videoEnabledWebChromeClient.isVideoFullscreen();
    }

    /**
     * Pass only a VideoEnabledWebChromeClient instance.
     */
    @Override
    @SuppressLint("SetJavaScriptEnabled")
    public void setWebChromeClient(WebChromeClient client) {
        getSettings().setJavaScriptEnabled(true);
        if (client instanceof VideoEnabledWebChromeClient) {
            this.videoEnabledWebChromeClient = (VideoEnabledWebChromeClient) client;
        }
        super.setWebChromeClient(client);
    }

    @Override
    public void loadData(String data, String mimeType, String encoding) {
        addJavascriptInterface();
        super.loadData(data, mimeType, encoding);
    }

    @Override
    public void loadDataWithBaseURL(String baseUrl, String data, String mimeType, String encoding, String historyUrl) {
        addJavascriptInterface();
        super.loadDataWithBaseURL(baseUrl, data, mimeType, encoding, historyUrl);
    }

    @Override
    public void loadUrl(String url) {
        super.loadUrl(url);
        addJavascriptInterface();
    }

    @Override
    public void loadUrl(String url, Map<String, String> additionalHttpHeaders) {
        addJavascriptInterface();
        super.loadUrl(url, additionalHttpHeaders);
    }

    @SuppressLint("AddJavascriptInterface")
    private void addJavascriptInterface() {
        if (!addedJavascriptInterface) {
            // Add javascript interface to be called when the video ends (must be done before page load)
            // Must match Javascript interface name of VideoEnabledWebChromeClient
            addJavascriptInterface(new JavascriptInterface(), "_VideoEnabledWebView");
            addedJavascriptInterface = true;
        }
    }
}

六、效果

        打开App之后,经过2s的时间(对于个人而言,2s是可接受的等待时间)直接视频全屏播放

        但我发现url有时候不是特别稳定,所以有时候看不了,并建议使用电脑端访问。

七、参考资料

1.如何在android WebView中全屏播放HTML5视频?

2.android webview播放视频自动全屏

八、声明

上述代码仅限个人的学习使用,请勿用于商业用途,请勿非法使用,谢谢。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐