Android平台实现RTSP|RTMP转GB28181网关接入

简介: 在事先Android平台RTSP、RTMP转GB28181网关之前,我们已经实现了Android平台GB28181的接入,可实现Android平台采集到的音视频数据,编码后,打包按需发到GB28181服务平台。此外,拉流端,我们已经有了成熟的RTSP和RTMP拉流播放方案。

背景

在事先Android平台RTSP、RTMP转GB28181网关之前,我们已经实现了Android平台GB28181的接入,可实现Android平台采集到的音视频数据,编码后,打包按需发到GB28181服务平台。此外,拉流端,我们已经有了成熟的RTSP和RTMP拉流播放方案。


今天,我们要做的是,把外部的RTSP或RTMP流,拉过来,然后对接到GB28181国标平台,实现媒体流数据的按需播放


和我们之前实现的轻量级RTSP服务网关模块类似,我们要做的是,实现RTSP或RTMP流,按需打包对接到GB28181服务平台。


简单来说,Android平台RTSP、RTMP转GB28181网关平台,是GB28181设备接入模块的一个扩展,由拉流端、GB28181接入端两个模块组成。


轻量级RTSP服务模块、RTSP|RTMP转GB28181网关模块和内置RTSP网关模块的区别和联系:


内置轻量级RTSP服务模块和内置RTSP网关模块,核心痛点是避免用户或者开发者单独部署RTSP或者RTMP服务,数据汇聚到内置RTSP服务,对外提供可供拉流的RTSP URL,适用于内网环境下,对并发要求不高的场景,支持H.264/H.265,支持RTSP鉴权、单播、组播模式,考虑到单个服务承载能力,我们支持同时创建多个RTSP服务,并支持获取当前RTSP服务会话连接数。


RTSP|RTMP转GB28181网关模块,实现的是音视频数据的转发,类似于RTSP|RTMP转RTMP推送模块,把本地数据源,对接到GB28181服务平台或RTMP服务平台。


三者不同点:数据来源不同


1. 内置轻量级RTSP服务模块,数据源来自摄像头、屏幕、麦克风等编码前数据,或者本地编码后的对接数据,这点和GB28181的设备接入模块类似。


2. 内置RTSP网关模块,实际上是RTSP/RTMP拉流模块+内置轻量级RTSP服务模块组合出来的。数据源来自RTSP或RTMP网络流,拉流模块完成编码后的音视频数据回调,然后,汇聚到内置轻量级RTSP服务模块。RTSP|RTMP转GB28181网关模块,和内置RTSP网关模块数据源接入一样。

技术实现

529d313101db48baa80cccb6674ebc2c.jpg

本文以之前Android平台RTSP|RTMP转发demo为例,在这个基础上,加GB28181网关扩展。


拉流端音频数据回调,拉流端获取到编码后是数据,回调上来,通过SmartPublisherPostAudioEncodedData()发送到推送模块。

class PlayerAudioDataCallback implements NTAudioDataCallback
  {
    private int audio_buffer_size = 0;
    private int param_info_size = 0;
    private ByteBuffer audio_buffer_ = null;
    private ByteBuffer parameter_info_ = null;
    @Override
    public ByteBuffer getAudioByteBuffer(int size)
    {
      //Log.i("getAudioByteBuffer", "size: " + size);
      if( size < 1 )
      {
        return null;
      }
      if ( size <= audio_buffer_size && audio_buffer_ != null )
      {
        return audio_buffer_;
      }
      audio_buffer_size = size + 512;
      audio_buffer_size = (audio_buffer_size+0xf) & (~0xf);
      audio_buffer_ = ByteBuffer.allocateDirect(audio_buffer_size);
      // Log.i("getAudioByteBuffer", "size: " + size + " buffer_size:" + audio_buffer_size);
      return audio_buffer_;
    }
    @Override
    public ByteBuffer getAudioParameterInfo(int size)
    {
      //Log.i("getAudioParameterInfo", "size: " + size);
      if(size < 1)
      {
        return null;
      }
      if ( size <= param_info_size &&  parameter_info_ != null )
      {
        return  parameter_info_;
      }
      param_info_size = size + 32;
      param_info_size = (param_info_size+0xf) & (~0xf);
      parameter_info_ = ByteBuffer.allocateDirect(param_info_size);
      //Log.i("getAudioParameterInfo", "size: " + size + " buffer_size:" + param_info_size);
      return parameter_info_;
    }
    public void onAudioDataCallback(int ret, int audio_codec_id, int sample_size, int is_key_frame, long timestamp, int sample_rate, int channel, int parameter_info_size, long reserve)
    {
      //Log.i("onAudioDataCallback", "ret: " + ret + ", audio_codec_id: " + audio_codec_id + ", sample_size: " + sample_size + ", timestamp: " + timestamp +
      //    ",sample_rate:" + sample_rate);
      if ( audio_buffer_ == null)
        return;
      audio_buffer_.rewind();
      if ( ret == 0 && (isPushing || isRTSPPublisherRunning || isGB28181StreamRunning)) {
        libPublisher.SmartPublisherPostAudioEncodedData(publisherHandle, audio_codec_id, audio_buffer_, sample_size, is_key_frame, timestamp, parameter_info_, parameter_info_size);
      }
      // test
        /*
        byte[] test_buffer = new byte[16];
        pcm_buffer_.get(test_buffer);
        Log.i(TAG, "onGetPcmFrame data:" + bytesToHexString(test_buffer));
        */
    }
  }

