iOS 如何连接 NAS?从 SMB 协议、认证到远程文件浏览架构

25 阅读17分钟

iOS 如何连接 NAS?从 SMB 协议、认证到远程文件浏览架构

在前面的文章中,我们已经讨论了三个问题:

  1. iOS 文件浏览器应该如何设计
  2. GB 级大文件应该如何安全处理
  3. iPhone 和电脑如何通过 Wi-Fi 直接传文件

这一篇继续把文件来源从:

iPhone 本地

扩展到:

NAS / Windows / 局域网共享存储。

对于一个文件浏览器来说,这是一个非常重要的变化。

因为以前我们的文件来自:

iPhone
  ↓
Sandbox
  ↓
FileManager

现在文件可能来自:

NAS
 │
 │ SMB
 ▼
Wi-Fi / LAN
 │
 ▼
iPhone

这意味着很多原来理所当然的事情都会发生变化。

例如:

  • FileManager 不再能够直接完成所有操作
  • 文件列表需要通过网络获取
  • 用户需要认证
  • NAS 可能突然离线
  • Wi-Fi 可能中断
  • 一个目录可能有几万个文件
  • 读取文件可能产生明显延迟
  • 大文件不能随意完整下载
  • 用户离开目录后连接仍然可能需要保留
  • 同一个文件可能被其他设备修改

因此:

远程文件浏览器,本质上不是“给 FileManager 换一个路径”,而是在本地文件模型之上增加一个网络文件系统。

这一篇就从 SMB 开始拆解。


1. 什么是 SMB?

SMB 全称:

Server Message Block

它是一种用于网络文件共享的协议。

非常典型的场景就是:

Windows PC
    │
Shared Folder
    │
    │ SMB
    ▼
Local Network
    │
    ▼
Other Device

NAS 也大量使用 SMB 提供文件共享。

例如家里有一台 NAS:

NAS
│
├── Movies
├── Music
├── Photos
├── Documents
└── Backup

它可能提供:

smb://192.168.1.100

用户输入:

Server
192.168.1.100

Username
kitty

Password
********

认证成功以后,就可以看到:

Movies
Music
Photos
Documents
Backup

这就是一个最基础的 SMB 文件浏览场景。


2. SMB 和上一篇 HTTP Server 有什么区别?

上一篇 Wi-Fi Transfer 的架构是:

Computer
   │
   │ HTTP
   ▼
iPhone
   │
HTTP Server

iPhone 是服务器。

SMB 场景正好相反:

iPhone
   │
   │ SMB
   ▼
NAS
   │
SMB Server

这里:

iPhone 是 Client。

NAS 是:

Server。

因此两个功能虽然都使用局域网,但角色完全不同:

Wi-Fi Transfer

Computer → Client
iPhone   → Server

而:

SMB / NAS

iPhone → Client
NAS    → Server

理解这个区别以后,很多架构问题就清楚了。


3. 一个 SMB 文件浏览器最基础的流程

用户第一次添加 NAS:

Add Server
    ↓
Host
    ↓
Username
    ↓
Password
    ↓
Connect
    ↓
Authenticate
    ↓
List Shares
    ↓
Open Share
    ↓
List Directory

例如:

192.168.1.100
       ↓
Authenticate
       ↓
Shares
       ↓
Movies
       ↓
2026
       ↓
movie.mp4

从 UI 看只是:

点文件夹 → 进入下一层。

但底层实际上不断发生:

SMB Request
    ↓
Network
    ↓
NAS
    ↓
SMB Response

因此它和本地文件浏览最大的区别之一就是:

Latency。


4. 本地目录和 SMB 目录不能使用相同的性能假设

本地:

Tap Folder
    ↓
FileManager
    ↓
Result

通常很快。

SMB:

Tap Folder
    ↓
SMB Request
    ↓
Wi-Fi
    ↓
NAS
    ↓
Disk
    ↓
SMB Response
    ↓
Wi-Fi
    ↓
iPhone

任何一层变慢都会影响用户体验。

因此不能写成:

let files = loadSMBFiles()
show(files)

然后阻塞 UI。

更合理的是:

Task {
    let files = try await smbProvider.contents(
        of: currentPath
    )

    await MainActor.run {
        self.items = files
    }
}

核心原则:

所有远程文件操作,从第一天开始就应该按照异步任务设计。


5. 为什么上一篇的 FileProvider 抽象开始真正有价值?

第一篇我们设计过:

protocol FileProvider {
    func contents(
        of path: String
    ) async throws -> [FileItem]

    func delete(
        _ item: FileItem
    ) async throws

