Android平台实现屏幕录制(屏幕投影)|音频播放采集|麦克风采集并推送RTMP或轻量级RTSP服务

本文涉及的产品
视觉智能开放平台,图像通用资源包5000点
视觉智能开放平台,分割抠图1万点
视觉智能开放平台,视频通用资源包5000点
简介: Android平台屏幕采集、音频播放声音采集、麦克风采集编码打包推送到RTMP和轻量级RTSP服务的相关技术实现,做成高稳定低延迟的同屏系统,还需要有配套好的RTMP、RTSP直播播放器

技术背景

好多开发者,希望我们能系统的介绍下无纸化同屏的原理和集成步骤,以Android平台为例,无纸化同屏将Android设备上的屏幕内容实时投射到另一个显示设备(如Windows终端、国产化操作系统或另一台Android设备)上,从而实现多屏互动和内容的无缝共享。

技术考量指标

本文以大牛直播SDK Android同屏采集推送为例,介绍下我们前些年做Android同屏采集推送的时候,一些注意点:

  1. 声明所需权限:在Android应用的AndroidManifest.xml文件中声明必要的权限;
  2. 获取MediaProjectionManager服务:在你Activity或Service,通过getSystemService方法获取MediaProjectionManager服务;
  3. 创建并启动屏幕捕获Intent:使用MediaProjectionManager的createScreenCaptureIntent方法创建一个Intent,该Intent会启动一个系统对话框,请求用户授权屏幕捕获;
  4. 处理用户授权结果:在onActivityResult回调中,根据用户授权的结果来获取MediaProjection对象;
  5. 创建VirtualDisplay并捕获屏幕:获得了MediaProjection对象,就可以使用它来创建一个VirtualDisplay,这个VirtualDisplay会捕获屏幕内容并将其发送到指定的Surface;
  6. 资源释放:当屏幕捕获不再需要时,确保释放MediaProjection和VirtualDisplay对象,以避免资源泄露;
  7. 视频编码:通过上述步骤,捕获带的屏幕内容需要进行视频编码,以便在网络中传输。如H.264、H.265等,以及设置合适的分辨率、帧率、码率,以适应不同的网络环境和接收设备的性能;
  8. 流媒体协议:为了将编码后的视频流实时传输到接收端,Android无纸化同屏技术通常采用RTMP推流模式或轻量级RTSP服务。

技术实现

本文以大牛直播SDK的Android的SmartServicePublisherV2的同屏demo为例,Android采集计时器,编码打包分别启动RTMP推送和轻量级RTSP服务,Windows过来分别拉取RTMP和RTSP的流,整体延迟毫秒级:

image.gif 编辑

启动APP后,先选择需要采集的分辨率(如果选原始分辨率,系统不做缩放),然后选择“启动媒体投影”,并分别启动音频播放采集、采集麦克风。如果音频播放采集和采集麦克风都打开,可以通过右侧下拉框,推送过程中,音频播放采集和麦克风采集实时切换。需要注意的是,Android采集音频播放的audio,音频播放采集是依赖屏幕投影的,屏幕投影关闭后,音频播放也就采不到了。

编码的话,考虑到屏幕分辨率一般不会太低,我们可以缩放后再推送,默认我们开启了原始分辨率、标准分辨率、低分辨率选项设置。一般建议标准分辨率即可。如果对画质和分辨率要求比较高,可以选择原始分辨率。设备支持硬编码,优先选择H.264硬编,如果是H.265硬编,需要RTMP服务器支持扩展H.265(或Enhanced RTMP)。

都选择好后,设置RTMP推送的URL,点开始RTMP推送按钮即可。

如果需要通过轻量级RTSP服务,发布RTSP流,先点击启动RTSP服务按钮,RTSP服务启动后,再点击启动RTSP流,RTSP流发布成功后,界面会回调上来RTSP拉流的URL。

image.gif 编辑

下面从代码逻辑实现角度,介绍下同屏的具体流程:

启动媒体服务,进入系统后,我们会自动启动媒体服务,对应的实现逻辑如下:

