Android如何实现茄子快传

作者: EthanMa | 来源:发表于2016-12-22 15:24 被阅读4916次

    Android如何实现茄子快传

    茄子快传是一款文件传输应用,相信大家都很熟悉这款应用,应该很多人用过用来文件的传输。它有两个核心的功能:

    1. 端到端的文件传输
    2. Web端的文件传输

    这两个核心的功能我们具体来分析一下!

    端到端的文件传输

    所谓的端到端的文件传输是指应用端发送到应用端(这里的应用端指Android应用端),这种文件传输方式是文件发送端和文件接收端必须安装应用。

    效果图

    文件发送方

    文件发送方_1 文件发送方_2
    文件发送方_3

    文件接收方

    文件接收方_1 文件接收方_2

    简单的文件传输的话,我们可以用蓝牙,wifi直连,ftp这几种方式来进行文件的传输。但是:

    1. 蓝牙传输的话,速度太慢,而且要配对。相对比较麻烦。
    2. wifi直连差不多跟蓝牙一样,但是速率很快,也要配对。
    3. ftp可以实现文件的批量传输,但是没有文件的缩略图。

    最初分析这个项目的时候就想着通过自定义协议的Socket的通信来实现,自定义的协议包括header + body的自定义协议, header部分包括了文件的信息(长度,大小,文件路径,缩略图), body部分就是文件。现在实现这一功能。(后序:后面开发《网页传》功能的时候,可以考虑这两个核心的功能都能用在Android架设微型Http服务器来实现。这是后话了。)

    流程图

    端到端的流程图

    编码实现

    两部设备文件传输是需要在一个局域网的条件下的,只有文件发送方连接上文件接收方的热点(搭建了一个局域网),这样文件发送方和文件接收方就在一个局域网里面,我们才可以进行Socket通信。这是一个大前提!

    初始化条件 -- Ap(热点)和Wifi的管理, 文件的扫描

    对Android的Ap(热点)和Wifi的一些操作都封装在下面两个类:

    WifiMgr.java

    APMgr.java

    关于热点和Wifi的操作都是根据WifiManager来操作的。所以要像操作WifiManeger是必须要一些权限的。必须在AndroidManifest.xml清单文件里面声明权限:

    
        <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
        <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
        <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
        <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    

    文件接收端打开热点并且配置热点的代码:

    
            //1.初始化热点
            WifiMgr.getInstance(getContext()).disableWifi();
            if(ApMgr.isApOn(getContext())){
                ApMgr.disableAp(getContext());
            }
    
            //热点相关的广播
            mWifiAPBroadcastReceiver = new WifiAPBroadcastReceiver() {
                @Override
                public void onWifiApEnabled() {
                    Log.i(TAG, "======>>>onWifiApEnabled !!!");
                    if(!mIsInitialized){
                        mUdpServerRuannable = createSendMsgToFileSenderRunnable();
                        AppContext.MAIN_EXECUTOR.execute(mUdpServerRuannable);
                        mIsInitialized = true;
    
                        tv_desc.setText(getResources().getString(R.string.tip_now_init_is_finish));
                        tv_desc.postDelayed(new Runnable() {
                            @Override
                            public void run() {
                                tv_desc.setText(getResources().getString(R.string.tip_is_waitting_connect));
                            }
                        }, 2*1000);
                    }
                }
            };
            IntentFilter filter = new IntentFilter(WifiAPBroadcastReceiver.ACTION_WIFI_AP_STATE_CHANGED);
            registerReceiver(mWifiAPBroadcastReceiver, filter);
    
            ApMgr.isApOn(getContext()); // check Ap state :boolean
            String ssid = TextUtils.isNullOrBlank(android.os.Build.DEVICE) ? Constant.DEFAULT_SSID : android.os.Build.DEVICE;
            ApMgr.configApState(getContext(), ssid); // change Ap state :boolean
    
    

    对于类WifiAPBroadcastReceiver是热点的一个广播类,最后一行代码是配置指定名称的热点,这里是以设备名称作为热点的名称。

    文件发送端发送文件,文件发送端首先要选择要发送的文件,然后将要选择的文件存储起来,这里我是用了一个HashMap将发送的文件存储起来,key是文件的路径,value是FileInfo对象。

    以下是扫描手机存储盘上面的文件列表的代码:

    
        /**
         * 存储卡获取 指定后缀名文件 
         * @param context
         * @param extension 
         * @return
         */
        public static List<FileInfo> getSpecificTypeFiles(Context context, String[] extension){
            List<FileInfo> fileInfoList = new ArrayList<FileInfo>();
    
            //内存卡文件的Uri
            Uri fileUri= MediaStore.Files.getContentUri("external");
            //筛选列,这里只筛选了:文件路径和含后缀的文件名
            String[] projection=new String[]{
                    MediaStore.Files.FileColumns.DATA, MediaStore.Files.FileColumns.TITLE
            };
    
            //构造筛选条件语句
            String selection="";
            for(int i=0;i<extension.length;i++)
            {
                if(i!=0)
                {
                    selection=selection+" OR ";
                }
                selection=selection+ MediaStore.Files.FileColumns.DATA+" LIKE '%"+extension[i]+"'";
            }
            //按时间降序条件
            String sortOrder = MediaStore.Files.FileColumns.DATE_MODIFIED;
    
            Cursor cursor = context.getContentResolver().query(fileUri, projection, selection, null, sortOrder);
            if(cursor != null){
                while (cursor.moveToNext()){
                    try{
                        String data = cursor.getString(0);
                        FileInfo fileInfo = new FileInfo();
                        fileInfo.setFilePath(data);
    
                        long size = 0;
                        try{
                            File file = new File(data);
                            size = file.length();
                            fileInfo.setSize(size);
                        }catch(Exception e){
    
                        }
                        fileInfoList.add(fileInfo);
                    }catch (Exception e){
                        Log.i("FileUtils", "------>>>" + e.getMessage());
                    }
    
                }
            }
            Log.i(TAG, "getSize ===>>> " + fileInfoList.size());
            return fileInfoList;
        }
    
    

    注意**:这里扫描的FileInfo对象只是扫描了文件路径filePath, 还有文件的大小size。
    FileInfo的其他属性到文件传输的时候再二次获取,获取FileInfo的其他属性都在FileUtils这个工具类里面了。

    文件发送端打开wifi扫描热点并且连接热点的代码:

    
            if(!WifiMgr.getInstance(getContext()).isWifiEnable()) {//wifi未打开的情况,打开wifi
                WifiMgr.getInstance(getContext()).openWifi();
            }
    
            //开始扫描
            WifiMgr.getInstance(getContext()).startScan();
            mScanResultList = WifiMgr.getInstance(getContext()).getScanResultList();
            mScanResultList = ListUtils.filterWithNoPassword(mScanResultList);
    
            if(mScanResultList != null){
                mWifiScanResultAdapter = new WifiScanResultAdapter(getContext(),mScanResultList);
                lv_result.setAdapter(mWifiScanResultAdapter);
                lv_result.setOnItemClickListener(new AdapterView.OnItemClickListener() {
                    @Override
                    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                        //单击选中指定的网络
                        ScanResult scanResult = mScanResultList.get(position);
                        Log.i(TAG, "###select the wifi info ======>>>" + scanResult.toString());
    
                        //1.连接网络
                        String ssid = Constant.DEFAULT_SSID;
                        ssid = scanResult.SSID;
                        WifiMgr.getInstance(getContext()).openWifi();
                        WifiMgr.getInstance(getContext()).addNetwork(WifiMgr.createWifiCfg(ssid, null, WifiMgr.WIFICIPHER_NOPASS));
    
                        //2.发送UDP通知信息到 文件接收方 开启ServerSocketRunnable
                        mUdpServerRuannable = createSendMsgToServerRunnable(WifiMgr.getInstance(getContext()).getIpAddressFromHotspot());
                        AppContext.MAIN_EXECUTOR.execute(mUdpServerRuannable);
                    }
                });
            }
    
    

    对于ListUtils.filterWithNoPassword是将扫描的结果进行过滤,过滤掉有密码的扫描结果。

    lv_result.setOnItemClickListener回调的方法是连接指定的热点来形成一个局域网。文件传输的大前提条件就已经形成了。

    到这里文件发送端和文件接收端的初始化环境也就搭建起来了。

    文件传输模块

    文件传输模块的核心代码就只有4个类,Transferable, BaseTransfer, FileSender, FileReceiver

    Transferable是接口。

    BaseTransfer, FileSender, FileReceiver是类。

    对于文件发送端,每一个文件发送对应一个FileSender,而对于文件接收端,每一个文件的接收对应一个FileReceiver。
    而FileSender,FileReceiver是继承自 抽象类BaseTransfer的。 BaseTransfer是实现了Transferable接口。

    下面是4个类图的关系:

    这里写图片描述

    在Transferable接口中定义了4个方法,分别是初始化解析头部解析主体结束。解析头部和解析主体分别对应上面说的自定义协议的headerbody。初始化是为每一次文件传输做初始化工作,而结束是为每一次文件传输做结束工作,比如关闭一些资源流,Socket等等。

    而BaseTransfer就只是实现了Transferable, 里面封装了一些常量。没有实现具体的方法,具体的实现是FileSender,FileReceiver。

    代码详情:

    Transferable
    BaseTransfer
    FileSender
    FileReceiver

    总结

    端到端的文件传输就分析到这里,主要是Ap热点的操作,Wifi的操作,Socket通信来实现文件的传输。但是这里的Socket用到的不是异步IO,是同步IO。所以会引起阻塞。比如在FileSender中的暂停文件传输pause方法调用之后,会引起FileReceiver中文件传输的阻塞。如果你对异步IO有兴趣,你也可以去实现一下。

    对于端对端的核心代码都是在 io.github.mayubao.kuaichuan.core 包下面。
    这是我在github上面的项目链接 https://github.com/mayubao/KuaiChuan

    web端的文件传输

    所谓的Web端的文件传输是指文件发送端作为一个Http服务器,提供文件接收端来下载。这种文件传输方式是文件发送端必须安装应用,而文件接收端只需要有浏览器即可

    效果图

    文件发送端

    文件选择 开启Http服务器

    文件接收端

    文件接收端浏览器访问

    在android应用端架设微型Http服务器来实现文件的传输。这里可以用ftp来实现,为什么不用ftp呢?因为没有缩略图,这是重点!

    web端的文件传输的核心重点:

    1. 文件发送端热点的开启(参考端对端的热点操作类 APMgr.java
    2. 文件发送端架设Http服务器。

    Android端的Http服务器

    Android上微型Http服务器(Socket实现),结合上面的效果图分析。主要解决三种Http url的请求形式就行了,由上面的文件接收端的效果图可以看出来(文件接收端是去访问文件发送端的Http服务器),大致可以分为三种链接:

    1. Index主页链接 http://hostname:port
    2. Image链接 http://hostname:port/image/xxx.xxx
    3. Download链接 http://hostname:port/download/xxx.xxx
    这里写图片描述

    下面用Socket来实现在Android上面的微型Http服务器的。

    关于Http协议,我简单的描述一下Http协议。对于Http协议,就是"请求-回复(响应)"的这种通信模式。客户端发出请求,服务器根据请求,返回一个回复(响应)给客户端。

    Http请求的大致分为四个部分:

    1. 请求行
    2. 请求头
    3. 空行
    4. 请求实体

    Http响应的大致分为四个部分:

    1. 状态行
    2. 响应头
    3. 空行
    4. 响应实体

    Http请求(POST请求)的示例

    
    POST /image/index.html HTTP/1.1
    Host: 127.0.0.1:7878
    Connection: keep-alive
    Content-Length: 247
    Cache-Control: no-cache
    Origin: chrome-extension://fdmmgilgnpjigdojojpjoooidkmcomcm
    User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36
    Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryLIr5t1rdtuD8Ztuw
    Accept: */*
    Accept-Encoding: gzip, deflate
    Accept-Language: zh-CN,zh;q=0.8,en;q=0.6
    
    ------WebKitFormBoundaryLIr5t1rdtuD8Ztuw
    Content-Disposition: form-data; name="username"
    
    mayubao
    ------WebKitFormBoundaryLIr5t1rdtuD8Ztuw
    Content-Disposition: form-data; name="username"
    
    123456
    ------WebKitFormBoundaryLIr5t1rdtuD8Ztuw--
    

    1.请求行(请求方式 + uri + http版本)

    
    POST /image/index.html HTTP/1.1
    

    2.请求头

    
    Host: 127.0.0.1:7878
    Connection: keep-alive
    Content-Length: 247
    Cache-Control: no-cache
    Origin: chrome-extension://fdmmgilgnpjigdojojpjoooidkmcomcm
    User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36
    Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryLIr5t1rdtuD8Ztuw
    Accept: */*
    Accept-Encoding: gzip, deflate
    Accept-Language: zh-CN,zh;q=0.8,en;q=0.6
    

    3.空行

    4.请求实体(对于GET请求一般没有请求实体)

    
    ------WebKitFormBoundaryLIr5t1rdtuD8Ztuw
    Content-Disposition: form-data; name="username"
    
    mayubao
    ------WebKitFormBoundaryLIr5t1rdtuD8Ztuw
    Content-Disposition: form-data; name="username"
    
    123456
    ------WebKitFormBoundaryLIr5t1rdtuD8Ztuw--
    

    Http响应示例

    
    HTTP/1.0 200 OK 
    Cache-Control:public, max-age=86400
    Content-Length:235
    Content-Type:image/png
    Date:Wed, 21 Dec 2016 08:20:54 GMT
    
    请求实体
    

    1.状态行(Http版本 + 状态 + 描述)

    
    HTTP/1.0 200 OK 
    

    2.响应头

    
    HTTP/1.0 200 OK 
    Cache-Control:public, max-age=86400
    Content-Length:235
    Content-Type:image/png
    Date:Wed, 21 Dec 2016 08:20:54 GMT
    

    3.空行

    4.响应实体

    上面只是简单的叙述了一下Http一般的请求-响应流程,还有对应请求,响应的结构。如果你想进一步了解http协议,请私下自行了解。

    回到我们的重点 AndroidMicroServer
    AndroidMicroServer是Http服务器的核心类,还有关联到其他的类,有IndexUriResHandler,ImageUriResHandler, DowloadUriResHandler。是AndroidMicroServer根据不同的Uri格式分配给指定的Handler去处理的。

    UML的分析图如下:

    AndroidMicroServer分析

    下面是AndroidMicroServer的源码:

    
    /**
     * The micro server in Android
     * Created by mayubao on 2016/12/14.
     * Contact me 345269374@qq.com
     */
    public class AndroidMicroServer {
    
        private static final String TAG = AndroidMicroServer.class.getSimpleName();
    
        /**
         * the server port
         */
        private int mPort;
    
        /**
         * the server socket
         */
        private ServerSocket mServerSocket;
    
        /**
         *  the thread pool which handle the incoming request
         */
        private ExecutorService mThreadPool = Executors.newCachedThreadPool();
    
        /**
         * uri router handler
         */
        private List<ResUriHandler> mResUriHandlerList = new ArrayList<ResUriHandler>();
    
        /**
         * the flag which the micro server enable
         */
        private boolean mIsEnable = true;
    
        public AndroidMicroServer(int port){
            this.mPort = port;
        }
    
        /**
         * register the resource uri handler
         * @param resUriHandler
         */
        public void resgisterResUriHandler(ResUriHandler resUriHandler){
            this.mResUriHandlerList.add(resUriHandler);
        }
    
        /**
         * unresigter all the resource uri hanlders
         */
        public void unresgisterResUriHandlerList(){
            for(ResUriHandler resUriHandler : mResUriHandlerList){
                resUriHandler.destroy();
                resUriHandler = null;
            }
        }
    
        /**
         * start the android micro server
         */
        public void start(){
            mThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    try {
                        mServerSocket = new ServerSocket(mPort);
    
                        while(mIsEnable){
                            Socket socket = mServerSocket.accept();
                            hanlderSocketAsyn(socket);
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
    
        /**
         * stop the android micro server
         */
        public void stop(){
            if(mIsEnable){
                mIsEnable = false;
            }
    
            //release resource
            unresgisterResUriHandlerList();
    
            if(mServerSocket != null){
                try {
    //                mServerSocket.accept(); //fuck ! fix the problem, block the main thread
                    mServerSocket.close();
                    mServerSocket = null;
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    
        /**
         * handle the incoming socket
         * @param socket
         */
        private void hanlderSocketAsyn(final Socket socket) {
            mThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    //1. auto create request object by the parameter socket
                    Request request = createRequest(socket);
    
                    //2. loop the mResUriHandlerList, and assign the task to the specify ResUriHandler
                    for(ResUriHandler resUriHandler : mResUriHandlerList){
                        if(!resUriHandler.matches(request.getUri())){
                            continue;
                        }
    
                        resUriHandler.handler(request);
                    }
                }
            });
    
        }
    
        /**
         * create the requset object by the specify socket
         *
         * @param socket
         * @return
         */
        private Request createRequest(Socket socket) {
            Request request = new Request();
            request.setUnderlySocket(socket);
            try {
                //Get the reqeust line
                SocketAddress socketAddress = socket.getRemoteSocketAddress();
                InputStream is = socket.getInputStream();
                String requestLine = IOStreamUtils.readLine(is);
                SLog.i(TAG, socketAddress + "requestLine------>>>" + requestLine);
                String requestType = requestLine.split(" ")[0];
                String requestUri = requestLine.split(" ")[1];
    
    //            requestUri = URLDecoder.decode(requestUri, "UTF-8");
    
                request.setUri(requestUri);
    
                //Get the header line
                String header = "";
                while((header = IOStreamUtils.readLine(is)) != null){
                    SLog.i(TAG, socketAddress + "header------>>>" + requestLine);
                    String headerKey = header.split(":")[0];
                    String headerVal = header.split(":")[1];
                    request.addHeader(headerKey, headerVal);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
    
            return request;
        }
    
    }
    
    

    AndroidMicroServer主要有两个方法:

    1. start (Http服务器的开启)
    2. stop (Http服务器的关闭,主要用来关闭ServerSocket和反注册UriResHandler)

    start方法 是Http服务器的入口

    对于start方法:

    
        /**
         * start the android micro server
         */
        public void start(){
            mThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    try {
                        mServerSocket = new ServerSocket(mPort);
    
                        while(mIsEnable){
                            Socket socket = mServerSocket.accept();
                            hanlderSocketAsyn(socket);
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
    

    开启一个线程去执行ServerSocket, while循环去接收每一个进来的Socket。 而hanlderSocketAsyn(socket)是异步处理每一个进来的socket。

    
        /**
         * handle the incoming socket
         * @param socket
         */
        private void hanlderSocketAsyn(final Socket socket) {
            mThreadPool.submit(new Runnable() {
                @Override
                public void run() {
                    //1. auto create request object by the parameter socket
                    Request request = createRequest(socket);
    
                    //2. loop the mResUriHandlerList, and assign the task to the specify ResUriHandler
                    for(ResUriHandler resUriHandler : mResUriHandlerList){
                        if(!resUriHandler.matches(request.getUri())){
                            continue;
                        }
    
                        resUriHandler.handler(request);
                    }
                }
            });
        }
    
        /**
         * create the requset object by the specify socket
         *
         * @param socket
         * @return
         */
        private Request createRequest(Socket socket) {
            Request request = new Request();
            request.setUnderlySocket(socket);
            try {
                //Get the reqeust line
                SocketAddress socketAddress = socket.getRemoteSocketAddress();
                InputStream is = socket.getInputStream();
                String requestLine = IOStreamUtils.readLine(is);
                SLog.i(TAG, socketAddress + "requestLine------>>>" + requestLine);
                String requestType = requestLine.split(" ")[0];
                String requestUri = requestLine.split(" ")[1];
    
    //            //解决URL中文乱码的问题
    //            requestUri = URLDecoder.decode(requestUri, "UTF-8");
    
                request.setUri(requestUri);
    
                //Get the header line
                String header = "";
                while((header = IOStreamUtils.readLine(is)) != null){
                    SLog.i(TAG, socketAddress + "header------>>>" + requestLine);
                    String headerKey = header.split(":")[0];
                    String headerVal = header.split(":")[1];
                    request.addHeader(headerKey, headerVal);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
    
            return request;
        }
    
    

    对于每一个进来的Socket:

    1. 通过createRequest(socket)来创建一个Request对象,对应一个Http Request对象。在createRequest(socket)中如何去从socket中去读取每一行呢?对于每一个Http请求的每一行都是以'\r\n'字节结尾的。只要判断读取字节流的时候判断连续的两个字节是以'\r\n'结尾的就是一行结尾的标识。详情请查看IOStreamUtils.java

    2. 根据请求行的path,分配给对应的Uri处理对象去处理,而所对应uri如何获取,是从Socket的Inputsream读取Http Request的请求行中读取出来的。对于ResUriHandler,是一个接口。主要根据请求行的uri 分配给对应的ResUriHandler去处理。 ResUriHandler的实现类是对应给出响应的处理类。

    注意:可参考上面的UML的类图分析

    ResUriHandler有三个实现类分别对应上面分析的三种Uri格式:

    1. IndexResUriHandler 处理发送文件列表的显示
    2. ImageResUriHandler 处理文件图片
    3. DownloadResUriHandler 处理文件下载

    总结

    AndroidMicroServer是架设在Android平台上面的一个微型HttpServer, 是根据快传项目的具体需求来实现的。巧妙的利用ResUriHandler来处理不同的uri。注意这不是一般通用的HttpServer, 之前有想过在Github上面去找一些Server端的代码来进行开发,发现代码关联太多,而且不容易定制,所以才会萌生自己用ServerSocket来实现符合自己需求的HttpServer。

    对于HttpServer的核心代码都是在 io.github.mayubao.kuaichuan.micro_server包下面。
    这是我在github上面的项目链接 https://github.com/mayubao/KuaiChuan

    相关文章

      网友评论

      • a0070de5e83a:茄子快传没有Web端的文件传输功能吧?
        a0070de5e83a:@EthanMa 我看了下v3.9,只有个扫码桌面客户端传输的功能,并没有Web端扫码传输文件的功能
        EthanMa:@MathJoy 有的~
      • feer921: 嗨,能问一下,那些个图是用什么软件画出来的?
      • 0e8e2733e0ef:能请教一下大神,传输过程中那个蓝绿色的进度条是怎么实现的吗,最近一直在头疼这个
      • 我系渣渣徽:先给个赞
      • 牵着你的手慢慢走:请问服务器ip为什么是192.168.43.1呀?是写死的吗?还是动态获取的
        EthanMa:@路路很任性 恩恩对的~
        路路很任性:@EthanMa 所以不管哪个是手机开启热点 这个ip 都是一样的呀
        EthanMa:@牵着你的手慢慢走 开启热点之后的的ip是固定的,至少在android这边Ip是固定的
      • 飞天德先生:6的飞起~~不过对于服务器的知识点不是特别明白。大神可以说下服务器这边的知识点都涉及到哪些了吗?
        飞天德先生:@EthanMa 非常感谢!
        EthanMa:@飞天小花猫 Http协议,一般Http的请求-回复模型,详情自行Google或者百度
      • 木乃伊459:请问为什么 http://192.168.43.1:3999/download/+文件名就可以对应到手机的具体文件了
        木乃伊459:@EthanMa 我知道他的作用,可是为什么为无法传输呢,方便给一个联系方式请教你一下吗
        EthanMa: @J_Night 这关系到http协议的内容了,Content-length是指定响应实体的内容大小。
        写出去的这个属性是为了让请求者知道响应的大小
        木乃伊459:我懂了,通过文件获取文件路径,然后用 PrintStream输出,可是如果不指定Content-Length的话,我传输以xls文件,就会失去内容,如果加上的话,就无法传输,请问是为什么呢?
      • e5497a4bf107:感谢分享。。

      本文标题:Android如何实现茄子快传

      本文链接:https://www.haomeiwen.com/subject/thqhvttx.html