    func rename(
        _ item: FileItem,
        to newName: String
    ) async throws
}

当时只有本地文件的时候,可能会觉得:

有必要搞这么复杂吗?

SMB 出现以后,答案就很明显了。

我们可以实现:

FileProvider
     │
 ┌───┴────┐
 ↓        ↓
Local    SMB

分别:

final class LocalFileProvider: FileProvider {
    // FileManager
}

和:

final class SMBFileProvider: FileProvider {
    // SMB Client
}

UI 不需要知道底层差异。


6. UI 应该只认识 FileItem

例如本地文件:

movie.mp4

来自:

Local

NAS 也有:

movie.mp4

来自:

SMB

对于文件列表 UI 来说,两者最好都变成:

struct FileItem {
    let id: FileID
    let name: String
    let type: FileType
    let size: Int64?
    let modifiedDate: Date?
    let location: FileLocation
}

FileLocation 可以进一步抽象:

enum FileLocation {
    case local(LocalLocation)
    case smb(SMBLocation)
    case webDAV(WebDAVLocation)
    case ftp(FTPLocation)
}

于是 UI 看到:

FileItem

而不是:

LocalFile
SMBFile
WebDAVFile
FTPFile

这会让后面的文件操作容易很多。


7. 不要把 URL 当成所有文件的唯一身份

本地文件中我们非常习惯:

URL

但是远程文件出现以后:

URL = File Identity

这种思维就开始出现问题。

一个 SMB 文件真正需要描述的可能是:

Server
Share
Path
Credentials Reference

例如:

Server:
192.168.1.100

Share:
Movies

Path:
/2026/movie.mp4

所以可以建立:

struct SMBLocation: Hashable {
    let serverID: UUID
    let share: String
    let path: String
}

而不是让整个 App 到处传:

smb://user:password@192.168.1.100/Movies/...

尤其:

绝对不要把密码直接塞进 URL 到处传递。


8. Server Configuration 应该独立管理

可以设计:

struct SMBServer {
    let id: UUID
    let name: String
    let host: String
    let port: Int
    let username: String?
}

密码不要直接放在普通模型里长期保存。

而应该考虑:

Keychain

架构:

SMBServer
   │
   ├── Host
   ├── Port
   ├── Username
   │
   └── Credential ID
             │
             ▼
          Keychain
             │
             ▼
          Password

这样:

FileItem

不需要知道密码。

SMBLocation

也不需要知道密码。

真正连接的时候:

SMBProvider
    ↓
Server ID
    ↓
Server Store
    ↓
Credential Store
    ↓
Authenticate

安全边界会清晰很多。


9. 不要把密码存在 UserDefaults

例如:

UserDefaults.standard.set(
    password,
    forKey: "nasPassword"
)

对于认证凭据来说,这通常不是理想方案。

更合理的方向是使用:

Keychain Services。

逻辑:

Username
   ↓
Configuration

Password
   ↓
Keychain

这也是文件管理器支持:

SMB
WebDAV
FTP
Cloud Storage

以后应该统一考虑的:

Credential Management Layer。

例如:

CredentialStore
      │
      ├── SMB
      ├── WebDAV
      ├── FTP
      └── Other

10. SMB 认证失败不是普通网络错误

假设连接失败。

不能只告诉用户:

Connection Failed.

因为原因可能完全不同:

Wrong Password
Server Offline
Host Not Found
Connection Timeout
Unsupported SMB Version
Permission Denied
Share Not Found
Network Unavailable

所以错误应该分类。

例如:

enum RemoteFileError: Error {
    case authenticationFailed
    case serverUnavailable
    case timeout
    case permissionDenied
    case shareNotFound
    case networkUnavailable
    case connectionLost
    case unknown(Error)
}

然后 UI 可以真正告诉用户:

用户名或密码错误

而不是:

发生未知错误 -1001

文件工具尤其需要把:

技术错误

转换成:

用户可以采取行动的错误。


11. NAS 地址不一定要让用户手动输入

最基础方式:

192.168.1.100

但普通用户可能根本不知道 NAS IP。

所以可以进一步支持:

Local Network Discovery

用户点击:

Scan Local Network

然后出现:

Available Devices

Synology-NAS
192.168.1.100

Windows-PC
192.168.1.120

HomeServer
192.168.1.150

整个体验从:

用户理解 IP
       ↓
输入 IP

变成:

Discover
   ↓
Select
   ↓
Login

产品门槛会明显降低。


12. 连接成功以后不要每个操作都重新登录

如果用户:

Open Folder
     ↓
Authenticate

Open Next Folder
     ↓