/*
 * MainActivity.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
private void start_media_service() {
    Intent intent = new Intent(getApplicationContext(), StreamMediaDemoService.class);
    if (Build.VERSION.SDK_INT >= 26) {
        Log.i(TAG, "startForegroundService");
        startForegroundService(intent);
    } else
        startService(intent);
    bindService(intent, service_connection_, Context.BIND_AUTO_CREATE);
    button_stop_media_service_.setText("停止媒体服务");
}
private void stop_media_service() {
    if (media_engine_callback_ != null)
        media_engine_callback_.reset(null);
    if (media_engine_ != null) {
        media_engine_.unregister_callback(media_engine_callback_);
        media_engine_ = null;
    }
    media_engine_callback_ = null;
    if (media_binder_ != null) {
        media_binder_ = null;
        unbindService(service_connection_);
    }
    Intent intent = new Intent(getApplicationContext(), StreamMediaDemoService.class);
    stopService(intent);
    button_stop_media_service_.setText("启动媒体服务");
}

image.gif

需要注意的是,Android 6.0及以上版本,动态获取Audio权限:

/*
 * MainActivity.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
private boolean check_record_audio_permission() {
    //6.0及以上版本,动态获取Audio权限
    if (PackageManager.PERMISSION_GRANTED == checkPermission(android.Manifest.permission.RECORD_AUDIO, Process.myPid(), Process.myUid()))
        return true;
    return false;
}
private void request_audio_permission() {
    if (Build.VERSION.SDK_INT < 23)
        return;
    Log.i(TAG, "requestPermissions RECORD_AUDIO");
    ActivityCompat.requestPermissions(this, new String[] {android.Manifest.permission.RECORD_AUDIO}, REQUEST_AUDIO_CODE);
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
    switch(requestCode){
        case REQUEST_AUDIO_CODE:
            if (grantResults != null && grantResults.length > 0 && PackageManager.PERMISSION_GRANTED == grantResults[0]) {
                Log.i(TAG, "RECORD_AUDIO permission has been granted");
            }else {
                Toast.makeText(this, "请开启录音权限!", Toast.LENGTH_SHORT).show();
            }
            break;
    }
}

image.gif

启动、停止媒体投影:

/*
 * MainActivity.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
private class ButtonStartMediaProjectionListener implements OnClickListener {
    public void onClick(View v) {
        if (null == media_engine_)
            return;
        if (media_engine_.is_video_capture_running()) {
            media_engine_.stop_audio_playback_capture();
            media_engine_.stop_video_capture();
            resolution_selector_.setEnabled(true);
            button_capture_audio_playback_.setText("采集音频播放");
            button_start_media_projection_.setText("启动媒体投影");
            return;
        }
        Intent capture_intent;
        capture_intent = media_projection_manager_.createScreenCaptureIntent();
        startActivityForResult(capture_intent, REQUEST_MEDIA_PROJECTION);
        Log.i(TAG, "startActivityForResult request media projection");
    }
}

image.gif

启动媒体投影后,选择“采集音频播放”,如果需要采集麦克风,可以点击“采集麦克风”:

/*
 * MainActivity.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
private class ButtonCaptureAudioPlaybackListener implements OnClickListener {
    public void onClick(View v) {
        if (null == media_engine_)
            return;
        if (media_engine_.is_audio_playback_capture_running()) {
            media_engine_.stop_audio_playback_capture();
            button_capture_audio_playback_.setText("采集音频播放");
            return;
        }
        if (!media_engine_.start_audio_playback_capture(44100, 1))
            Log.e(TAG, "start_audio_playback_capture failed");
        else
            button_capture_audio_playback_.setText("停止音频播放采集");
    }
}
private class ButtonStartAudioRecordListener implements OnClickListener {
    public void onClick(View v) {
        if (null == media_engine_)
            return;
        if (media_engine_.is_audio_record_running()) {
            media_engine_.stop_audio_record();
            button_start_audio_record_.setText("采集麦克风");
            return;
        }
        if (!media_engine_.start_audio_record(44100, 1))
            Log.e(TAG, "start_audio_record failed");
        else
            button_start_audio_record_.setText("停止麦克风");
    }
}

image.gif

启动、停止RTMP推送:

/*
 * MainActivity.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
private class ButtonRTMPPublisherListener implements OnClickListener {
    @Override
    public void onClick(View v) {
        if (null == media_engine_)
            return;
        if (media_engine_.is_rtmp_stream_running()) {
            media_engine_.stop_rtmp_stream();
            button_rtmp_publisher_.setText("开始RTMP推送");
            text_view_rtmp_url_.setText("RTMP URL: ");
            Log.i(TAG, "stop rtmp stream");
            return;
        }
        if (!media_engine_.is_video_capture_running())
            return;
        String rtmp_url;
        if (input_rtmp_url_ != null && input_rtmp_url_.length() > 1) {
            rtmp_url = input_rtmp_url_;
            Log.i(TAG, "start, input rtmp url:" + rtmp_url);
        } else {
            rtmp_url = baseURL + String.valueOf((int) (System.currentTimeMillis() % 1000000));
            Log.i(TAG, "start, generate random url:" + rtmp_url);
        }
        media_engine_.set_fps(fps_);
        media_engine_.set_gop(gop_);
        media_engine_.set_video_encoder_type(video_encoder_type);
        if (!media_engine_.start_rtmp_stream(rtmp_url))
            return;
        button_rtmp_publisher_.setText("停止RTMP推送");
        text_view_rtmp_url_.setText("RTMP URL:" + rtmp_url);
        Log.i(TAG, "RTMP URL:" + rtmp_url);
    }
}

image.gif

启动RTSP服务:

/*
 * MainActivity.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
private class ButtonRTSPServiceListener implements OnClickListener {
    public void onClick(View v) {
        if (null == media_engine_)
            return;
        if (media_engine_.is_rtsp_server_running()) {
            media_engine_.stop_rtsp_stream();
            media_engine_.stop_rtsp_server();
            button_rtsp_publisher_.setText("启动RTSP流");
            button_rtsp_service_.setText("启动RTSP服务");
            text_view_rtsp_url_.setText("RTSP URL:");
            return;
        }
        if (!media_engine_.start_rtsp_server(rtsp_port_, null, null))
            return;
        button_rtsp_service_.setText("停止RTSP服务");
    }
}

image.gif

发布RTSP流:

/*
 * MainActivity.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
private class ButtonRtspPublisherListener implements OnClickListener {
    public void onClick(View v) {
        if (null == media_engine_)
            return;
        if (media_engine_.is_rtsp_stream_running()) {
            media_engine_.stop_rtsp_stream();
            button_rtsp_publisher_.setText("启动RTSP流");
            text_view_rtsp_url_.setText("RTSP URL:");
            return;
        }
        if (!media_engine_.is_video_capture_running())
            return;
        media_engine_.set_fps(fps_);
        media_engine_.set_gop(gop_);
        media_engine_.set_video_encoder_type(video_encoder_type);
        if (!media_engine_.start_rtsp_stream("stream1"))
            return;
        button_rtsp_publisher_.setText("停止RTSP流");
    }
}

image.gif

RTSP流发布成功后,底层会把RTSP拉流的URL回调上来:

/*
 * MainActivity.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
@Override
public void on_nt_rtsp_stream_url(String url) {
    Log.i(TAG, "on_nt_rtsp_stream_url: " + url);
    MainActivity activity = get_activity();
    if (activity != null) {
        activity.runOnUiThread(new Runnable() {
            MainActivity activity_;
            String url_;
            @Override
            public void run() {
               activity_.text_view_rtsp_url_.setText("RTSP URL:" + url_);
            }
            public Runnable set(MainActivity activity, String url) {
                this.activity_ = activity;
                this.url_ = url;
                return this;
            }
        }.set(activity, url));
    }
}

image.gif

可以看到,上述操作,都是在MainActivity.java调用的,如果是需要做demo版本集成,只需要关注MainActivity.java的业务逻辑即可,为了便于开发者对接,我们做了接口的二次封装,除了常规的RTMP推送、轻量级RTSP服务设计外,如果需要录像,只要在MainActivity.java调用这里的接口逻辑即可,非常方便:

/*
 * NTStreamMediaEngine.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
package com.daniulive.smartpublisher;
public interface NTStreamMediaEngine {
    void register_callback(Callback callback);
    void unregister_callback(Callback callback);
    void set_resolution_level(int level);
    int get_resolution_level();
    /*
    * 启动媒体投影
     */
    boolean start_video_capture(int token_code, android.content.Intent token_data);
    boolean is_video_capture_running();
    void stop_video_capture();
    /*
    * 启动麦克风
     */
    boolean start_audio_record(int sample_rate, int channels);
    boolean is_audio_record_running();
    void stop_audio_record();
    /*
     *  Android 10及以上支持, Android10以下设备调用直接返回false
     *  需要有RECORD_AUDIO权限
     *  要开启媒体投影
     */
    boolean start_audio_playback_capture(int sample_rate, int channels);
    boolean is_audio_playback_capture_running();
    void stop_audio_playback_capture();
    /*
     * 输出的音频类型
     *  0: 不输出音频
     *  1: 输出麦克风
     *  2: 输出audio playback(Android 10及以上支持)
     */
    boolean set_audio_output_type(int type);
    int get_audio_output_type();
    void set_fps(int fps);
    void set_gop(int gop);
    boolean set_video_encoder_type(int video_encoder_type);
    int get_video_encoder_type();
    /*
    * 推送RTMP
     */
    boolean start_rtmp_stream(String url);
    boolean is_rtmp_stream_running();
    String get_rtmp_stream_url();
    void stop_rtmp_stream();
    /*
    * 启动RTSP Server, 需要设置端口,用户名和密码可选
     */
    boolean start_rtsp_server(int port, String user_name, String password);
    boolean is_rtsp_server_running();
    void stop_rtsp_server();
    /*
    * 发布RTSP流
     */
    boolean start_rtsp_stream(String stream_name);
    boolean is_rtsp_stream_running();
    String get_rtsp_stream_url();
    void stop_rtsp_stream();
    /*
    * 启动本地录像
     */
    boolean start_stream_record(String record_directory, int file_max_size);
    boolean is_stream_recording();
    void stop_stream_record();
    boolean is_stream_running();
    interface Callback {
        void on_nt_video_capture_stop();
        void on_nt_rtsp_stream_url(String url);
    }
}

