├── README.md └── ResUpdater ├── AbstractCheckState.cs ├── CheckMd5State.cs ├── CheckVersionState.cs ├── DownloadResState.cs ├── Downloader.cs ├── Reporter.cs ├── Res.cs └── ResUpdater.cs /README.md: -------------------------------------------------------------------------------- 1 | # unityresupdater 2 | unity resource updater 3 | 4 | ##资源自动更新系统 5 | 6 | ###资源类型 7 | 1. res.version 8 | 2. res.md5 9 | 3. res[] 10 | 11 | ###资源目录 12 | 1. StreamingAssetPath 这个随包发布 13 | 2. PersistentAssetPath 这个之后下载, 14 | 15 | * 可能上次下载没完成(这时候文件写了一半这种情况不考虑,猜测现在os应该都是先写data,sync后再写inode) 16 | * 可能重装时上次安装留下,(比如导致这里的版本低于StreamingAssetPath里的) 17 | * 可能被用户删除回收(全部或部分) 18 | * 可能服务器回退版本 19 | 20 | ###目标 21 | 1. 客户端读取正确版本res 22 | 2. 适应上面所有情况自动修复。 23 | 3. 第一次下载安装不解压。 24 | 4. 如果用户手工改动单个文件且文件大小保持不变,不修复。(如果修复需要每次启动计算md5,太慢) 25 | 26 | 27 | ###基本流程 28 | 29 | 1. CheckVersion 30 | 31 | * 下载res.version到res.version.latest,读取2个目录res.version和res.version. 32 | * 如果下载或读取res.version.latest出错,则Failed。 33 | * 如果res.version.latest == stream/res.version,则Succeed。(信任stream目录整体不被修改) 34 | * 进入Md5Check 35 | 36 | 2. CheckMd5 37 | 38 | * 如果persistent/res.md5存在且res.version.latest == persistent/res.version (自动修复的关键在这,不能信任persistent目录不被修改,信任的是单个文件不被修改) 39 | * 不用下载res.md5.latest。只读取2个目录res.md5。 40 | * 检测persistent/res.md5里对应的文件是否都存在,构建downloadList(如果在stream里,比较Md5和Size,不在的话在Persistent目录下找如果找到取文件长度比较Size,如果长度不同则删除)。(以免用户删除部分文件,或2下载res没完成)。 41 | * 如果downloadList为空则Succeed。 42 | * 否则,启动下载downloadList,进入DownloadRes。 43 | 44 | * 否则 45 | * 下载res.md5.lastest,读取2个目录res.md5和res.md5.latest。 46 | * 如果下载或读取res.md5.latest出错,则Failed。 47 | * 检测res.md5.latest里对应的文件是否都存在,构建downloadList(如果在stream里,比较Md5和Size,不在的话在Persistent目录下找,如果找到取文件长度比较Size,同时比较Persistent下res.md5里对应的Md5和Size,如果不同则删除)。 48 | * 把res.md5.lastest重命名为res.md5。 49 | * 把res.version.latest重命名为res.version。 50 | * 如果downloadList为空则Succeed。 51 | * 否则,启动下载downloadList,进入DownloadRes。 52 | 53 | 3. DownloadRes 54 | 55 | * 如果都下载完成没错误,进入Succeed 56 | * 否则Failed 57 | 58 | 4. Succeed 59 | 60 | 5. Failed 61 | 62 | ###使用 63 | 64 | 1. using(var ru = new ResUpdater(hosts, thread, reporter, startCoroutine)) { ru.Start() } 65 | 66 | 2. reporter来驱动UI 67 | 68 | ###Note 69 | 70 | 1. 虽然按照标准有query_string时http cache要带上query_string 作为key,但好像有的宽带提供商没按标准,所以最保险的策略可能是更新的时候改文件名字。 71 | 现在是加了"?version=md5"的方式。好像一般也没问题。 72 | 73 | 2. 下载完成没有计算文件的md5,来跟res.md5比对,感觉没必要,应该相信tcp,相信不会被中间缓存取成老版本。 74 | -------------------------------------------------------------------------------- /ResUpdater/AbstractCheckState.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.IO; 4 | using UnityEngine; 5 | 6 | namespace ResUpdater 7 | { 8 | public abstract class AbstractCheckState 9 | { 10 | protected readonly ResUpdater updater; 11 | private readonly string _name; 12 | private readonly string _latestName; 13 | 14 | protected AbstractCheckState(ResUpdater updater, string name, string latestName) 15 | { 16 | this.updater = updater; 17 | _name = name; 18 | _latestName = latestName; 19 | } 20 | 21 | protected abstract void OnDownloadError(Exception err); 22 | protected abstract void OnWWW(Loc loc, WWW www); 23 | 24 | internal IEnumerator StartRead(Loc loc) 25 | { 26 | string url; 27 | switch (loc) 28 | { 29 | case Loc.Stream: 30 | url = Application.streamingAssetsPath + "/" + _name; 31 | break; 32 | case Loc.Persistent: 33 | url = "file://" + Application.persistentDataPath + "/" + _name; 34 | break; 35 | default: 36 | url = "file://" + Application.persistentDataPath + "/" + _latestName; 37 | break; 38 | } 39 | 40 | WWW www = new WWW(url); 41 | yield return www; 42 | OnWWW(loc, www); 43 | } 44 | 45 | internal void OnDownloadCompleted(Exception err) 46 | { 47 | if (err == null) 48 | updater.StartCoroutine(StartRead(Loc.Latest)); 49 | else 50 | OnDownloadError(err); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /ResUpdater/CheckMd5State.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using UnityEngine; 5 | 6 | namespace ResUpdater 7 | { 8 | public class CheckMd5State : AbstractCheckState 9 | { 10 | public class Info 11 | { 12 | public readonly string Md5; 13 | public readonly int Size; 14 | 15 | public Info(string md5, int size) 16 | { 17 | Md5 = md5; 18 | Size = size; 19 | } 20 | 21 | public bool Equals(Info other) 22 | { 23 | return Md5.Equals(other.Md5) && Size == other.Size; 24 | } 25 | } 26 | 27 | private static readonly Dictionary empty = new Dictionary(); 28 | 29 | internal const string res_md5 = "res.md5"; 30 | internal const string res_md5_latest = "res.md5.latest"; 31 | 32 | public Dictionary StreamInfo { get; private set; } 33 | public Dictionary PersistentInfo { get; private set; } 34 | 35 | public bool DownloadLatest { get; private set; } 36 | public bool LatestOk { get; private set; } 37 | public Dictionary LatestInfo { get; private set; } 38 | 39 | 40 | public CheckMd5State(ResUpdater updater) : base(updater, res_md5, res_md5_latest) 41 | { 42 | } 43 | 44 | internal void Start() 45 | { 46 | updater.StartCoroutine(StartRead(Loc.Stream)); 47 | string path = Application.persistentDataPath + "/" + res_md5; 48 | if (File.Exists(path)) 49 | { 50 | updater.StartCoroutine(StartRead(Loc.Persistent)); 51 | if (updater.CheckVersion.PersistentVersion == updater.CheckVersion.LatestVersion) 52 | { 53 | DownloadLatest = false; 54 | LatestInfo = empty; 55 | } 56 | else 57 | { 58 | StartDownloadLatest(); 59 | } 60 | } 61 | else 62 | { 63 | PersistentInfo = empty; 64 | StartDownloadLatest(); 65 | } 66 | } 67 | 68 | private void StartDownloadLatest() 69 | { 70 | DownloadLatest = true; 71 | LatestInfo = null; 72 | updater.StartDownload(res_md5 + "?version=" + updater.CheckVersion.LatestVersion, res_md5_latest, true); 73 | } 74 | 75 | protected override void OnDownloadError(Exception err) 76 | { 77 | updater.Reporter.DownloadLatestMd5Err(err); 78 | LatestOk = false; 79 | LatestInfo = empty; 80 | check(); 81 | } 82 | 83 | protected override void OnWWW(Loc loc, WWW www) 84 | { 85 | Dictionary info; 86 | bool ok = false; 87 | if (www.error != null) 88 | { 89 | updater.Reporter.ReadMd5Err(loc, www.error, null); 90 | info = empty; 91 | } 92 | else 93 | { 94 | try 95 | { 96 | info = new Dictionary(); 97 | foreach (var line in www.text.Split('\n')) 98 | { 99 | var sp = line.Split(' '); 100 | var res = sp[0]; 101 | var md5 = sp[1]; 102 | var size = int.Parse(sp[2]); 103 | 104 | info.Add(res, new Info(md5, size)); 105 | } 106 | ok = true; 107 | } 108 | catch (Exception e) 109 | { 110 | updater.Reporter.ReadMd5Err(loc, null, e); 111 | info = empty; 112 | } 113 | } 114 | 115 | switch (loc) 116 | { 117 | case Loc.Stream: 118 | StreamInfo = info; 119 | break; 120 | case Loc.Persistent: 121 | if (!ok && !DownloadLatest) //try my best to recover 122 | { 123 | StartDownloadLatest(); 124 | } 125 | PersistentInfo = info; 126 | break; 127 | default: 128 | LatestOk = ok; 129 | LatestInfo = info; 130 | break; 131 | } 132 | 133 | check(); 134 | } 135 | 136 | 137 | private void check() 138 | { 139 | if (StreamInfo != null && PersistentInfo != null && LatestInfo != null) 140 | { 141 | if (DownloadLatest) 142 | { 143 | if (LatestOk) 144 | { 145 | doCheckResource(LatestInfo, true); 146 | } 147 | else 148 | { 149 | updater.Reporter.CheckMd5Done(State.Failed, null); 150 | } 151 | } 152 | else 153 | { 154 | doCheckResource(PersistentInfo, false); 155 | } 156 | } 157 | } 158 | 159 | private void doCheckResource(Dictionary target, bool isTargetLatest) 160 | { 161 | var downloadList = new Dictionary(); 162 | foreach (var kv in target) 163 | { 164 | var fn = kv.Key; 165 | var info = kv.Value; 166 | 167 | Info infoInStream; 168 | bool inStream = StreamInfo.TryGetValue(fn, out infoInStream) && 169 | infoInStream.Equals(info); 170 | 171 | if (inStream) 172 | { 173 | Res.resourcesInStreamWhenNotUseStreamVersion.Add(fn); 174 | } 175 | else 176 | { 177 | var fi = new FileInfo(Application.persistentDataPath + "/" + fn); 178 | if (fi.Exists) 179 | { 180 | if (fi.Length != info.Size) 181 | { 182 | downloadList.Add(fn, info); 183 | fi.Delete(); 184 | } 185 | else if (isTargetLatest) 186 | { 187 | Info infoInPersistent; 188 | bool inPersistent = PersistentInfo.TryGetValue(fn, out infoInPersistent) && 189 | infoInPersistent.Equals(info); 190 | 191 | if (!inPersistent) 192 | { 193 | downloadList.Add(fn, info); 194 | fi.Delete(); 195 | } 196 | } 197 | } 198 | else 199 | { 200 | downloadList.Add(fn, info); 201 | } 202 | } 203 | } 204 | 205 | if (isTargetLatest) 206 | { 207 | File.Replace(Application.persistentDataPath + "/" + res_md5_latest, 208 | Application.persistentDataPath + "/" + res_md5, null); 209 | File.Replace(Application.persistentDataPath + "/" + CheckVersionState.res_version_latest, 210 | Application.persistentDataPath + "/" + CheckVersionState.res_version, null); 211 | } 212 | 213 | if (downloadList.Count == 0) 214 | { 215 | updater.Reporter.CheckMd5Done(State.Succeed, null); 216 | } 217 | else 218 | { 219 | updater.Reporter.CheckMd5Done(State.DownloadRes, downloadList); 220 | updater.DownloadRes.Start(downloadList); 221 | } 222 | } 223 | } 224 | } -------------------------------------------------------------------------------- /ResUpdater/CheckVersionState.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using UnityEngine; 4 | 5 | namespace ResUpdater 6 | { 7 | public class CheckVersionState : AbstractCheckState 8 | { 9 | internal const string res_version = "res.version"; 10 | internal const string res_version_latest = "res.version.latest"; 11 | 12 | //0: err, >0 ok 13 | public int StreamVersion { get; private set; } 14 | public int PersistentVersion { get; private set; } 15 | public int LatestVersion { get; private set; } 16 | 17 | public int LocalVersion { get; private set; } 18 | 19 | 20 | public CheckVersionState(ResUpdater updater) : base(updater, res_version, res_version_latest) 21 | { 22 | } 23 | 24 | internal void Start() 25 | { 26 | StreamVersion = -1; 27 | PersistentVersion = -1; 28 | LatestVersion = -1; 29 | LocalVersion = -1; 30 | Res.useStreamVersion = false; 31 | Res.resourcesInStreamWhenNotUseStreamVersion.Clear(); 32 | 33 | updater.StartCoroutine(StartRead(Loc.Stream)); 34 | string path = Application.persistentDataPath + "/" + res_version; 35 | if (File.Exists(path)) 36 | { 37 | updater.StartCoroutine(StartRead(Loc.Persistent)); 38 | } 39 | else 40 | { 41 | PersistentVersion = 0; 42 | } 43 | 44 | updater.StartDownload(res_version + "?version=" + DateTime.Now.Ticks, res_version_latest, true); 45 | } 46 | 47 | protected override void OnDownloadError(Exception err) 48 | { 49 | updater.Reporter.DownloadLatestVersionErr(err); 50 | LatestVersion = 0; 51 | check(); 52 | } 53 | 54 | protected override void OnWWW(Loc loc, WWW www) 55 | { 56 | int version = 0; 57 | if (www.error != null) 58 | { 59 | updater.Reporter.ReadVersionErr(loc, www.error, null); 60 | } 61 | else 62 | { 63 | try 64 | { 65 | version = int.Parse(www.text); 66 | } 67 | catch (Exception e) 68 | { 69 | updater.Reporter.ReadVersionErr(loc, null, e); 70 | } 71 | } 72 | 73 | switch (loc) 74 | { 75 | case Loc.Stream: 76 | StreamVersion = version; 77 | break; 78 | case Loc.Persistent: 79 | PersistentVersion = version; 80 | break; 81 | default: 82 | LatestVersion = version; 83 | break; 84 | } 85 | check(); 86 | } 87 | 88 | private void check() 89 | { 90 | if (StreamVersion != -1 && PersistentVersion != -1 && LatestVersion != -1) 91 | { 92 | if (LatestVersion != 0) 93 | { 94 | if (LatestVersion == StreamVersion) 95 | { 96 | Res.useStreamVersion = true; 97 | updater.Reporter.CheckVersionDone(State.Succeed, LocalVersion, LatestVersion); 98 | } 99 | else 100 | { 101 | LocalVersion = Math.Max(StreamVersion, PersistentVersion); 102 | updater.Reporter.CheckVersionDone(State.CheckMd5, LocalVersion, LatestVersion); 103 | updater.CheckMd5.Start(); 104 | } 105 | } 106 | else 107 | { 108 | LocalVersion = Math.Max(StreamVersion, PersistentVersion); 109 | updater.Reporter.CheckVersionDone(State.Failed, LocalVersion, LatestVersion); 110 | } 111 | } 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /ResUpdater/DownloadResState.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace ResUpdater 5 | { 6 | public class DownloadResState 7 | { 8 | private readonly ResUpdater updater; 9 | public Dictionary DownloadList { get; private set; } 10 | public int OkCount { get; private set; } 11 | public int ErrCount { get; private set; } 12 | 13 | public DownloadResState(ResUpdater resUpdater) 14 | { 15 | updater = resUpdater; 16 | } 17 | 18 | public void Start(Dictionary needDownloads) 19 | { 20 | DownloadList = needDownloads; 21 | foreach (var kv in DownloadList) 22 | { 23 | updater.StartDownload(kv.Key + "?version=" + kv.Value.Md5, kv.Key, false); 24 | } 25 | } 26 | 27 | internal void OnDownloadCompleted(Exception err, string fn) 28 | { 29 | updater.Reporter.DownloadOneResComplete(err, fn, DownloadList[fn]); 30 | if (err != null) 31 | OkCount++; 32 | else 33 | ErrCount++; 34 | 35 | if ((OkCount + ErrCount) == DownloadList.Count) 36 | { 37 | if (ErrCount == 0) 38 | updater.Reporter.DownloadResDone(State.Succeed, 0); 39 | else 40 | updater.Reporter.DownloadResDone(State.Failed, ErrCount); 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /ResUpdater/Downloader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Net; 5 | 6 | namespace ResUpdater 7 | { 8 | internal class Downloader : IDisposable 9 | { 10 | private class Info 11 | { 12 | public string fn; 13 | public bool isHighPriority; 14 | public int tryCnt; 15 | public string url; 16 | } 17 | 18 | public delegate void DownloadDoneFunc(Exception err, string fn); 19 | 20 | 21 | private readonly string[] hosts; //前面的网络更好,带宽更贵,所以meta从前往后试,data从后往前试;只试一轮 22 | private readonly int thread; //同时download的文件数 23 | 24 | private readonly string outputPath; 25 | private readonly DownloadDoneFunc DownloadDone; 26 | 27 | private readonly WebClient webclient; 28 | 29 | private readonly Dictionary downloadings = new Dictionary(); 30 | private readonly Dictionary pendings = new Dictionary(); 31 | 32 | public Downloader(string[] hosts, int thread, string outputPath, DownloadDoneFunc downloadDone) 33 | { 34 | this.hosts = hosts; 35 | this.thread = thread; 36 | this.outputPath = outputPath; 37 | DownloadDone = downloadDone; 38 | webclient = new WebClient(); 39 | webclient.DownloadFileCompleted += OnDownloadFileCompleted; 40 | } 41 | 42 | 43 | public void StartDownload(string url, string fn, bool isHighPriority = false) 44 | { 45 | if (downloadings.ContainsKey(fn)) 46 | return; 47 | 48 | var info = new Info 49 | { 50 | url = url, 51 | fn = fn, 52 | isHighPriority = isHighPriority, 53 | tryCnt = 0 54 | }; 55 | 56 | if (downloadings.Count < thread) 57 | { 58 | downloadings.Add(fn, info); 59 | StartDownload(info); 60 | } 61 | else 62 | { 63 | if (pendings.ContainsKey(fn)) 64 | pendings[fn] = info; 65 | else 66 | pendings.Add(fn, info); 67 | } 68 | } 69 | 70 | private void StartDownload(Info info) 71 | { 72 | var idx = info.isHighPriority 73 | ? info.tryCnt 74 | : hosts.Length - 1 - info.tryCnt; 75 | webclient.DownloadFileAsync(new Uri(hosts[idx] + info.url), outputPath + info.fn, info.fn); 76 | info.tryCnt++; 77 | } 78 | 79 | private void OnDownloadFileCompleted(object sender, AsyncCompletedEventArgs e) 80 | { 81 | string fn = (string) e.UserState; 82 | if (e.Error == null) 83 | { 84 | oneDone(fn, null); 85 | } 86 | else 87 | { 88 | var info = downloadings[fn]; 89 | if (info.tryCnt < hosts.Length) 90 | { 91 | info.tryCnt++; 92 | StartDownload(info); 93 | } 94 | else 95 | { 96 | oneDone(fn, e.Error); 97 | } 98 | } 99 | } 100 | 101 | private void oneDone(string fn, Exception err) 102 | { 103 | downloadings.Remove(fn); 104 | DownloadDone(err, fn); 105 | 106 | if (pendings.Count > 0) 107 | { 108 | var e = pendings.GetEnumerator(); 109 | e.MoveNext(); 110 | var pfn = e.Current.Key; 111 | var pinfo = e.Current.Value; 112 | pendings.Remove(pfn); 113 | 114 | if (downloadings.ContainsKey(pfn)) 115 | downloadings[pfn] = pinfo; 116 | else 117 | downloadings.Add(pfn, pinfo); 118 | } 119 | } 120 | 121 | public void Dispose() 122 | { 123 | webclient.Dispose(); 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /ResUpdater/Reporter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace ResUpdater 5 | { 6 | public enum Loc 7 | { 8 | Stream, 9 | Persistent, 10 | Latest 11 | } 12 | 13 | public enum State 14 | { 15 | CheckVersion, 16 | CheckMd5, 17 | DownloadRes, 18 | Succeed, 19 | Failed 20 | } 21 | 22 | public interface Reporter 23 | { 24 | void DownloadLatestVersionErr(Exception err); 25 | 26 | void ReadVersionErr(Loc loc, string wwwErr, Exception parseErr); 27 | 28 | void CheckVersionDone(State nextState, int localVersion, int latestVersion); //CheckMd5, Succeed, Failed 29 | 30 | 31 | void DownloadLatestMd5Err(Exception err); 32 | 33 | void ReadMd5Err(Loc loc, string wwwwErr, Exception parseErr); 34 | 35 | void CheckMd5Done(State nextState, Dictionary downloadList); //DownloadRes, Succeed, Failed 36 | 37 | 38 | void DownloadOneResComplete(Exception err, string fn, CheckMd5State.Info info); 39 | 40 | void DownloadResDone(State nextState, int errCount); //Succeed, Failed 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /ResUpdater/Res.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace ResUpdater 4 | { 5 | public static class Res //给lua用 6 | { 7 | public static bool useStreamVersion; 8 | public static readonly HashSet resourcesInStreamWhenNotUseStreamVersion = new HashSet(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ResUpdater/ResUpdater.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using UnityEngine; 4 | 5 | namespace ResUpdater 6 | { 7 | public delegate Coroutine StartCoroutineFunc(IEnumerator routine); 8 | 9 | public class ResUpdater : IDisposable 10 | { 11 | private readonly Downloader downloader; 12 | 13 | internal readonly Reporter Reporter; 14 | internal readonly StartCoroutineFunc StartCoroutine; 15 | 16 | public readonly CheckVersionState CheckVersion; 17 | public readonly CheckMd5State CheckMd5; 18 | public readonly DownloadResState DownloadRes; 19 | 20 | public ResUpdater(string[] hosts, int thread, Reporter reporter, StartCoroutineFunc startCoroutine) 21 | { 22 | downloader = new Downloader(hosts, thread, Application.persistentDataPath, DownloadDone); 23 | Reporter = reporter; 24 | StartCoroutine = startCoroutine; 25 | 26 | CheckVersion = new CheckVersionState(this); 27 | CheckMd5 = new CheckMd5State(this); 28 | DownloadRes = new DownloadResState(this); 29 | } 30 | 31 | public void Start() 32 | { 33 | CheckVersion.Start(); 34 | } 35 | 36 | public void Dispose() 37 | { 38 | downloader.Dispose(); 39 | } 40 | 41 | internal void StartDownload(string url, string fn, bool isHighPriority) 42 | { 43 | downloader.StartDownload(url, fn, isHighPriority); 44 | } 45 | 46 | private void DownloadDone(Exception err, string fn) 47 | { 48 | switch (fn) 49 | { 50 | case CheckVersionState.res_version_latest: 51 | CheckVersion.OnDownloadCompleted(err); 52 | break; 53 | case CheckMd5State.res_md5_latest: 54 | CheckMd5.OnDownloadCompleted(err); 55 | break; 56 | default: 57 | DownloadRes.OnDownloadCompleted(err, fn); 58 | break; 59 | } 60 | } 61 | } 62 | } --------------------------------------------------------------------------------