Authenticate

Download
     ↓
Authenticate

显然非常低效。

所以需要:

SMBConnection

以及:

ConnectionManager

例如:

SMBConnectionManager
        │
        ├── NAS A
        │     └── Active Session
        │
        └── NAS B
              └── Idle

它负责:

Connect
Reuse
Reconnect
Timeout
Disconnect

于是 Provider 只需要:

Give me connection for Server A

而不是自己重新建立完整连接。


13. 连接池是否越多越好?

也不是。

如果同时保持:

NAS A
NAS B
Windows PC
Server C
Server D

全部长期连接:

会消耗:

  • Socket
  • Memory
  • Server resources
  • Network resources

所以可以设计:

Active
Idle
Expired
Disconnected

例如:

Last Used
   ↓
Idle Timeout
   ↓
Disconnect

需要的时候再:

Reconnect

这和数据库 Connection Pool 的思路有一些相似。


14. SMB 文件列表应该支持缓存

假设:

Movies

目录中有:

3000 Files

每次用户:

Movies
 ↓
Back
 ↓
Movies

都重新完整请求一次,会产生明显等待。

因此可以加入:

Directory Cache

例如:

SMBLocation
     ↓
Cached [FileItem]
     ↓
Timestamp

打开目录:

Cache exists?
   │
 ┌─┴─┐
Yes  No
 │    │
 ▼    ▼
Show  Fetch
 │
 ▼
Refresh in Background

这样可以做到:

先显示,再刷新。


15. 但缓存不能成为真相

NAS 最大的问题是:

文件可能被其他设备修改。

例如:

Mac
 ↓
Delete movie.mp4

但 iPhone Cache 仍然显示:

movie.mp4

所以:

Cache ≠ Source of Truth

缓存只是:

Performance Optimization。

最终真相仍然来自:

Remote Server

可以采用:

Cached Result
     ↓
Immediate UI
     ↓
Background Refresh
     ↓
Diff
     ↓
Update UI

这会比每次空白等待更加自然。


16. 目录有 50,000 个文件怎么办?

这是 NAS 场景非常实际的问题。

例如:

Photos/
    ↓
50,000 Files

如果一次性:

Fetch All
   ↓
Create 50,000 FileItem
   ↓
Generate Metadata
   ↓
Generate Thumbnail

性能可能很差。

因此需要考虑:

Pagination
Batching
Lazy Loading
Virtualized UI

理想流程:

Open Folder
    ↓
First Batch
    ↓
Display
    ↓
User Scroll
    ↓
Next Batch

文件浏览器的一个重要原则:

目录有多少文件,不应该决定首屏需要等多久。


17. 缩略图绝对不能直接下载完整视频

假设 NAS 里有:

movie.mp4
Size: 25GB

为了生成一个:

160 × 160

缩略图,如果先下载整个:

25GB

显然不可接受。

远程缩略图需要完全不同的策略:

Server Thumbnail

或者:

Partial Read

或者:

Download only required metadata/data range

根据协议和媒体格式选择不同方案。

因此 Thumbnail Service 需要知道:

Local?
Remote?

但是 UI 不需要知道。


18. NAS 视频播放为什么值得单独设计?

用户看到:

movie.mkv

点击以后,最差的实现:

Download 30GB
     ↓
Wait
     ↓
Play

用户显然不会接受。

理想体验:

Tap
 ↓
Buffer
 ↓
Play
 ↓
Continue Streaming

也就是:

Remote Media Streaming。

这要求底层支持:

Seek
Range Read
Buffer
Prefetch

播放器拖到:

01:24:32

底层需要跳到对应文件 Offset。

这就是为什么远程文件访问不仅是:

download()

还需要:

read(offset:length:)

这样的能力。


19. FileProvider 接口需要继续升级

第一篇简单设计:

func contents(of path: String)

到了 SMB,就会发现不够。

可能需要:

protocol FileProvider {

    func list(
        at location: FileLocation
    ) async throws -> [FileItem]

    func metadata(
        for item: FileItem
    ) async throws -> FileMetadata

    func read(
        _ item: FileItem,
        offset: Int64,
        length: Int
    ) async throws -> Data

    func download(
        _ item: FileItem,
        to destination: URL
    ) async throws

    func upload(
        from source: URL,
        to destination: FileLocation
    ) async throws

    func delete(
        _ item: FileItem
    ) async throws

    func rename(
        _ item: FileItem,
        to name: String
    ) async throws
}

尤其:

read(offset:length:)

非常重要。

因为它可以支持:

Thumbnail
Streaming
Preview
Partial Download
Resume