image.gif

如果对音视频这块相对了解的开发者,可以继续到NTStreamMediaProjectionEngineImpl.java文件,查看或修改相关的技术实现:

/*
 * NTStreamMediaProjectionEngineImpl.java
 * Created by daniusdk.com on 2017/04/19.
 * WeChat: xinsheng120
 */
package com.daniulive.smartpublisher;
import android.app.Activity;
import android.app.Application;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Point;
import android.graphics.Rect;
import android.media.Image;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Process;
import android.util.Log;
import android.util.Size;
import android.view.Surface;
import android.view.WindowManager;
import android.view.WindowMetrics;
import com.eventhandle.NTSmartEventCallbackV2;
import com.eventhandle.NTSmartEventID;
import com.voiceengine.NTAudioRecordV2;
import com.voiceengine.NTAudioRecordV2Callback;
import com.videoengine.NTMediaProjectionCapture;
import com.voiceengine.NTAudioPlaybackCapture;
import java.lang.ref.WeakReference;
import java.nio.ByteBuffer;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicReference;
public class NTStreamMediaProjectionEngineImpl implements AutoCloseable, NTStreamMediaEngine,
        NTVirtualDisplaySurfaceSinker.Callback, NTMediaProjectionCapture.Callback {
    private static final String TAG = "NTLogProjectionEngine";
    private static final Size DEFAULT_SIZE = new Size(1920, 1080);
    public static final int RESOLUTION_LOW = 0;
    public static final int RESOLUTION_MEDIUM = 1;
    public static final int RESOLUTION_HIGH = 2;
    private final Application application_;
    private final long image_thread_id_;
    private final long running_thread_id_;
    private final Handler image_handler_;
    private final Handler running_handler_;
    private final WindowManager window_manager_;
    private final MediaProjectionManager projection_manager_;
    private int screen_density_dpi_ = android.util.DisplayMetrics.DENSITY_DEFAULT;
    private final SmartPublisherJniV2 lib_publisher_;
    private final LibPublisherWrapper.RTSPServer rtsp_server_;
    private final LibPublisherWrapper stream_publisher_;
    private final CopyOnWriteArrayList<NTStreamMediaEngine.Callback> callbacks_ = new CopyOnWriteArrayList<>();
    private final AtomicReference<VideoSinkerCapturePair> video_capture_pair_ = new AtomicReference<>();
    private final AudioRecordCallbackImpl audio_record_callback_;
    private final AudioPlaybackCaptureCallbackImpl audio_playback_capture_callback_;
    private final AtomicReference<NTAudioRecordV2> audio_record_ = new AtomicReference<>();
    private final AtomicReference<NTAudioPlaybackCapture> audio_playback_capture_ = new AtomicReference<>();
    
    ...
}