拉流端视频数据回调,拉流端获取到编码后是数据,回调上来,通过SmartPublisherPostVideoEncodedData()发送到推送模块。

class PlayerVideoDataCallback implements NTVideoDataCallback
  {
    private int video_buffer_size = 0;
    private ByteBuffer video_buffer_ = null;
    @Override
    public ByteBuffer getVideoByteBuffer(int size)
    {
      //Log.i("getVideoByteBuffer", "size: " + size);
      if( size < 1 )
      {
        return null;
      }
      if ( size <= video_buffer_size &&  video_buffer_ != null )
      {
        return  video_buffer_;
      }
      video_buffer_size = size + 1024;
      video_buffer_size = (video_buffer_size+0xf) & (~0xf);
      video_buffer_ = ByteBuffer.allocateDirect(video_buffer_size);
      // Log.i("getVideoByteBuffer", "size: " + size + " buffer_size:" + video_buffer_size);
      return video_buffer_;
    }
    public void onVideoDataCallback(int ret, int video_codec_id, int sample_size, int is_key_frame, long timestamp, int width, int height, long presentation_timestamp)
    {
      //Log.i("onVideoDataCallback", "ret: " + ret + ", video_codec_id: " + video_codec_id + ", sample_size: " + sample_size + ", is_key_frame: "+ is_key_frame +  ", timestamp: " + timestamp +
      //    ",presentation_timestamp:" + presentation_timestamp);
      if ( video_buffer_ == null)
        return;
      video_buffer_.rewind();
      if ( ret == 0 &&  (isPushing || isRTSPPublisherRunning || isGB28181StreamRunning) ) {
        libPublisher.SmartPublisherPostVideoEncodedData(publisherHandle, video_codec_id, video_buffer_, sample_size, is_key_frame, timestamp, presentation_timestamp);
      }
    }
  }

GB28181网关模块,初始化:

/*
   * gb28181 agent初始化
   * Github: https://github.com/daniulive/SmarterStreaming
   */
 private boolean initGB28181Agent() {
      if ( gb28181_agent_ != null )
         return  true;
      getLocation(myContext);
      String local_ip_addr = IPAddrUtils.getIpAddress(myContext);
      Log.i(TAG, "initGB28181Agent local ip addr: " + local_ip_addr);
      if ( local_ip_addr == null || local_ip_addr.isEmpty() ) {
         Log.e(TAG, "initGB28181Agent local ip is empty");
         return  false;
      }
      gb28181_agent_ = GBSIPAgentFactory.getInstance().create();
      if ( gb28181_agent_ == null ) {
         Log.e(TAG, "initGB28181Agent create agent failed");
         return false;
      }
      gb28181_agent_.addListener(this);
      // 必填信息
      gb28181_agent_.setLocalAddressInfo(local_ip_addr, gb28181_sip_local_port_);
      gb28181_agent_.setServerParameter(gb28181_sip_server_addr_, gb28181_sip_server_port_, gb28181_sip_server_id_, gb28181_sip_server_domain_);
      gb28181_agent_.setUserInfo(gb28181_sip_username_, gb28181_sip_password_);
      // 可选参数
      gb28181_agent_.setUserAgent(gb28181_sip_user_agent_filed_);
      gb28181_agent_.setTransportProtocol(gb28181_sip_trans_protocol_==0?"UDP":"TCP");
      // GB28181配置
      gb28181_agent_.config(gb28181_reg_expired_, gb28181_heartbeat_interval_, gb28181_heartbeat_count_);
      com.gb28181.ntsignalling.Device gb_device = new com.gb28181.ntsignalling.Device("34020000001380000001", "安卓测试设备", Build.MANUFACTURER, Build.MODEL,
            "宇宙","火星1","火星", true);
      if (mLongitude != null && mLatitude != null) {
         com.gb28181.ntsignalling.DevicePosition device_pos = new com.gb28181.ntsignalling.DevicePosition();
         device_pos.setTime(mLocationTime);
         device_pos.setLongitude(mLongitude);
         device_pos.setLatitude(mLatitude);
         gb_device.setPosition(device_pos);
         gb_device.setSupportMobilePosition(true); // 设置支持移动位置上报
      }
      gb28181_agent_.addDevice(gb_device);
      if (!gb28181_agent_.initialize()) {
         gb28181_agent_ = null;
         Log.e(TAG, "initGB28181Agent gb28181_agent_.initialize failed.");
         return  false;
      }
      return true;
   }