而不用每次完整下载文件。


20. NAS → iPhone 的大文件下载

假设:

NAS
 ↓
30GB Video
 ↓
iPhone

这就重新回到第二篇的大文件架构:

SMB Read
   ↓
Chunk
   ↓
Temporary File
   ↓
Chunk
   ↓
Temporary File
   ↓
Progress
   ↓
Complete
   ↓
Finalize

仍然需要:

Progress
Cancellation
Temporary File
Retry
Resume
Validation

所以:

SMBFileProvider 不应该自己重新发明任务管理系统。

它应该进入:

FileOperationManager

21. 本地 ↔ NAS 应该统一成 Copy Operation

这是整个架构最有意思的地方。

用户看到的操作只是:

Copy

但实际可能有:

Local → Local

Local → SMB

SMB → Local

SMB → SMB

未来还可能:

WebDAV → Local

SMB → WebDAV

FTP → SMB

如果每一种都单独写:

copyLocalToSMB()

copySMBToLocal()

copySMBToWebDAV()

copyFTPToSMB()

组合会越来越多。

假设有:

5 Providers

理论组合数量就会迅速膨胀。

更好的方式:

Source Provider
      ↓
Read Stream
      ↓
Transfer Pipeline
      ↓
Write Stream
      ↓
Destination Provider

于是:

Copy(
    source,
    destination
)

变成统一操作。


22. Provider-to-Provider 是一个非常重要的抽象

理想架构:

              FileOperationManager
                       │
                       ▼
                Transfer Pipeline
                       │
             ┌─────────┴─────────┐
             ▼                   ▼
      Source Provider     Destination Provider
             │                   │
        SMB / Local         Local / WebDAV

例如:

NAS A
 ↓
SMB Provider
 ↓
Stream
 ↓
WebDAV Provider
 ↓
Cloud

UI 只知道:

把这个文件复制到那里。

至于:

SMB → Local
Local → WebDAV
FTP → SMB

由底层决定。

这就是统一文件管理器真正有价值的架构。


23. 远程文件操作需要考虑“文件已经变了”

假设用户看到:

report.pdf
10MB

然后准备下载。

但另一台电脑刚刚:

Replace report.pdf

这时候:

UI State
≠
Remote State

因此对重要操作,可以根据能力考虑:

Size
Modification Time
File ID
Checksum

等信息判断文件是否变化。

尤其:

Resume

场景。

如果断点下载:

Old File
10GB
 ↓
Downloaded 5GB

NAS 上文件被替换以后继续:

New File
 ↓
Resume from 5GB

最终文件可能直接损坏。


24. 网络断开不是异常情况,而是正常状态

开发本地文件时,很容易把网络断开理解成:

Error

但在 NAS App 中:

Wi-Fi Lost
Router Restart
NAS Sleep
NAS Reboot
User Leaves Home
VPN Disconnected

都是非常正常的现实情况。

所以架构应该是:

Connected
   ↓
Disconnected
   ↓
Waiting
   ↓
Reconnect
   ↓
Resume

而不是:

Disconnected
   ↓
💥

这也是远程文件系统和本地文件系统最大的思维差异之一。


25. SMB Server 状态应该显式建模

例如:

enum ServerConnectionState {
    case disconnected
    case connecting
    case authenticating
    case connected
    case reconnecting
    case failed(RemoteFileError)
}

UI 就可以显示:

Home NAS

● Connected

或者:

Home NAS

○ Reconnecting...

而不是用户点进去以后一直:

Loading...

不知道发生了什么。


26. iPhone 离开家庭 Wi-Fi 后怎么办?

假设 NAS 地址:

192.168.1.100

用户出门以后:

5G

显然不能再访问这个私有局域网地址。

产品应该明确区分:

Server Saved

和:

Server Reachable

服务器仍然存在于收藏列表。

但状态:

Offline

而不是删除连接配置。

这对用户来说非常重要。


27. SMB 不是“云盘”

这也是产品设计容易混淆的地方。

云盘通常假设:

Internet
 ↓
Cloud

NAS SMB 更常见:

Local Network
 ↓
NAS

所以:

  • NAS 睡眠可能导致连接失败
  • 局域网地址会变化
  • 用户离家后可能无法访问
  • 路由器配置会影响连接
  • Guest Wi-Fi 可能隔离设备
  • VPN 可能改变路由

错误提示应该针对这些现实情况,而不是简单写:

Server Error。


28. 一个比较完整的 SMB 文件架构

最终可以整理为:

┌──────────────────────────────────┐
│               UI                 │
│                                  │
│ File Browser / Server List       │
│ Search / Task Center             │
└────────────────┬─────────────────┘
                 │
                 ▼