image.gif

以Android平台RTMP推送模块为例,我们主要实现了如下功能:

  • 音频编码:AAC/SPEEX;
  • 视频编码:H.264、H.265;
  • 推流协议:RTMP;
  • [音视频]支持纯音频/纯视频/音视频推送;
  • [摄像头]支持采集过程中,前后摄像头实时切换;
  • 支持帧率、关键帧间隔(GOP)、码率(bit-rate)设置;
  • 支持RTMP推送 live|record模式设置;
  • 支持前置摄像头镜像设置;
  • 支持软编码、特定机型硬编码;
  • 支持横屏、竖屏推送;
  • 支持Android屏幕采集推送;
  • 支持自建标准RTMP服务器或CDN;
  • 支持断网自动重连、网络状态回调;
  • 支持实时动态水印;
  • 支持实时快照;
  • 支持降噪处理、自动增益控制;
  • 支持外部编码前音视频数据对接;
  • 支持外部编码后音视频数据对接;
  • 支持RTMP扩展H.265(需设备支持H.265特定机型硬编码)和Enhanced RTMP;
  • 支持实时音量调节;
  • 支持扩展录像模块;
  • 支持Unity接口;
  • 支持H.264扩展SEI发送模块;
  • 支持Android 5.1及以上版本。

轻量级RTSP服务,在上述非RTMP协议依赖的基础上,增加了如下功能:

  • [音频格式]AAC;
  • [视频格式]H.264、H.265;
  • [协议类型]RTSP;
  • [传输模式]支持单播和组播模式;
  • [端口设置]支持RTSP端口设置;
  • [鉴权设置]支持RTSP鉴权用户名、密码设置;
  • [获取session连接数]支持获取当前RTSP服务会话连接数;
  • [多服务支持]支持同时创建多个内置RTSP服务;
  • [RTSP url回调]支持设置后的rtsp url通过event回调到上层。