GB28181网关模块按钮相关代码实现,整体和GB28181设备接入一致:

class ButtonGB28181AgentListener implements OnClickListener {
    public void onClick(View v) {
      stopGB28181Stream();
      destoryRTPSender();
      if (null == gb28181_agent_ ) {
        if( !initGB28181Agent() )
          return;
      }
      if (gb28181_agent_.isRunning()) {
        gb28181_agent_.terminateAllPlays(true);// 目前测试下来,发送BYE之后,有些服务器会立即发送INVITE,是否发送BYE根据实际情况看
        gb28181_agent_.stop();
        btnGB28181Agent.setText("启动GB28181");
      }
      else {
        if ( gb28181_agent_.start() ) {
          btnGB28181Agent.setText("停止GB28181");
        }
      }
    }
  }
  //停止GB28181 媒体流
  private void stopGB28181Stream() {
    if(!isGB28181StreamRunning)
      return;
    if (libPublisher != null) {
      libPublisher.StopGB28181MediaStream(publisherHandle);
    }
    if (!isRecording && !isRTSPPublisherRunning && !isPushing) {
      if (publisherHandle != 0) {
        if (libPublisher != null) {
          libPublisher.SmartPublisherClose(publisherHandle);
          publisherHandle = 0;
        }
      }
    }
    isGB28181StreamRunning = false;
  }
  private void destoryRTPSender() {
    if (gb28181_rtp_sender_handle_ != 0) {
      libPublisher.DestoryRTPSender(gb28181_rtp_sender_handle_);
      gb28181_rtp_sender_handle_ = 0;
    }
  }