┌──────────────────────────────────┐
│           FileService            │
└────────────────┬─────────────────┘
                 │
                 ▼
┌──────────────────────────────────┐
│      FileOperationManager        │
│                                  │
│ Copy / Download / Upload         │
│ Progress / Retry / Resume        │
└────────────────┬─────────────────┘
                 │
                 ▼
┌──────────────────────────────────┐
│          FileProvider            │
└───────┬──────────────────┬───────┘
        │                  │
        ▼                  ▼
 LocalFileProvider     SMBFileProvider
        │                  │
        ▼                  ▼
 FileManager       SMBConnectionManager
                           │
                    ┌──────┴──────┐
                    ▼             ▼
               Credential      Cache
                  Store
                    │
                    ▼
                 Keychain

另外还有:

Network Monitor
Thumbnail Service
Media Streaming
Directory Cache
Server Discovery

这些都是独立能力。


29. TS File Explorer 为什么需要 SMB?

从技术角度,我们讨论的是:

SMB Protocol
Authentication
Connection
Cache
Streaming
Remote IO

但用户的问题其实简单得多:

“我 NAS 里的文件,能不能直接在 iPhone 上打开?”

例如:

NAS
 ↓
Movies
 ↓
movie.mkv

用户真正希望的是:

打开 TS File Explorer
       ↓
NAS
       ↓
Movies
       ↓
movie.mkv
       ↓
Play

而不是:

打开电脑
 ↓
下载 NAS 文件
 ↓
上传云盘
 ↓
iPhone 下载
 ↓
打开

所以 SMB 对文件浏览器来说并不是一个单纯的:

“协议支持”。

它真正增加的是:

一个新的文件来源。


30. 文件浏览器真正应该管理的是“文件来源”

做到这里,我们可以重新理解:

File Explorer。

刚开始:

File Explorer
      ↓
Browse Local Files

继续开发以后:

File Explorer
      │
      ├── On My iPhone
      │
      ├── Imported Files
      │
      ├── NAS / SMB
      │
      ├── WebDAV
      │
      ├── FTP
      │
      └── Cloud

所以真正需要抽象的已经不是:

Folder UI

而是:

File Source

不同 Source:

Local
SMB
WebDAV
FTP
Cloud

使用不同协议。

但最终统一成:

FileItem
+
FileProvider
+
FileOperation

这就是整个系列前四篇逐渐建立起来的核心架构。


31. 开发 SMB/NAS 功能之前,先回答这 15 个问题

如果准备开发自己的 NAS 文件客户端,我建议先回答:

1. 如何保存 Server Configuration?

2. Credential 保存在哪里?

3. 如何处理认证失败?

4. 是否支持 Server Discovery?

5. Connection 是否复用?

6. Connection 什么时候释放?

7. Directory 是否缓存?

8. 大目录如何分页?

9. Remote Thumbnail 怎么生成?

10. 视频是否支持 Streaming?

11. 大文件下载是否支持 Resume?

12. 网络断开以后怎么处理?

13. Server Offline 如何展示?

14. SMB → Local 如何统一成 Copy?

15. SMB → 其他 Provider 怎么设计?

如果这些问题没有提前考虑,SMB 很容易变成:

一堆网络 API
+
一堆 View 中的特殊判断

最终非常难维护。


写在最后

从:

FileManager

开始做文件浏览器以后,我们一步一步增加:

Local File
     ↓
Large File
     ↓
Wi-Fi Transfer
     ↓
SMB / NAS

随着文件来源越来越多,一个非常重要的架构原则开始变得清晰:

UI 不应该关心文件来自哪里。

UI 只应该看到:

FileItem

文件操作层看到:

FileOperation

真正知道文件来自哪里的是:

FileProvider

于是:

Local
SMB
WebDAV
FTP

都可以进入同一套文件管理体系。

这也是我在开发 TS File Explorer 时逐渐确定的产品方向:

文件管理器不应该只是管理“手机里的文件”,而应该尽可能统一管理用户能够访问的文件来源。

当这个抽象建立以后,下一步自然就是另外一种非常常见的远程文件协议:

《WebDAV 文件管理是如何实现的?从 HTTP 方法到跨存储 FileProvider 架构》

下一篇我们可以继续讨论:

PROPFIND
GET
PUT
DELETE
MOVE
MKCOL

以及:

WebDAV
   ↓
HTTP
   ↓
Remote File System

看看为什么一个建立在 HTTP 之上的协议,也可以提供类似文件系统的操作能力。