总结

以上是Android平台屏幕采集、音频播放声音采集、麦克风采集编码打包推送到RTMP和轻量级RTSP服务的相关技术实现,做成高稳定低延迟的同屏系统,还需要有配套好的RTMP、RTSP直播播放器,整体部署,内网大并发环境下,还需要考虑到如何组网等诸多因素。做demo容易,做个成熟的模块还是有一定的难度,以上抛砖引玉,感兴趣的开发者,可以单独跟我沟通探讨。

相关文章
|
2月前
|
机器学习/深度学习 Android开发 数据安全/隐私保护
手机脚本录制器, 脚本录制器安卓,识图识色屏幕点击器【autojs】
完整的UI界面,包含录制控制按钮和状态显示 屏幕点击动作录制功能,记录点击坐标和时间间隔
|
3月前
|
监控 Android开发 数据安全/隐私保护
批量发送短信的平台,安卓群发短信工具插件脚本,批量群发短信软件【autojs版】
这个Auto.js脚本实现了完整的批量短信发送功能,包含联系人管理、短信内容编辑、发送状态监控等功能
|
7月前
|
存储 编解码 监控
Android平台GB28181执法记录仪技术方案与实现
本文介绍了大牛直播SDK的SmartGBD在执法记录仪场景中的应用。GB28181协议作为视频监控联网的国家标准,为设备互联互通提供规范。SmartGBD专为Android平台设计,支持音视频采集、编码与传输,具备自适应算法和多功能扩展优势。文章分析了执法记录仪的需求,如实时音视频传输、设备管理及数据安全,并详细阐述了基于SmartGBD的技术实现方案,包括环境准备、SDK集成、设备注册、音视频处理及功能扩展等步骤。最后展望了SmartGBD在未来智慧物联领域的广阔应用前景。
328 13
|
7月前
|
存储 编解码 开发工具
Android平台毫秒级低延迟HTTP-FLV直播播放器技术探究与实现
本文详细探讨了在Android平台上实现HTTP-FLV播放器的过程。首先介绍了FLV格式的基础,包括文件头和标签结构。接着分析了HTTP-FLV传输原理,通过分块传输实现流畅播放。然后重点讲解了播放器的实现步骤,涵盖网络请求、数据解析、音视频解码与渲染,以及播放控制功能的设计。文章还讨论了性能优化和网络异常处理的方法,并总结了HTTP-FLV播放器的技术价值,尤其是在特定场景下的应用意义。
308 11
|
7月前
|
监控 Shell Linux
Android调试终极指南:ADB安装+多设备连接+ANR日志抓取全流程解析,覆盖环境变量配置/多设备调试/ANR日志分析全流程,附Win/Mac/Linux三平台解决方案
ADB(Android Debug Bridge)是安卓开发中的重要工具,用于连接电脑与安卓设备,实现文件传输、应用管理、日志抓取等功能。本文介绍了 ADB 的基本概念、安装配置及常用命令。包括:1) 基本命令如 `adb version` 和 `adb devices`;2) 权限操作如 `adb root` 和 `adb shell`;3) APK 操作如安装、卸载应用;4) 文件传输如 `adb push` 和 `adb pull`;5) 日志记录如 `adb logcat`;6) 系统信息获取如屏幕截图和录屏。通过这些功能,用户可高效调试和管理安卓设备。
|
8天前
|
移动开发 JavaScript 应用服务中间件
【06】优化完善落地页样式内容-精度优化-vue加vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
【06】优化完善落地页样式内容-精度优化-vue加vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
79 5
【06】优化完善落地页样式内容-精度优化-vue加vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
|
15天前
|
移动开发 前端开发 Android开发
【02】建立各项目录和页面标准化产品-vue+vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
【02】建立各项目录和页面标准化产品-vue+vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
185 12
【02】建立各项目录和页面标准化产品-vue+vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
|
16天前
|
移动开发 Rust JavaScript
【01】首页建立-vue+vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
【01】首页建立-vue+vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
258 3
【01】首页建立-vue+vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
|
1月前
|
开发工具 Android开发
X Android SDK file not found: adb.安卓开发常见问题-Android SDK 缺少 `adb`(Android Debug Bridge)-优雅草卓伊凡
X Android SDK file not found: adb.安卓开发常见问题-Android SDK 缺少 `adb`(Android Debug Bridge)-优雅草卓伊凡
348 11
X Android SDK file not found: adb.安卓开发常见问题-Android SDK 缺少 `adb`(Android Debug Bridge)-优雅草卓伊凡
|
13天前
|
移动开发 Android开发
【03】建立隐私关于等相关页面和内容-vue+vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
【03】建立隐私关于等相关页面和内容-vue+vite开发实战-做一个非常漂亮的APP下载落地页-支持PC和H5自适应提供安卓苹果鸿蒙下载和网页端访问-优雅草卓伊凡
62 0

热门文章

最新文章