GB28181网关模块,事件回调处理:
@Override
  public void ntsRegisterOK(String dateString) {
    Log.i(TAG, "ntsRegisterOK Date: " + (dateString!= null? dateString : ""));
  }
  @Override
  public void ntsRegisterTimeout() {
    Log.e(TAG, "ntsRegisterTimeout");
  }
  @Override
  public void ntsRegisterTransportError(String errorInfo) {
    Log.e(TAG, "ntsRegisterTransportError error:" + (errorInfo != null?errorInfo :""));
  }
  @Override
  public void ntsOnHeartBeatException(int exceptionCount,  String lastExceptionInfo) {
    Log.e(TAG, "ntsOnHeartBeatException heart beat timeout count reached, count:" + exceptionCount+
        ", exception info:" + (lastExceptionInfo!=null?lastExceptionInfo:""));
    // 10毫秒后,停止信令, 然后重启
    handler.postDelayed(new Runnable() {
      @Override
      public void run() {
        Log.i(TAG, "gb28281_heart_beart_timeout");
        stopGB28181Stream();
        destoryRTPSender();
        if (gb28181_agent_ != null) {
          Log.i(TAG, "gb28281_heart_beart_timeout sip stop");
          gb28181_agent_.stop();
          Log.i(TAG, "gb28281_heart_beart_timeout sip start");
          gb28181_agent_.start();
        }
      }
    },10);
  }
  @Override
  public void ntsOnInvitePlay(String deviceId, InvitePlaySessionDescription session_des) {
    handler.postDelayed(new Runnable() {
      @Override
      public void run() {
        Log.i(TAG,"ntsInviteReceived, device_id:" +device_id_+", is_tcp:" + session_des_.isRTPOverTCP()
            + " rtp_port:" + session_des_.getMediaPort() + " ssrc:" + session_des_.getSSRC()
            + " address_type:" + session_des_.getAddressType() + " address:" + session_des_.getAddress());
        // 可以先给信令服务器发送临时振铃响应
        //sip_stack_android.respondPlayInvite(180, device_id_);
        long rtp_sender_handle = libPublisher.CreateRTPSender(0);
        if ( rtp_sender_handle == 0 ) {
          gb28181_agent_.respondPlayInvite(488, device_id_);
          Log.i(TAG, "ntsInviteReceived CreateRTPSender failed, response 488, device_id:" + device_id_);
          return;
        }
        gb28181_rtp_payload_type_ = session_des_.getPSRtpMapAttribute().getPayloadType();
        libPublisher.SetRTPSenderTransportProtocol(rtp_sender_handle, session_des_.isRTPOverUDP()?0:1);
        libPublisher.SetRTPSenderIPAddressType(rtp_sender_handle, session_des_.isIPv4()?0:1);
        libPublisher.SetRTPSenderLocalPort(rtp_sender_handle, 0);
        libPublisher.SetRTPSenderSSRC(rtp_sender_handle, session_des_.getSSRC());
        libPublisher.SetRTPSenderSocketSendBuffer(rtp_sender_handle, 2*1024*1024); // 设置到2M
        libPublisher.SetRTPSenderClockRate(rtp_sender_handle, session_des_.getPSRtpMapAttribute().getClockRate());
        libPublisher.SetRTPSenderDestination(rtp_sender_handle, session_des_.getAddress(), session_des_.getMediaPort());
        if ( libPublisher.InitRTPSender(rtp_sender_handle) != 0 ) {
          gb28181_agent_.respondPlayInvite(488, device_id_);
          libPublisher.DestoryRTPSender(rtp_sender_handle);
          return;
        }
        int local_port = libPublisher.GetRTPSenderLocalPort(rtp_sender_handle);
        if (local_port == 0) {
          gb28181_agent_.respondPlayInvite(488, device_id_);
          libPublisher.DestoryRTPSender(rtp_sender_handle);
          return;
        }
        Log.i(TAG,"get local_port:" + local_port);
        String local_ip_addr = IPAddrUtils.getIpAddress(myContext);
        gb28181_agent_.respondPlayInviteOK(device_id_,local_ip_addr, local_port);
        gb28181_rtp_sender_handle_ = rtp_sender_handle;
      }
      private String device_id_;
      private InvitePlaySessionDescription session_des_;
      public Runnable set(String device_id, InvitePlaySessionDescription session_des) {
        this.device_id_ = device_id;
        this.session_des_ = session_des;
        return this;
      }
    }.set(deviceId, session_des),0);
  }
  @Override
  public void ntsOnCancelPlay(String deviceId) {
    // 这里取消Play会话
    handler.postDelayed(new Runnable() {
      @Override
      public void run() {
        Log.i(TAG, "ntsOnCancelPlay, deviceId=" + device_id_);
        destoryRTPSender();
      }
      private String device_id_;
      public Runnable set(String device_id) {
        this.device_id_ = device_id;
        return this;
      }
    }.set(deviceId),0);
  }
  @Override
  public void ntsOnAckPlay(String deviceId) {
    handler.postDelayed(new Runnable() {
      @Override
      public void run() {
        Log.i(TAG,"ntsOnACKPlay, device_id:" +device_id_);
        if (!isRecording && !isRTSPPublisherRunning && !isPushing) {
          OpenPushHandle();
        }
        libPublisher.SetGB28181RTPSender(publisherHandle, gb28181_rtp_sender_handle_, gb28181_rtp_payload_type_);
        int startRet = libPublisher.StartGB28181MediaStream(publisherHandle);
        if (startRet != 0) {
          if (!isRecording && !isRTSPPublisherRunning && !isPushing) {
            if (publisherHandle != 0) {
              libPublisher.SmartPublisherClose(publisherHandle);
              publisherHandle = 0;
            }
          }
          destoryRTPSender();
          Log.e(TAG, "Failed to start GB28181 service..");
          return;
        }
        isGB28181StreamRunning = true;
      }
      private String device_id_;
      public Runnable set(String device_id) {
        this.device_id_ = device_id;
        return this;
      }
    }.set(deviceId),0);
  }
  @Override
  public void ntsOnPlayInviteResponseException(String deviceId, int statusCode, String errorInfo) {
    // 这里要释放掉响应的资源
    Log.i(TAG, "ntsOnPlayInviteResponseException, deviceId=" + deviceId + " statusCode=" +statusCode
        + " errorInfo:" + errorInfo);
    handler.postDelayed(new Runnable() {
      @Override
      public void run() {
        Log.i(TAG, "ntsOnPlayInviteResponseException, deviceId=" + device_id_);
        destoryRTPSender();
      }
      private String device_id_;
      public Runnable set(String device_id) {
        this.device_id_ = device_id;
        return this;
      }
    }.set(deviceId),0);
  }
  @Override
  public void ntsOnByePlay(String deviceId) {
    handler.postDelayed(new Runnable() {
      @Override
      public void run() {
        Log.i(TAG, "ntsOnByePlay, stop GB28181 media stream, deviceId=" + device_id_);
        stopGB28181Stream();
        destoryRTPSender();
      }
      private String device_id_;
      public Runnable set(String device_id) {
        this.device_id_ = device_id;
        return this;
      }
    }.set(deviceId),0);
  }
  @Override
  public void ntsOnPlayDialogTerminated(String deviceId) {
        /*
        Play会话对应的对话终止, 一般不会出发这个回调,目前只有在响应了200K, 但在64*T1时间后还没收到ACK,才可能会出发
        收到这个请做相关清理处理
        */
    handler.postDelayed(new Runnable() {
      @Override
      public void run() {
        Log.i(TAG, "ntsOnPlayDialogTerminated, deviceId=" + device_id_);
        stopGB28181Stream();
        destoryRTPSender();
      }
      private String device_id_;
      public Runnable set(String device_id) {
        this.device_id_ = device_id;
        return this;
      }
    }.set(deviceId),0);
  }
  @Override
  public void ntsOnDevicePositionRequest(String deviceId, int interval) {
    handler.postDelayed(new Runnable() {
      @Override
      public void run() {
        getLocation(myContext);
        Log.v(TAG, "ntsOnDevicePositionRequest, deviceId:" + this.device_id_ + ", Longitude:" + mLongitude
            + ", Latitude:" + mLatitude + ", Time:" + mLocationTime);
        if (mLongitude != null && mLatitude != null) {
          com.gb28181.ntsignalling.DevicePosition device_pos = new com.gb28181.ntsignalling.DevicePosition();
          device_pos.setTime(mLocationTime);
          device_pos.setLongitude(mLongitude);
          device_pos.setLatitude(mLatitude);
          if (gb28181_agent_ != null ) {
            gb28181_agent_.updateDevicePosition(device_id_, device_pos);
          }
        }
      }
      private String device_id_;
      private int interval_;
      public Runnable set(String device_id, int interval) {
        this.device_id_ = device_id;
        this.interval_ = interval;
        return this;
      }
    }.set(deviceId, interval),0);
  }

OnDestroy(),停掉GB28181 stream,释放资源。

@Override
  protected void onDestroy() {
    Log.i(TAG, "Run into activity destory++");
    if (gb28181_agent_ != null ) {
      if (gb28181_agent_.isRunning()) {
        gb28181_agent_.terminateAllPlays(false);
        gb28181_agent_.stop();
      }
      Log.i(TAG, " gb28181_agent_.unInitialize++");
      gb28181_agent_.unInitialize();
      Log.i(TAG, " gb28181_agent_.unInitialize--");
      gb28181_agent_ = null;
    }
    stopGB28181Stream();
    destoryRTPSender();
    super.onDestroy();
    finish();
    System.exit(0);
  }

以上是大概流程,感兴趣的开发者,可酌情参考。

相关文章
|
3月前
|
Java Android开发 Swift
安卓与iOS开发对比:平台选择对项目成功的影响
【10月更文挑战第4天】在移动应用开发的世界中,选择合适的平台是至关重要的。本文将深入探讨安卓和iOS两大主流平台的开发环境、用户基础、市场份额和开发成本等方面的差异,并分析这些差异如何影响项目的最终成果。通过比较这两个平台的优势与挑战,开发者可以更好地决定哪个平台更适合他们的项目需求。
123 1
|
4月前
|
IDE Android开发 iOS开发
探索Android与iOS开发的差异:平台选择对项目成功的影响
【9月更文挑战第27天】在移动应用开发的世界中,Android和iOS是两个主要的操作系统平台。每个系统都有其独特的开发环境、工具和用户群体。本文将深入探讨这两个平台的关键差异点,并分析这些差异如何影响应用的性能、用户体验和最终的市场表现。通过对比分析,我们将揭示选择正确的开发平台对于确保项目成功的重要作用。
|
27天前
|
IDE 开发工具 Android开发
移动应用开发之旅:探索Android和iOS平台
在这篇文章中,我们将深入探讨移动应用开发的两个主要平台——Android和iOS。我们将了解它们的操作系统、开发环境和工具,并通过代码示例展示如何在这两个平台上创建一个简单的“Hello World”应用。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的信息和技巧,帮助你更好地理解和掌握移动应用开发。
60 17
|
3月前
|
Linux API 开发工具
FFmpeg开发笔记(五十九)Linux编译ijkplayer的Android平台so库
ijkplayer是由B站研发的移动端播放器,基于FFmpeg 3.4,支持Android和iOS。其源码托管于GitHub,截至2024年9月15日,获得了3.24万星标和0.81万分支,尽管已停止更新6年。本文档介绍了如何在Linux环境下编译ijkplayer的so库,以便在较新的开发环境中使用。首先需安装编译工具并调整/tmp分区大小,接着下载并安装Android SDK和NDK,最后下载ijkplayer源码并编译。详细步骤包括环境准备、工具安装及库编译等。更多FFmpeg开发知识可参考相关书籍。
119 0
FFmpeg开发笔记(五十九)Linux编译ijkplayer的Android平台so库
|
2月前
|
开发框架 前端开发 Android开发
安卓与iOS开发中的跨平台策略
在移动应用开发的战场上,安卓和iOS两大阵营各据一方。随着技术的演进,跨平台开发框架成为开发者的新宠,旨在实现一次编码、多平台部署的梦想。本文将探讨跨平台开发的优势与挑战,并分享实用的开发技巧,帮助开发者在安卓和iOS的世界中游刃有余。
|
2月前
|
缓存 前端开发 Android开发
安卓开发中的自定义视图:从零到英雄
【10月更文挑战第42天】 在安卓的世界里,自定义视图是一块画布,让开发者能够绘制出独一无二的界面体验。本文将带你走进自定义视图的大门,通过深入浅出的方式,让你从零基础到能够独立设计并实现复杂的自定义组件。我们将探索自定义视图的核心概念、实现步骤,以及如何优化你的视图以提高性能和兼容性。准备好了吗?让我们开始这段创造性的旅程吧!
30 1
|
27天前
|
搜索推荐 前端开发 API
探索安卓开发中的自定义视图:打造个性化用户界面
在安卓应用开发的广阔天地中,自定义视图是一块神奇的画布,让开发者能够突破标准控件的限制,绘制出独一无二的用户界面。本文将带你走进自定义视图的世界,从基础概念到实战技巧,逐步揭示如何在安卓平台上创建和运用自定义视图来提升用户体验。无论你是初学者还是有一定经验的开发者,这篇文章都将为你打开新的视野,让你的应用在众多同质化产品中脱颖而出。
53 19
|
2月前
|
IDE Java 开发工具
移动应用与系统:探索Android开发之旅
在这篇文章中,我们将深入探讨Android开发的各个方面,从基础知识到高级技术。我们将通过代码示例和案例分析,帮助读者更好地理解和掌握Android开发。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的信息和技巧。让我们一起开启Android开发的旅程吧!
|
27天前
|
JSON Java API
探索安卓开发:打造你的首个天气应用
在这篇技术指南中,我们将一起潜入安卓开发的海洋,学习如何从零开始构建一个简单的天气应用。通过这个实践项目,你将掌握安卓开发的核心概念、界面设计、网络编程以及数据解析等技能。无论你是初学者还是有一定基础的开发者,这篇文章都将为你提供一个清晰的路线图和实用的代码示例,帮助你在安卓开发的道路上迈出坚实的一步。让我们一起开始这段旅程,打造属于你自己的第一个安卓应用吧!
56 14
|
30天前
|
Java Linux 数据库
探索安卓开发:打造你的第一款应用
在数字时代的浪潮中,每个人都有机会成为创意的实现者。本文将带你走进安卓开发的奇妙世界,通过浅显易懂的语言和实际代码示例,引导你从零开始构建自己的第一款安卓应用。无论你是编程新手还是希望拓展技术的开发者,这篇文章都将为你打开一扇门,让你的创意和技术一起飞扬。